From a5f508f1ff756b73438ccb57b934acfdad7d6acc Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:58:32 -0800 Subject: [PATCH 001/256] Display pipeline summary info after pipeline succeeds (#13970) * Display pipeline summary info after pipeline succeeds * Fix concurrency issue * Use options object * Fix build * Update surface API --------- Co-authored-by: Sebastien Ros --- .../BackchannelJsonSerializerContext.cs | 1 + .../Commands/PipelineCommandBase.cs | 4 +- src/Aspire.Cli/Utils/ConsoleActivityLogger.cs | 38 ++++- .../AzureEnvironmentResource.cs | 24 +++ .../Backchannel/BackchannelDataTypes.cs | 7 + .../CompatibilitySuppressions.xml | 7 + .../Pipelines/IPipelineActivityReporter.cs | 8 + .../Pipelines/NullPipelineActivityReporter.cs | 7 + .../Pipelines/PipelineActivityReporter.cs | 19 ++- .../Pipelines/PipelineContext.cs | 17 ++ .../Pipelines/PipelineStepContext.cs | 17 ++ .../Pipelines/PipelineSummary.cs | 89 ++++++++++ .../Pipelines/PublishCompletionOptions.cs | 30 ++++ .../Publishing/PipelineExecutor.cs | 18 +- .../Pipelines/PipelineSummaryTests.cs | 154 ++++++++++++++++++ .../PipelineActivityReporterTests.cs | 57 ++++++- tests/Shared/TestPipelineActivityReporter.cs | 33 +++- 17 files changed, 505 insertions(+), 25 deletions(-) create mode 100644 src/Aspire.Hosting/Pipelines/PipelineSummary.cs create mode 100644 src/Aspire.Hosting/Pipelines/PublishCompletionOptions.cs create mode 100644 tests/Aspire.Hosting.Tests/Pipelines/PipelineSummaryTests.cs diff --git a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs index 069008d8629..32aa129a3cd 100644 --- a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs +++ b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs @@ -37,6 +37,7 @@ namespace Aspire.Cli.Backchannel; [JsonSerializable(typeof(EnvVar))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List>))] [JsonSerializable(typeof(bool?))] [JsonSerializable(typeof(AppHostProjectSearchResultPoco))] [JsonSerializable(typeof(DashboardMcpConnectionInfo))] diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index 2f3b4f227d1..46813c44d5d 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -638,7 +638,9 @@ var cs when IsCompletionStateWarning(cs) => ConsoleActivityLogger.ActivityState. logger.SetStepDurations(durationRecords); // Provide final result to logger and print its structured summary. - logger.SetFinalResult(!hasErrors); + // Pass the pipeline summary if available for successful pipelines + var pipelineSummary = !hasErrors ? publishingActivity.Data.PipelineSummary : null; + logger.SetFinalResult(!hasErrors, pipelineSummary); logger.WriteSummary(); // Visual bell diff --git a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs index 5f9fc13941b..5a723f60c2a 100644 --- a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs +++ b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs @@ -40,6 +40,7 @@ internal sealed class ConsoleActivityLogger private string? _finalStatusHeader; private bool _pipelineSucceeded; + private IReadOnlyList>? _pipelineSummary; // No raw ANSI escape codes; rely on Spectre.Console markup tokens. @@ -274,6 +275,19 @@ public void WriteSummary() { _console.MarkupLine(_finalStatusHeader!); + // Display pipeline summary if available (for successful deployments) + // Store in local variable to avoid potential threading issues + var pipelineSummary = _pipelineSummary; + if (_pipelineSucceeded && pipelineSummary is { Count: > 0 }) + { + _console.WriteLine(); + foreach (var kvp in pipelineSummary) + { + var formattedLine = FormatPipelineSummaryKvp(kvp.Key, kvp.Value); + _console.MarkupLine(formattedLine); + } + } + // If pipeline failed and not already in debug/trace mode, show help message about using --log-level debug if (!_pipelineSucceeded && !_isDebugOrTraceLoggingEnabled) { @@ -289,12 +303,32 @@ public void WriteSummary() } /// - /// Sets the final deployment result lines to be displayed in the summary (e.g., DEPLOYMENT FAILED ...). + /// Formats a single key-value pair for the pipeline summary display. + /// + private string FormatPipelineSummaryKvp(string key, string value) + { + if (_enableColor) + { + var escapedKey = key.EscapeMarkup(); + var escapedValue = value.EscapeMarkup(); + return $" [blue]{escapedKey}[/]: {escapedValue}"; + } + else + { + return $" {key}: {value}"; + } + } + + /// + /// Sets the final pipeline result lines to be displayed in the summary (e.g., PIPELINE FAILED ...). /// Optional usage so existing callers remain compatible. /// - public void SetFinalResult(bool succeeded) + /// Whether the pipeline succeeded. + /// Optional pipeline summary as key-value pairs to display after the result. The list preserves insertion order. + public void SetFinalResult(bool succeeded, IReadOnlyList>? pipelineSummary = null) { _pipelineSucceeded = succeeded; + _pipelineSummary = pipelineSummary; // Always show only a single final header line with symbol; no per-step duplication. if (succeeded) { diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 34fbcd659c3..19a62a9e3e5 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -95,6 +95,9 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet var provisioningContextProvider = ctx.Services.GetRequiredService(); var provisioningContext = await provisioningContextProvider.CreateProvisioningContextAsync(ctx.CancellationToken).ConfigureAwait(false); ProvisioningContextTask.TrySetResult(provisioningContext); + + // Add Azure deployment information to the pipeline summary + AddToPipelineSummary(ctx, provisioningContext); }, RequiredBySteps = [WellKnownPipelineSteps.Deploy], DependsOnSteps = [WellKnownPipelineSteps.DeployPrereq] @@ -123,6 +126,27 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet PrincipalId = principalId; } + /// + /// Adds Azure deployment information to the pipeline summary. + /// + /// The pipeline step context. + /// The Azure provisioning context. + private static void AddToPipelineSummary(PipelineStepContext ctx, ProvisioningContext provisioningContext) + { + // Safely access the nested properties with null checks for reference types + // AzureLocation is a struct so it cannot be null + var resourceGroupName = provisioningContext.ResourceGroup?.Name ?? "unknown"; + var subscriptionId = provisioningContext.Subscription?.Id.Name ?? "unknown"; + var location = provisioningContext.Location.Name; + +#pragma warning disable ASPIREPIPELINES001 // PipelineSummary is experimental + ctx.Summary.Add("☁️ Target", "Azure"); + ctx.Summary.Add("📦 Resource Group", resourceGroupName); + ctx.Summary.Add("🔑 Subscription", subscriptionId); + ctx.Summary.Add("🌐 Location", location); +#pragma warning restore ASPIREPIPELINES001 + } + private Task PublishAsync(PipelineStepContext context) { var azureProvisioningOptions = context.Services.GetRequiredService>(); diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 6335ba766f1..ddef85facb2 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -360,6 +360,13 @@ internal sealed class PublishingActivityData /// public string? CompletionMessage { get; init; } + /// + /// Gets the pipeline summary information to display after pipeline completion. + /// This is a list of key-value pairs with deployment targets, resource names, URLs, etc. + /// The list preserves the order items were added. + /// + public IReadOnlyList>? PipelineSummary { get; init; } + /// /// Gets the input information for prompt activities, if available. /// diff --git a/src/Aspire.Hosting/CompatibilitySuppressions.xml b/src/Aspire.Hosting/CompatibilitySuppressions.xml index 2a2a7a8471d..f953a02bdcb 100644 --- a/src/Aspire.Hosting/CompatibilitySuppressions.xml +++ b/src/Aspire.Hosting/CompatibilitySuppressions.xml @@ -57,6 +57,13 @@ lib/net8.0/Aspire.Hosting.dll true + + CP0006 + M:Aspire.Hosting.Pipelines.IPipelineActivityReporter.CompletePublishAsync(Aspire.Hosting.Pipelines.PublishCompletionOptions,System.Threading.CancellationToken) + lib/net8.0/Aspire.Hosting.dll + lib/net8.0/Aspire.Hosting.dll + true + CP0006 P:Aspire.Hosting.IDeveloperCertificateService.UseForHttps diff --git a/src/Aspire.Hosting/Pipelines/IPipelineActivityReporter.cs b/src/Aspire.Hosting/Pipelines/IPipelineActivityReporter.cs index 9a4a093e99b..92c1982d04e 100644 --- a/src/Aspire.Hosting/Pipelines/IPipelineActivityReporter.cs +++ b/src/Aspire.Hosting/Pipelines/IPipelineActivityReporter.cs @@ -19,11 +19,19 @@ public interface IPipelineActivityReporter /// The publishing step Task CreateStepAsync(string title, CancellationToken cancellationToken = default); + /// + /// Signals that the entire publishing process has completed. + /// + /// The options for completing the publishing process. + /// The cancellation token. + Task CompletePublishAsync(PublishCompletionOptions? options = null, CancellationToken cancellationToken = default); + /// /// Signals that the entire publishing process has completed. /// /// The completion message of the publishing process. /// The completion state of the publishing process. When null, the state is automatically aggregated from all steps. /// The cancellation token. + [Obsolete("Use CompletePublishAsync(PublishCompletionOptions?, CancellationToken) instead.")] Task CompletePublishAsync(string? completionMessage = null, CompletionState? completionState = null, CancellationToken cancellationToken = default); } diff --git a/src/Aspire.Hosting/Pipelines/NullPipelineActivityReporter.cs b/src/Aspire.Hosting/Pipelines/NullPipelineActivityReporter.cs index eea872c3882..3a73b79bf8b 100644 --- a/src/Aspire.Hosting/Pipelines/NullPipelineActivityReporter.cs +++ b/src/Aspire.Hosting/Pipelines/NullPipelineActivityReporter.cs @@ -21,6 +21,13 @@ public Task CreateStepAsync(string title, CancellationToken canc } /// + public Task CompletePublishAsync(PublishCompletionOptions? options = null, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + /// + [Obsolete("Use CompletePublishAsync(PublishCompletionOptions?, CancellationToken) instead.")] public Task CompletePublishAsync(string? completionMessage = null, CompletionState? completionState = null, CancellationToken cancellationToken = default) { return Task.CompletedTask; diff --git a/src/Aspire.Hosting/Pipelines/PipelineActivityReporter.cs b/src/Aspire.Hosting/Pipelines/PipelineActivityReporter.cs index 72af3b03c2a..fbae605f6df 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineActivityReporter.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineActivityReporter.cs @@ -229,10 +229,10 @@ public async Task CompleteTaskAsync(ReportingTask task, CompletionState completi await ActivityItemUpdated.Writer.WriteAsync(state, cancellationToken).ConfigureAwait(false); } - public async Task CompletePublishAsync(string? completionMessage = null, CompletionState? completionState = null, CancellationToken cancellationToken = default) + public async Task CompletePublishAsync(PublishCompletionOptions? options = null, CancellationToken cancellationToken = default) { // Use provided state or aggregate from all steps - var finalState = completionState ?? CalculateOverallAggregatedState(); + var finalState = options?.CompletionState ?? CalculateOverallAggregatedState(); var state = new PublishingActivity { @@ -240,20 +240,31 @@ public async Task CompletePublishAsync(string? completionMessage = null, Complet Data = new PublishingActivityData { Id = PublishingActivityTypes.PublishComplete, - StatusText = completionMessage ?? finalState switch + StatusText = options?.CompletionMessage ?? finalState switch { CompletionState.Completed => "Pipeline completed successfully", CompletionState.CompletedWithWarning => "Pipeline completed with warnings", CompletionState.CompletedWithError => "Pipeline completed with errors", _ => "Pipeline completed" }, - CompletionState = ToBackchannelCompletionState(finalState) + CompletionState = ToBackchannelCompletionState(finalState), + PipelineSummary = options?.PipelineSummary } }; await ActivityItemUpdated.Writer.WriteAsync(state, cancellationToken).ConfigureAwait(false); } + [Obsolete("Use CompletePublishAsync(PublishCompletionOptions?, CancellationToken) instead.")] + public Task CompletePublishAsync(string? completionMessage = null, CompletionState? completionState = null, CancellationToken cancellationToken = default) + { + return CompletePublishAsync(new PublishCompletionOptions + { + CompletionMessage = completionMessage, + CompletionState = completionState + }, cancellationToken); + } + /// /// Calculates the overall completion state by aggregating all steps. /// diff --git a/src/Aspire.Hosting/Pipelines/PipelineContext.cs b/src/Aspire.Hosting/Pipelines/PipelineContext.cs index 9baaa86874d..a2906a9cc22 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineContext.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineContext.cs @@ -47,4 +47,21 @@ public sealed class PipelineContext( /// Gets the cancellation token for the pipeline operation. /// public CancellationToken CancellationToken { get; set; } = cancellationToken; + + /// + /// Gets the pipeline summary that steps can add information to. + /// The summary will be displayed to users after pipeline execution completes. + /// + /// + /// Pipeline steps can add key-value pairs to the summary to provide useful information + /// about the pipeline execution, such as deployment targets, resource names, URLs, etc. + /// + /// + /// + /// // In a pipeline step + /// context.PipelineContext.Summary.Add("☁️ Target", "Azure"); + /// context.PipelineContext.Summary.Add("📦 Resource Group", "rg-myapp"); + /// + /// + public PipelineSummary Summary { get; } = new(); } diff --git a/src/Aspire.Hosting/Pipelines/PipelineStepContext.cs b/src/Aspire.Hosting/Pipelines/PipelineStepContext.cs index d3d7faa47fd..85e80977bc0 100644 --- a/src/Aspire.Hosting/Pipelines/PipelineStepContext.cs +++ b/src/Aspire.Hosting/Pipelines/PipelineStepContext.cs @@ -54,4 +54,21 @@ public sealed class PipelineStepContext /// Gets the cancellation token for the pipeline operation. /// public CancellationToken CancellationToken => PipelineContext.CancellationToken; + + /// + /// Gets the pipeline summary that steps can add information to. + /// The summary will be displayed to users after pipeline execution completes. + /// + /// + /// Pipeline steps can add key-value pairs to the summary to provide useful information + /// about the pipeline execution, such as deployment targets, resource names, URLs, etc. + /// + /// + /// + /// // In a pipeline step + /// context.Summary.Add("☁️ Target", "Azure"); + /// context.Summary.Add("📦 Resource Group", "rg-myapp"); + /// + /// + public PipelineSummary Summary => PipelineContext.Summary; } \ No newline at end of file diff --git a/src/Aspire.Hosting/Pipelines/PipelineSummary.cs b/src/Aspire.Hosting/Pipelines/PipelineSummary.cs new file mode 100644 index 00000000000..f6f2f15fa2b --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/PipelineSummary.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Represents pipeline summary information to be displayed after pipeline completion. +/// This is a general-purpose key-value collection that pipeline steps can contribute to. +/// +/// +/// +/// This class provides a flexible way for any pipeline step to contribute +/// information to be displayed after pipeline execution. The data is stored as +/// key-value pairs that will be formatted as a table or list in the CLI output. +/// +/// +/// Pipeline steps can add any relevant information such as resource group names, +/// subscription IDs, URLs, namespaces, cluster names, or any other details. +/// +/// +/// The summary is available via the +/// property and can be accessed from any pipeline step. +/// +/// +/// +/// +/// // In a pipeline step, add to the summary +/// public async Task ExecuteAsync(PipelineStepContext context) +/// { +/// // Do work... +/// +/// // Add summary items +/// context.PipelineContext.Summary.Add("☁️ Target", "Azure"); +/// context.PipelineContext.Summary.Add("📦 Resource Group", "rg-myapp"); +/// context.PipelineContext.Summary.Add("🔑 Subscription", "12345678-1234-1234-1234-123456789012"); +/// context.PipelineContext.Summary.Add("🌐 Location", "eastus"); +/// } +/// +/// // Kubernetes example +/// context.PipelineContext.Summary.Add("☸️ Target", "Kubernetes"); +/// context.PipelineContext.Summary.Add("📦 Namespace", "production"); +/// context.PipelineContext.Summary.Add("🖥️ Cluster", "my-cluster"); +/// +/// // Docker example +/// context.PipelineContext.Summary.Add("🐳 Target", "Docker"); +/// context.PipelineContext.Summary.Add("🌐 Endpoint", "localhost:8080"); +/// +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics#{0}")] +public sealed class PipelineSummary +{ + private readonly object _lock = new(); + private readonly List> _items = []; + + /// + /// Gets the items in the pipeline summary as a read-only collection. + /// Items are displayed in the order they were added. + /// + public ReadOnlyCollection> Items + { + get + { + lock (_lock) + { + return new ReadOnlyCollection>(_items.ToList()); + } + } + } + + /// + /// Adds a key-value pair to the pipeline summary. + /// + /// The key or label for the item (e.g., "Resource Group", "Namespace", "URL"). + /// The value for the item. + public void Add(string key, string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentException.ThrowIfNullOrWhiteSpace(value); + + lock (_lock) + { + _items.Add(new KeyValuePair(key, value)); + } + } + +} diff --git a/src/Aspire.Hosting/Pipelines/PublishCompletionOptions.cs b/src/Aspire.Hosting/Pipelines/PublishCompletionOptions.cs new file mode 100644 index 00000000000..560107813a5 --- /dev/null +++ b/src/Aspire.Hosting/Pipelines/PublishCompletionOptions.cs @@ -0,0 +1,30 @@ +// 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; + +namespace Aspire.Hosting.Pipelines; + +/// +/// Options for completing the publishing process. +/// +[Experimental("ASPIREPIPELINES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class PublishCompletionOptions +{ + /// + /// Gets or sets the completion message of the publishing process. + /// + public string? CompletionMessage { get; set; } + + /// + /// Gets or sets the completion state of the publishing process. + /// When , the state is automatically aggregated from all steps. + /// + public CompletionState? CompletionState { get; set; } + + /// + /// Gets or sets optional pipeline summary information as key-value pairs to display after completion. + /// The list preserves insertion order. + /// + public IReadOnlyList>? PipelineSummary { get; set; } +} diff --git a/src/Aspire.Hosting/Publishing/PipelineExecutor.cs b/src/Aspire.Hosting/Publishing/PipelineExecutor.cs index f2c92cce020..adfeeb3a505 100644 --- a/src/Aspire.Hosting/Publishing/PipelineExecutor.cs +++ b/src/Aspire.Hosting/Publishing/PipelineExecutor.cs @@ -52,17 +52,18 @@ await eventing.PublishAsync( new BeforePublishEvent(serviceProvider, model), stoppingToken ).ConfigureAwait(false); - await ExecutePipelineAsync(model, stoppingToken).ConfigureAwait(false); + // Execute the pipeline and get the summary from the context + var pipelineSummary = await ExecutePipelineAsync(model, stoppingToken).ConfigureAwait(false); await eventing.PublishAsync( new AfterPublishEvent(serviceProvider, model), stoppingToken ).ConfigureAwait(false); - // We pass null here so the aggregate state can be calculated based on the state of - // each of the pipeline steps that have been enumerated. + // Get the pipeline summary items (preserves insertion order) + var summaryItems = pipelineSummary.Items.Count > 0 ? pipelineSummary.Items : null; await step.SucceedAsync(cancellationToken: stoppingToken).ConfigureAwait(false); - await activityReporter.CompletePublishAsync(completionMessage: null, completionState: null, cancellationToken: stoppingToken).ConfigureAwait(false); + await activityReporter.CompletePublishAsync(new PublishCompletionOptions { PipelineSummary = summaryItems }, stoppingToken).ConfigureAwait(false); // If we are running in publish mode and a backchannel is being // used then we don't want to stop the app host. Instead the @@ -80,7 +81,7 @@ await eventing.PublishAsync( await step.FailAsync(cancellationToken: stoppingToken).ConfigureAwait(false); - await activityReporter.CompletePublishAsync(completionMessage: ex.Message, completionState: CompletionState.CompletedWithError, cancellationToken: stoppingToken).ConfigureAwait(false); + await activityReporter.CompletePublishAsync(new PublishCompletionOptions { CompletionMessage = ex.Message, CompletionState = CompletionState.CompletedWithError }, stoppingToken).ConfigureAwait(false); if (!backchannelService.IsBackchannelExpected) { @@ -95,11 +96,16 @@ await eventing.PublishAsync( } } - public async Task ExecutePipelineAsync(DistributedApplicationModel model, CancellationToken cancellationToken) + /// + /// Executes the pipeline and returns the summary that was populated by pipeline steps. + /// + public async Task ExecutePipelineAsync(DistributedApplicationModel model, CancellationToken cancellationToken) { var pipelineContext = new PipelineContext(model, executionContext, serviceProvider, logger, cancellationToken); var pipeline = serviceProvider.GetRequiredService(); await pipeline.ExecuteAsync(pipelineContext).ConfigureAwait(false); + + return pipelineContext.Summary; } } diff --git a/tests/Aspire.Hosting.Tests/Pipelines/PipelineSummaryTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/PipelineSummaryTests.cs new file mode 100644 index 00000000000..6488259f57d --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Pipelines/PipelineSummaryTests.cs @@ -0,0 +1,154 @@ +// 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 ASPIREPIPELINES001 + +using Aspire.Hosting.Pipelines; + +namespace Aspire.Hosting.Tests.Pipelines; + +public class PipelineSummaryTests +{ + [Fact] + public void Add_WithValidKeyAndValue_AddsItemToCollection() + { + // Arrange + var summary = new PipelineSummary(); + + // Act + summary.Add("Key1", "Value1"); + + // Assert + Assert.Single(summary.Items); + Assert.Equal("Key1", summary.Items[0].Key); + Assert.Equal("Value1", summary.Items[0].Value); + } + + [Fact] + public void Add_MultipleItems_PreservesInsertionOrder() + { + // Arrange + var summary = new PipelineSummary(); + + // Act + summary.Add("First", "1"); + summary.Add("Second", "2"); + summary.Add("Third", "3"); + + // Assert + Assert.Equal(3, summary.Items.Count); + Assert.Equal("First", summary.Items[0].Key); + Assert.Equal("Second", summary.Items[1].Key); + Assert.Equal("Third", summary.Items[2].Key); + } + + [Fact] + public void Add_DuplicateKeys_AllowsBothInItems() + { + // Arrange + var summary = new PipelineSummary(); + + // Act + summary.Add("Key", "Value1"); + summary.Add("Key", "Value2"); + + // Assert + Assert.Equal(2, summary.Items.Count); + Assert.Equal("Value1", summary.Items[0].Value); + Assert.Equal("Value2", summary.Items[1].Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Add_WithNullOrWhitespaceKey_ThrowsArgumentException(string? key) + { + // Arrange + var summary = new PipelineSummary(); + + // Act & Assert + Assert.ThrowsAny(() => summary.Add(key!, "value")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Add_WithNullOrWhitespaceValue_ThrowsArgumentException(string? value) + { + // Arrange + var summary = new PipelineSummary(); + + // Act & Assert + Assert.ThrowsAny(() => summary.Add("key", value!)); + } + + [Fact] + public void Items_ReturnsReadOnlyCollection() + { + // Arrange + var summary = new PipelineSummary(); + summary.Add("Key", "Value"); + + // Act + var items = summary.Items; + + // Assert + Assert.IsType>>(items); + } + + [Fact] + public void Items_WhenEmpty_ReturnsEmptyCollection() + { + // Arrange + var summary = new PipelineSummary(); + + // Act & Assert + Assert.Empty(summary.Items); + } + + [Fact] + public void Items_WithItems_ReturnsItemsInInsertionOrder() + { + // Arrange + var summary = new PipelineSummary(); + summary.Add("Key1", "Value1"); + summary.Add("Key2", "Value2"); + + // Act & Assert + Assert.Equal(2, summary.Items.Count); + Assert.Equal(new KeyValuePair("Key1", "Value1"), summary.Items[0]); + Assert.Equal(new KeyValuePair("Key2", "Value2"), summary.Items[1]); + } + + [Fact] + public void Items_WithDuplicateKeys_PreservesAllEntries() + { + // Arrange + var summary = new PipelineSummary(); + summary.Add("Key", "FirstValue"); + summary.Add("Key", "LastValue"); + + // Act & Assert + Assert.Equal(2, summary.Items.Count); + Assert.Equal(new KeyValuePair("Key", "FirstValue"), summary.Items[0]); + Assert.Equal(new KeyValuePair("Key", "LastValue"), summary.Items[1]); + } + + [Fact] + public void Add_WithUnicodeCharactersInKey_Succeeds() + { + // Arrange + var summary = new PipelineSummary(); + + // Act + summary.Add("☁️ Target", "Azure"); + summary.Add("📦 Resource Group", "rg-test"); + + // Assert + Assert.Equal(2, summary.Items.Count); + Assert.Equal("☁️ Target", summary.Items[0].Key); + Assert.Equal("📦 Resource Group", summary.Items[1].Key); + } +} diff --git a/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs b/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs index 08390719213..f5b838e838e 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/PipelineActivityReporterTests.cs @@ -287,7 +287,7 @@ public async Task CompletePublishAsync_EmitsCorrectActivity(CompletionState comp var reporter = CreatePublishingReporter(); // Act - await reporter.CompletePublishAsync(null, completionState, CancellationToken.None); + await reporter.CompletePublishAsync(new PublishCompletionOptions { CompletionState = completionState }, CancellationToken.None); // Assert var activityReader = reporter.ActivityItemUpdated.Reader; @@ -308,7 +308,7 @@ public async Task CompletePublishAsync_EmitsCorrectActivity_WithCompletionMessag var expectedStatusText = "Some error occurred"; // Act - await reporter.CompletePublishAsync(expectedStatusText, CompletionState.CompletedWithError, CancellationToken.None); + await reporter.CompletePublishAsync(new PublishCompletionOptions { CompletionMessage = expectedStatusText, CompletionState = CompletionState.CompletedWithError }, CancellationToken.None); // Assert var activityReader = reporter.ActivityItemUpdated.Reader; @@ -348,7 +348,7 @@ public async Task CompletePublishAsync_AggregatesStateFromSteps() while (activityReader.TryRead(out _)) { } // Act - Complete publish without specifying state (should aggregate) - await reporter.CompletePublishAsync(cancellationToken: CancellationToken.None); + await reporter.CompletePublishAsync(options: null, cancellationToken: CancellationToken.None); // Assert Assert.True(activityReader.TryRead(out var activity)); @@ -813,7 +813,7 @@ public async Task CompletePublishAsync_WithDeployFlag_EmitsCorrectActivity(Compl var reporter = CreatePublishingReporter(); // Act - await reporter.CompletePublishAsync(null, completionState, CancellationToken.None); + await reporter.CompletePublishAsync(new PublishCompletionOptions { CompletionState = completionState }, CancellationToken.None); // Assert var activityReader = reporter.ActivityItemUpdated.Reader; @@ -834,7 +834,7 @@ public async Task CompletePublishAsync_WithDeployFlag_EmitsCorrectActivity_WithC var expectedStatusText = "Some deployment error occurred"; // Act - await reporter.CompletePublishAsync(expectedStatusText, CompletionState.CompletedWithError, CancellationToken.None); + await reporter.CompletePublishAsync(new PublishCompletionOptions { CompletionMessage = expectedStatusText, CompletionState = CompletionState.CompletedWithError }, CancellationToken.None); // Assert var activityReader = reporter.ActivityItemUpdated.Reader; @@ -846,6 +846,53 @@ public async Task CompletePublishAsync_WithDeployFlag_EmitsCorrectActivity_WithC Assert.True(activity.Data.IsError); } + [Fact] + public async Task CompletePublishAsync_WithPipelineSummary_IncludesSummaryInActivity() + { + // Arrange + var reporter = CreatePublishingReporter(); + var pipelineSummary = new List> + { + new("Target", "TestTarget"), + new("Environment", "test-env"), + new("Identifier", "test-123"), + new("Region", "test-region") + }; + + // Act + await reporter.CompletePublishAsync(new PublishCompletionOptions { CompletionState = CompletionState.Completed, PipelineSummary = pipelineSummary }, CancellationToken.None); + + // Assert + var activityReader = reporter.ActivityItemUpdated.Reader; + Assert.True(activityReader.TryRead(out var activity)); + Assert.Equal(PublishingActivityTypes.PublishComplete, activity.Type); + Assert.NotNull(activity.Data.PipelineSummary); + Assert.Equal(4, activity.Data.PipelineSummary.Count); + Assert.Equal(new KeyValuePair("Target", "TestTarget"), activity.Data.PipelineSummary[0]); + Assert.Equal(new KeyValuePair("Environment", "test-env"), activity.Data.PipelineSummary[1]); + Assert.Equal(new KeyValuePair("Identifier", "test-123"), activity.Data.PipelineSummary[2]); + Assert.Equal(new KeyValuePair("Region", "test-region"), activity.Data.PipelineSummary[3]); + Assert.True(activity.Data.IsComplete); + Assert.False(activity.Data.IsError); + } + + [Fact] + public async Task CompletePublishAsync_WithNullPipelineSummary_OmitsSummaryFromActivity() + { + // Arrange + var reporter = CreatePublishingReporter(); + + // Act + await reporter.CompletePublishAsync(new PublishCompletionOptions { CompletionState = CompletionState.Completed }, CancellationToken.None); + + // Assert + var activityReader = reporter.ActivityItemUpdated.Reader; + Assert.True(activityReader.TryRead(out var activity)); + Assert.Equal(PublishingActivityTypes.PublishComplete, activity.Type); + Assert.Null(activity.Data.PipelineSummary); + Assert.True(activity.Data.IsComplete); + } + [Fact] public async Task CreateStepAsync_WithMarkdownText_PreservesMarkdown() { diff --git a/tests/Shared/TestPipelineActivityReporter.cs b/tests/Shared/TestPipelineActivityReporter.cs index 7cb684fce38..16e198054c8 100644 --- a/tests/Shared/TestPipelineActivityReporter.cs +++ b/tests/Shared/TestPipelineActivityReporter.cs @@ -66,20 +66,25 @@ public TestPipelineActivityReporter(ITestOutputHelper testOutputHelper) public Action? OnStepCompleted { get; set; } /// - /// Gets a value indicating whether has been called. + /// Gets a value indicating whether has been called. /// public bool CompletePublishCalled { get; private set; } /// - /// Gets the completion message passed to . + /// Gets the completion message passed to . /// public string? CompletionMessage { get; private set; } /// - /// Gets the completion state passed to . + /// Gets the completion state passed to . /// public CompletionState? ResultCompletionState { get; private set; } + /// + /// Gets the pipeline summary passed to . + /// + public IReadOnlyList>? PipelineSummary { get; private set; } + /// /// Clears all captured state to allow reuse between pipeline runs. /// @@ -112,19 +117,33 @@ public void Clear() CompletePublishCalled = false; CompletionMessage = null; ResultCompletionState = null; + PipelineSummary = null; } /// - public Task CompletePublishAsync(string? completionMessage = null, CompletionState? completionState = null, CancellationToken cancellationToken = default) + public Task CompletePublishAsync(PublishCompletionOptions? options = null, CancellationToken cancellationToken = default) { CompletePublishCalled = true; - CompletionMessage = completionMessage; - ResultCompletionState = completionState; - _testOutputHelper.WriteLine($"[CompletePublish] {completionMessage} (State: {completionState})"); + CompletionMessage = options?.CompletionMessage; + ResultCompletionState = options?.CompletionState; + PipelineSummary = options?.PipelineSummary; + var summaryStr = options?.PipelineSummary != null ? string.Join(", ", options.PipelineSummary.Select(kvp => $"{kvp.Key}={kvp.Value}")) : null; + _testOutputHelper.WriteLine($"[CompletePublish] {options?.CompletionMessage} (State: {options?.CompletionState}) (Summary: {summaryStr})"); return Task.CompletedTask; } + /// + [Obsolete("Use CompletePublishAsync(PublishCompletionOptions?, CancellationToken) instead.")] + public Task CompletePublishAsync(string? completionMessage = null, CompletionState? completionState = null, CancellationToken cancellationToken = default) + { + return CompletePublishAsync(new PublishCompletionOptions + { + CompletionMessage = completionMessage, + CompletionState = completionState + }, cancellationToken); + } + /// public Task CreateStepAsync(string title, CancellationToken cancellationToken = default) { From e55fdbdd2e28866e738c2c3569ffe9d640e1a228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Fri, 30 Jan 2026 14:57:32 -0800 Subject: [PATCH 002/256] Create Aspire.Hosting.SqlServer exports (#14239) * Create Aspire.Hosting.SqlServer exports * Add validation app --- .gitignore | 3 + .../.aspire/settings.json | 9 + .../Aspire.Hosting.SqlServer/apphost.run.json | 13 + .../Aspire.Hosting.SqlServer/apphost.ts | 34 + .../package-lock.json | 961 ++++++++++++++++++ .../Aspire.Hosting.SqlServer/package.json | 19 + .../Aspire.Hosting.SqlServer/tsconfig.json | 15 + .../Projects/AppHostServerProject.cs | 23 +- .../SqlServerBuilderExtensions.cs | 7 + 9 files changed, 1082 insertions(+), 2 deletions(-) create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/apphost.run.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/apphost.ts create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/package-lock.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/package.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/tsconfig.json diff --git a/.gitignore b/.gitignore index 376e6919109..6fb4a98b72d 100644 --- a/.gitignore +++ b/.gitignore @@ -168,6 +168,9 @@ playground/**/dist/ #Aspire CLI .aspire/ +# Required for polyglot apps +!playground/polyglot/**/.aspire/ + # Release notes automation output tools/ReleaseNotes/analysis-output/ tools/ReleaseNotes/release-notes-*.md diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json new file mode 100644 index 00000000000..32bf312c1cb --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json @@ -0,0 +1,9 @@ +{ + "appHostPath": "../apphost.ts", + "language": "typescript/nodejs", + "channel": "pr-13970", + "sdkVersion": "13.1.0", + "packages": { + "Aspire.Hosting.SqlServer": "13.2.0-pr.13970.g0575147c" + } +} \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/apphost.run.json b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/apphost.run.json new file mode 100644 index 00000000000..a847248a109 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/apphost.run.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "https": { + "applicationUrl": "https://localhost:55686;http://localhost:57768", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:51980", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:28684" + } + } + } +} \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/apphost.ts b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/apphost.ts new file mode 100644 index 00000000000..046e8daddc7 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/apphost.ts @@ -0,0 +1,34 @@ +import { createBuilder, ContainerLifetime } from './.modules/aspire.js'; + +const builder = await createBuilder(); + +// Test 1: Basic SQL Server resource creation (addSqlServer) +const sqlServer = await builder.addSqlServer("sql"); + +// Test 2: Add database to SQL Server (addDatabase) +await sqlServer.addDatabase("mydb"); + +// Test 3: Test withDataVolume +await builder.addSqlServer("sql-volume") + .withDataVolume(); + +// Test 4: Test withHostPort +await builder.addSqlServer("sql-port") + .withHostPort({ port: 11433 }); + +// Test 5: Test password parameter with addParameter +const customPassword = await builder.addParameter("sql-password", { secret: true }); +await builder.addSqlServer("sql-custom-pass", { password: customPassword }); + +// Test 6: Chained configuration - multiple With* methods +const sqlChained = await builder.addSqlServer("sql-chained") + .withLifetime(ContainerLifetime.Persistent) + .withDataVolume({ name: "sql-chained-data" }) + .withHostPort({ port: 12433 }); + +// Test 7: Add multiple databases to same server +await sqlChained.addDatabase("db1"); +await sqlChained.addDatabase("db2", { databaseName: "customdb2" }); + +// Build and run the app +await builder.build().run(); diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/package-lock.json b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/package-lock.json new file mode 100644 index 00000000000..ea06ceb0755 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/package-lock.json @@ -0,0 +1,961 @@ +{ + "name": "validationapphost", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "validationapphost", + "version": "1.0.0", + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "nodemon": "^3.1.11", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", + "integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", + "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + } + } +} diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/package.json b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/package.json new file mode 100644 index 00000000000..be16934198a --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/package.json @@ -0,0 +1,19 @@ +{ + "name": "validationapphost", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "aspire run", + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "nodemon": "^3.1.11", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } +} \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/tsconfig.json b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/tsconfig.json new file mode 100644 index 00000000000..edf7302cc25 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": ["apphost.ts", ".modules/**/*.ts"], + "exclude": ["node_modules"] +} \ No newline at end of file diff --git a/src/Aspire.Cli/Projects/AppHostServerProject.cs b/src/Aspire.Cli/Projects/AppHostServerProject.cs index d5bdaf4a6e7..40bcebf8077 100644 --- a/src/Aspire.Cli/Projects/AppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/AppHostServerProject.cs @@ -334,6 +334,25 @@ await NuGetConfigMerger.CreateOrUpdateAsync( new XAttribute("Include", "appsettings.json"), new XAttribute("CopyToOutputDirectory", "PreserveNewest")))); + // For dev mode, create Directory.Packages.props to enable central package management + // This ensures transitive dependencies use versions from the repo's Directory.Packages.props + if (LocalAspirePath is not null) + { + var repoRoot = Path.GetFullPath(LocalAspirePath); + var repoDirectoryPackagesProps = Path.Combine(repoRoot, "Directory.Packages.props"); + var directoryPackagesProps = $""" + + + true + true + + + + """; + var directoryPackagesPropsPath = Path.Combine(_projectModelPath, "Directory.Packages.props"); + File.WriteAllText(directoryPackagesPropsPath, directoryPackagesProps); + } + var projectFileName = Path.Combine(_projectModelPath, ProjectFileName); doc.Save(projectFileName); @@ -381,8 +400,8 @@ private XDocument CreateDevModeProjectFile(IEnumerable<(string Name, string Vers {repoRoot}artifacts/bin/Aspire.Dashboard/Debug/net8.0/ - - + + """; diff --git a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs index ad26bb3a4a4..a90962ab290 100644 --- a/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs +++ b/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs @@ -33,6 +33,7 @@ public static partial class SqlServerBuilderExtensions /// The parameter used to provide the administrator password for the SQL Server resource. If a random password will be generated. /// The host port for the SQL Server. /// A reference to the . + [AspireExport("addSqlServer", Description = "Adds a SQL Server container resource")] public static IResourceBuilder AddSqlServer(this IDistributedApplicationBuilder builder, [ResourceName] string name, IResourceBuilder? password = null, int? port = null) { ArgumentNullException.ThrowIfNull(builder); @@ -110,6 +111,7 @@ public static IResourceBuilder AddSqlServer(this IDistr /// The database creation happens automatically as part of the resource lifecycle. /// /// + [AspireExport("addDatabase", Description = "Adds a SQL Server database resource")] public static IResourceBuilder AddDatabase(this IResourceBuilder builder, [ResourceName] string name, string? databaseName = null) { ArgumentNullException.ThrowIfNull(builder); @@ -148,6 +150,7 @@ public static IResourceBuilder AddDatabase(this IReso /// The name of the volume. Defaults to an auto-generated name based on the application and resource names. /// A flag that indicates if this is a read-only volume. /// The . + [AspireExport("withDataVolume", Description = "Adds a named volume for the SQL Server data folder")] public static IResourceBuilder WithDataVolume(this IResourceBuilder builder, string? name = null, bool isReadOnly = false) { ArgumentNullException.ThrowIfNull(builder); @@ -166,6 +169,7 @@ public static IResourceBuilder WithDataVolume(this IRes /// The container starts up as non-root and the directory must be readable by the user that the container runs as. /// https://learn.microsoft.com/sql/linux/sql-server-linux-docker-container-configure?view=sql-server-ver16&pivots=cs1-bash#mount-a-host-directory-as-data-volume /// + [AspireExport("withDataBindMount", Description = "Adds a bind mount for the SQL Server data folder")] public static IResourceBuilder WithDataBindMount(this IResourceBuilder builder, string source, bool isReadOnly = false) { ArgumentNullException.ThrowIfNull(builder); @@ -201,6 +205,7 @@ public static IResourceBuilder WithDataBindMount(this I /// /// Default script is IF ( NOT EXISTS ( SELECT 1 FROM sys.databases WHERE name = @DatabaseName ) ) CREATE DATABASE [<QUOTED_DATABASE_NAME%gt;]; /// + [AspireExport("withCreationScript", Description = "Defines the SQL script used to create the database")] public static IResourceBuilder WithCreationScript(this IResourceBuilder builder, string script) { ArgumentNullException.ThrowIfNull(builder); @@ -217,6 +222,7 @@ public static IResourceBuilder WithCreationScript(thi /// The resource builder. /// The parameter used to provide the password for the SqlServer resource. /// The . + [AspireExport("withPassword", Description = "Configures the password for the SQL Server resource")] public static IResourceBuilder WithPassword(this IResourceBuilder builder, IResourceBuilder password) { ArgumentNullException.ThrowIfNull(builder); @@ -232,6 +238,7 @@ public static IResourceBuilder WithPassword(this IResou /// The resource builder. /// The port to bind on the host. If is used random port will be assigned. /// The . + [AspireExport("withHostPort", Description = "Sets the host port for the SQL Server resource")] public static IResourceBuilder WithHostPort(this IResourceBuilder builder, int? port) { ArgumentNullException.ThrowIfNull(builder); From 913701a706ce5942039e6e6926df6fbde9090d7b Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:40:37 -0800 Subject: [PATCH 003/256] Update dependencies from https://github.com/microsoft/dcp build 0.22.2 (#14263) On relative base path root Microsoft.DeveloperControlPlane.darwin-amd64 , Microsoft.DeveloperControlPlane.darwin-arm64 , Microsoft.DeveloperControlPlane.linux-amd64 , Microsoft.DeveloperControlPlane.linux-arm64 , Microsoft.DeveloperControlPlane.linux-musl-amd64 , Microsoft.DeveloperControlPlane.windows-amd64 , Microsoft.DeveloperControlPlane.windows-arm64 From Version 0.22.1 -> To Version 0.22.2 Co-authored-by: dotnet-maestro[bot] --- NuGet.config | 2 +- eng/Version.Details.xml | 28 ++++++++++++++-------------- eng/Versions.props | 14 +++++++------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/NuGet.config b/NuGet.config index fa18b73d78a..2822bc5d52a 100644 --- a/NuGet.config +++ b/NuGet.config @@ -1,4 +1,4 @@ - + diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index ef0b076b7e0..2b5b136f5a0 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,33 +1,33 @@ - + https://github.com/microsoft/dcp - 1d5d617292c05ff9870cd20d1f455f8f41dac523 + 4808f6b49ebf5feeffe79034cf2b140f5d7c548c - + https://github.com/microsoft/dcp - 1d5d617292c05ff9870cd20d1f455f8f41dac523 + 4808f6b49ebf5feeffe79034cf2b140f5d7c548c - + https://github.com/microsoft/dcp - 1d5d617292c05ff9870cd20d1f455f8f41dac523 + 4808f6b49ebf5feeffe79034cf2b140f5d7c548c - + https://github.com/microsoft/dcp - 1d5d617292c05ff9870cd20d1f455f8f41dac523 + 4808f6b49ebf5feeffe79034cf2b140f5d7c548c - + https://github.com/microsoft/dcp - 1d5d617292c05ff9870cd20d1f455f8f41dac523 + 4808f6b49ebf5feeffe79034cf2b140f5d7c548c - + https://github.com/microsoft/dcp - 1d5d617292c05ff9870cd20d1f455f8f41dac523 + 4808f6b49ebf5feeffe79034cf2b140f5d7c548c - + https://github.com/microsoft/dcp - 1d5d617292c05ff9870cd20d1f455f8f41dac523 + 4808f6b49ebf5feeffe79034cf2b140f5d7c548c https://dev.azure.com/dnceng/internal/_git/dotnet-extensions diff --git a/eng/Versions.props b/eng/Versions.props index 3f2f9f1bd87..2be7c94dc1e 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -28,13 +28,13 @@ 8.0.100-rtm.23512.16 - 0.22.1 - 0.22.1 - 0.22.1 - 0.22.1 - 0.22.1 - 0.22.1 - 0.22.1 + 0.22.2 + 0.22.2 + 0.22.2 + 0.22.2 + 0.22.2 + 0.22.2 + 0.22.2 11.0.0-beta.25610.3 11.0.0-beta.25610.3 From 85fee2ad2e5ed3dff5182a6547e9e72b5085208e Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Sat, 31 Jan 2026 21:44:47 +0800 Subject: [PATCH 004/256] Use DefaultTimeout in CLI tests (#14241) --- .../Agents/AgentEnvironmentApplicatorTests.cs | 5 +- .../Agents/AgentEnvironmentDetectorTests.cs | 11 +- .../Agents/CommonAgentApplicatorsTests.cs | 3 +- .../CopilotCliAgentEnvironmentScannerTests.cs | 15 +-- .../VsCodeAgentEnvironmentScannerTests.cs | 33 +++--- .../Caching/DiskCacheTests.cs | 31 +++--- .../Certificates/CertificateServiceTests.cs | 11 +- tests/Aspire.Cli.Tests/CliSmokeTests.cs | 7 +- tests/Aspire.Cli.Tests/CliTestConstants.cs | 10 -- .../Commands/AddCommandTests.cs | 31 +++--- .../Commands/ConfigCommandTests.cs | 51 ++++----- .../Commands/DeployCommandTests.cs | 8 +- .../Commands/DoCommandTests.cs | 21 ++-- .../Commands/DoctorCommandTests.cs | 3 +- .../Commands/ExecCommandTests.cs | 15 +-- .../Commands/ExtensionInternalCommandTests.cs | 17 +-- .../Commands/InitCommandTests.cs | 11 +- .../Commands/LogsCommandTests.cs | 21 ++-- .../Commands/McpCommandTests.cs | 11 +- .../Commands/NewCommandTests.cs | 28 ++--- .../Commands/PsCommandTests.cs | 13 +-- ...PublishCommandPromptingIntegrationTests.cs | 33 +++--- .../Commands/PublishCommandTests.cs | 6 +- .../Commands/ResourcesCommandTests.cs | 19 ++-- .../Commands/RootCommandTests.cs | 7 +- .../Commands/RunCommandTests.cs | 34 +++--- .../Commands/SdkInstallerTests.cs | 15 +-- .../Commands/UpdateCommandTests.cs | 31 +++--- .../DotNetSdkInstallerTests.cs | 23 ++-- .../ConsoleInteractionServiceTests.cs | 3 +- .../Mcp/Docs/LlmsTxtParserTests.cs | 71 ++++++------ .../Mcp/ListAppHostsToolTests.cs | 13 +-- .../Mcp/ListIntegrationsToolTests.cs | 7 +- .../NuGet/NuGetPackageCacheTests.cs | 11 +- .../NuGetConfigMergerSnapshotTests.cs | 21 ++-- .../Packaging/NuGetConfigMergerTests.cs | 27 ++--- .../Packaging/PackagingServiceTests.cs | 27 ++--- .../Packaging/TemporaryNuGetConfigTests.cs | 2 +- .../Projects/AppHostServerProjectTests.cs | 31 +++--- .../Projects/DefaultLanguageDiscoveryTests.cs | 9 +- .../Projects/ProjectLocatorTests.cs | 61 +++++------ .../Projects/ProjectUpdaterTests.cs | 101 +++++++++--------- .../Telemetry/AspireCliTelemetryTests.cs | 3 +- .../LinuxInformationProviderTests.cs | 5 +- .../MacOSXInformationProviderTests.cs | 5 +- .../Telemetry/TelemetryConfigurationTests.cs | 5 +- .../WindowsInformationProviderTests.cs | 5 +- .../Templating/DotNetTemplateFactoryTests.cs | 19 ++-- .../TestServices/TestExtensionBackchannel.cs | 3 +- .../TestServices/TestProjectLocator.cs | 3 +- .../CliUpdateNotificationServiceTests.cs | 37 +++---- .../Utils/OutputCollectorTests.cs | 13 +-- 52 files changed, 522 insertions(+), 484 deletions(-) delete mode 100644 tests/Aspire.Cli.Tests/CliTestConstants.cs diff --git a/tests/Aspire.Cli.Tests/Agents/AgentEnvironmentApplicatorTests.cs b/tests/Aspire.Cli.Tests/Agents/AgentEnvironmentApplicatorTests.cs index 961b54b5a4e..3896acbe7c9 100644 --- a/tests/Aspire.Cli.Tests/Agents/AgentEnvironmentApplicatorTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/AgentEnvironmentApplicatorTests.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 Microsoft.AspNetCore.InternalTesting; using Aspire.Cli.Agents; namespace Aspire.Cli.Tests.Agents; @@ -19,7 +20,7 @@ public async Task ApplyAsync_InvokesCallback() return Task.CompletedTask; }); - await applicator.ApplyAsync(CancellationToken.None); + await applicator.ApplyAsync(CancellationToken.None).DefaultTimeout(); Assert.True(callbackInvoked); } @@ -37,7 +38,7 @@ public async Task ApplyAsync_PassesCancellationToken() return Task.CompletedTask; }); - await applicator.ApplyAsync(cts.Token); + await applicator.ApplyAsync(cts.Token).DefaultTimeout(); Assert.Equal(cts.Token, receivedToken); } diff --git a/tests/Aspire.Cli.Tests/Agents/AgentEnvironmentDetectorTests.cs b/tests/Aspire.Cli.Tests/Agents/AgentEnvironmentDetectorTests.cs index 1ea1fecfa6d..6862440176b 100644 --- a/tests/Aspire.Cli.Tests/Agents/AgentEnvironmentDetectorTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/AgentEnvironmentDetectorTests.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 Microsoft.AspNetCore.InternalTesting; using Aspire.Cli.Agents; using Aspire.Cli.Tests.Utils; @@ -19,7 +20,7 @@ public async Task DetectAsync_WithNoScanners_ReturnsEmptyArray() RepositoryRoot = workspace.WorkspaceRoot, }; - var applicators = await detector.DetectAsync(context, CancellationToken.None); + var applicators = await detector.DetectAsync(context, CancellationToken.None).DefaultTimeout(); Assert.Empty(applicators); } @@ -36,7 +37,7 @@ public async Task DetectAsync_WithScanner_RunsScannerWithCorrectContext() RepositoryRoot = workspace.WorkspaceRoot, }; - var applicators = await detector.DetectAsync(context, CancellationToken.None); + var applicators = await detector.DetectAsync(context, CancellationToken.None).DefaultTimeout(); Assert.True(scanner.WasScanned); Assert.Equal(workspace.WorkspaceRoot.FullName, scanner.ScanContext?.WorkingDirectory.FullName); @@ -60,7 +61,7 @@ public async Task DetectAsync_WithScannerThatAddsApplicator_ReturnsApplicator() RepositoryRoot = workspace.WorkspaceRoot, }; - var applicators = await detector.DetectAsync(context, CancellationToken.None); + var applicators = await detector.DetectAsync(context, CancellationToken.None).DefaultTimeout(); Assert.Single(applicators); Assert.Equal("Test Environment", applicators[0].Description); @@ -89,7 +90,7 @@ public async Task DetectAsync_WithMultipleScanners_RunsAllScanners() RepositoryRoot = workspace.WorkspaceRoot, }; - var applicators = await detector.DetectAsync(context, CancellationToken.None); + var applicators = await detector.DetectAsync(context, CancellationToken.None).DefaultTimeout(); Assert.True(scanner1.WasScanned); Assert.True(scanner2.WasScanned); @@ -108,7 +109,7 @@ public async Task DetectAsync_WithConfigurePlaywrightTrue_PassesContextToScanner RepositoryRoot = workspace.WorkspaceRoot, }; - var applicators = await detector.DetectAsync(context, CancellationToken.None); + var applicators = await detector.DetectAsync(context, CancellationToken.None).DefaultTimeout(); Assert.True(scanner.WasScanned); } diff --git a/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs b/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs index d54a9ff2f7f..ea0abb88cc2 100644 --- a/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.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 Microsoft.AspNetCore.InternalTesting; using Aspire.Cli.Agents; using Aspire.Cli.Tests.Utils; @@ -90,7 +91,7 @@ public async Task TryAddSkillFileApplicator_CreatesSkillFileWhenItDoesNotExist() workspace.WorkspaceRoot, TestSkillRelativePath, TestDescription); - await context.Applicators[0].ApplyAsync(CancellationToken.None); + await context.Applicators[0].ApplyAsync(CancellationToken.None).DefaultTimeout(); // Assert var skillFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, TestSkillRelativePath); diff --git a/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs index 58f94eb8bf9..abfa1ca0490 100644 --- a/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.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 Microsoft.AspNetCore.InternalTesting; using System.Text.Json.Nodes; using Aspire.Cli.Agents; using Aspire.Cli.Agents.CopilotCli; @@ -21,7 +22,7 @@ public async Task ScanAsync_WhenCopilotCliInstalled_ReturnsApplicator() var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); - await scanner.ScanAsync(context, CancellationToken.None); + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); // Scanner adds applicators for: Aspire MCP, Playwright MCP, and agent instructions Assert.NotEmpty(context.Applicators); @@ -42,13 +43,13 @@ public async Task ApplyAsync_CreatesMcpConfigJsonWithCorrectConfiguration() var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); - await scanner.ScanAsync(context, CancellationToken.None); + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); // Scanner adds applicators for: Aspire MCP, Playwright MCP, and agent instructions Assert.NotEmpty(context.Applicators); var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); - await aspireApplicator.ApplyAsync(CancellationToken.None); + await aspireApplicator.ApplyAsync(CancellationToken.None).DefaultTimeout(); var mcpConfigPath = Path.Combine(copilotFolder.FullName, "mcp-config.json"); Assert.True(File.Exists(mcpConfigPath)); @@ -111,8 +112,8 @@ public async Task ApplyAsync_PreservesExistingMcpConfigContent() var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); - await scanner.ScanAsync(context, CancellationToken.None); - await context.Applicators[0].ApplyAsync(CancellationToken.None); + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); + await context.Applicators[0].ApplyAsync(CancellationToken.None).DefaultTimeout(); var content = await File.ReadAllTextAsync(mcpConfigPath); var config = JsonNode.Parse(content)?.AsObject(); @@ -160,7 +161,7 @@ public async Task ScanAsync_WhenAspireAlreadyConfigured_ReturnsNoApplicator() var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); - await scanner.ScanAsync(context, CancellationToken.None); + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); // No applicators should be returned since Aspire MCP, Playwright MCP are configured and skill file exists Assert.Empty(context.Applicators); @@ -175,7 +176,7 @@ public async Task ScanAsync_WhenInVSCode_ReturnsApplicatorWithoutCallingRunner() var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); - await scanner.ScanAsync(context, CancellationToken.None); + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); // Scanner adds applicators for: Aspire MCP, Playwright MCP, and agent instructions Assert.NotEmpty(context.Applicators); diff --git a/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs index ff3d3d3f687..bdb0b30a43e 100644 --- a/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.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 Microsoft.AspNetCore.InternalTesting; using System.Text.Json.Nodes; using Aspire.Cli.Agents; using Aspire.Cli.Agents.VsCode; @@ -22,7 +23,7 @@ public async Task ScanAsync_WhenVsCodeFolderExists_ReturnsApplicator() var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); - await scanner.ScanAsync(context, CancellationToken.None); + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); // Scanner adds applicators for: Aspire MCP, Playwright MCP, and agent instructions Assert.NotEmpty(context.Applicators); @@ -40,7 +41,7 @@ public async Task ScanAsync_WhenVsCodeFolderExistsInParent_ReturnsApplicatorForP var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); var context = CreateScanContext(childDir, workspace.WorkspaceRoot); - await scanner.ScanAsync(context, CancellationToken.None); + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); // Scanner adds applicators for: Aspire MCP, Playwright MCP, and agent instructions Assert.NotEmpty(context.Applicators); @@ -58,7 +59,7 @@ public async Task ScanAsync_WhenRepositoryRootReachedBeforeVsCode_AndNoCliAvaila var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); var context = CreateScanContext(childDir, workspace.WorkspaceRoot); - await scanner.ScanAsync(context, CancellationToken.None); + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); Assert.Empty(context.Applicators); } @@ -72,7 +73,7 @@ public async Task ScanAsync_WhenNoVsCodeFolder_AndVsCodeCliAvailable_ReturnsAppl var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); - await scanner.ScanAsync(context, CancellationToken.None); + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); // Scanner adds applicators for: Aspire MCP, Playwright MCP, and agent instructions Assert.NotEmpty(context.Applicators); @@ -90,7 +91,7 @@ public async Task ScanAsync_WhenNoVsCodeFolder_AndNoCliAvailable_ReturnsNoApplic // This test assumes no VSCODE_* environment variables are set // With no CLI available and no env vars, no applicator should be returned - await scanner.ScanAsync(context, CancellationToken.None); + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); // The result depends on whether VSCODE_* environment variables exist // We just verify the test runs without throwing @@ -109,14 +110,14 @@ public async Task ApplyAsync_CreatesVsCodeFolderIfNotExists() var parentVsCode = workspace.CreateDirectory(".vscode"); var context = CreateScanContext(workspace.WorkspaceRoot); - await scanner.ScanAsync(context, CancellationToken.None); + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); // Scanner adds applicators for: Aspire MCP, Playwright MCP, and agent instructions Assert.NotEmpty(context.Applicators); var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); // Apply the configuration - await aspireApplicator.ApplyAsync(CancellationToken.None); + await aspireApplicator.ApplyAsync(CancellationToken.None).DefaultTimeout(); // Verify the mcp.json was created var mcpJsonPath = Path.Combine(parentVsCode.FullName, "mcp.json"); @@ -133,8 +134,8 @@ public async Task ApplyAsync_CreatesMcpJsonWithCorrectConfiguration() var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); - await scanner.ScanAsync(context, CancellationToken.None); - await context.Applicators[0].ApplyAsync(CancellationToken.None); + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); + await context.Applicators[0].ApplyAsync(CancellationToken.None).DefaultTimeout(); var mcpJsonPath = Path.Combine(vsCodeFolder.FullName, "mcp.json"); Assert.True(File.Exists(mcpJsonPath)); @@ -189,8 +190,8 @@ public async Task ApplyAsync_PreservesExistingMcpJsonContent() var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); - await scanner.ScanAsync(context, CancellationToken.None); - await context.Applicators[0].ApplyAsync(CancellationToken.None); + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); + await context.Applicators[0].ApplyAsync(CancellationToken.None).DefaultTimeout(); var content = await File.ReadAllTextAsync(mcpJsonPath); var config = JsonNode.Parse(content)?.AsObject(); @@ -230,13 +231,13 @@ public async Task ApplyAsync_UpdatesExistingAspireServerConfig() var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); - await scanner.ScanAsync(context, CancellationToken.None); + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); // Should return applicators for Aspire MCP, Playwright MCP, and agent instructions Assert.NotEmpty(context.Applicators); var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); - await aspireApplicator.ApplyAsync(CancellationToken.None); + await aspireApplicator.ApplyAsync(CancellationToken.None).DefaultTimeout(); var content = await File.ReadAllTextAsync(mcpJsonPath); var config = JsonNode.Parse(content)?.AsObject(); @@ -261,13 +262,13 @@ public async Task ApplyAsync_WithConfigurePlaywrightTrue_AddsPlaywrightServer() var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); var context = CreateScanContext(workspace.WorkspaceRoot); - await scanner.ScanAsync(context, CancellationToken.None); + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); // Apply both MCP-related applicators (Aspire and Playwright) var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); var playwrightApplicator = context.Applicators.First(a => a.Description.Contains("Playwright MCP")); - await aspireApplicator.ApplyAsync(CancellationToken.None); - await playwrightApplicator.ApplyAsync(CancellationToken.None); + await aspireApplicator.ApplyAsync(CancellationToken.None).DefaultTimeout(); + await playwrightApplicator.ApplyAsync(CancellationToken.None).DefaultTimeout(); var mcpJsonPath = Path.Combine(vsCodeFolder.FullName, "mcp.json"); var content = await File.ReadAllTextAsync(mcpJsonPath); diff --git a/tests/Aspire.Cli.Tests/Caching/DiskCacheTests.cs b/tests/Aspire.Cli.Tests/Caching/DiskCacheTests.cs index 5f8873acb68..365384ccfa4 100644 --- a/tests/Aspire.Cli.Tests/Caching/DiskCacheTests.cs +++ b/tests/Aspire.Cli.Tests/Caching/DiskCacheTests.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 Microsoft.AspNetCore.InternalTesting; using Aspire.Cli.Caching; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.Configuration; @@ -31,11 +32,11 @@ public async Task CacheMissThenHit() var cache = CreateCache(workspace); var key = "query=foo|prerelease=False|take=10|skip=0|nugetConfigHash=abc|cliVersion=1.0"; - var miss = await cache.GetAsync(key, CancellationToken.None); + var miss = await cache.GetAsync(key, CancellationToken.None).DefaultTimeout(); Assert.Null(miss); - await cache.SetAsync(key, "RESULT-A", CancellationToken.None); - var hit = await cache.GetAsync(key, CancellationToken.None); + await cache.SetAsync(key, "RESULT-A", CancellationToken.None).DefaultTimeout(); + var hit = await cache.GetAsync(key, CancellationToken.None).DefaultTimeout(); Assert.Equal("RESULT-A", hit); } @@ -51,11 +52,11 @@ public async Task ExpiredEntryReturnsNull() }); var key = "query=bar|prerelease=False|take=10|skip=0|nugetConfigHash=def|cliVersion=1.0"; - await cache.SetAsync(key, "RESULT-B", CancellationToken.None); + await cache.SetAsync(key, "RESULT-B", CancellationToken.None).DefaultTimeout(); // Wait slightly over 1 second so entry expires - await Task.Delay(TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2)).DefaultTimeout(); - var after = await cache.GetAsync(key, CancellationToken.None); + var after = await cache.GetAsync(key, CancellationToken.None).DefaultTimeout(); Assert.Null(after); } @@ -71,12 +72,12 @@ public async Task NewerEntrySupersedesOlder() var diskPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cache", "nuget-search"); var key = "query=baz|prerelease=False|take=10|skip=0|nugetConfigHash=ghi|cliVersion=1.0"; - await cache.SetAsync(key, "OLD", CancellationToken.None); + await cache.SetAsync(key, "OLD", CancellationToken.None).DefaultTimeout(); // Slight delay to ensure different timestamp await Task.Delay(50); - await cache.SetAsync(key, "NEW", CancellationToken.None); + await cache.SetAsync(key, "NEW", CancellationToken.None).DefaultTimeout(); - var val = await cache.GetAsync(key, CancellationToken.None); + var val = await cache.GetAsync(key, CancellationToken.None).DefaultTimeout(); Assert.Equal("NEW", val); // Ensure only one valid (newest) file remains for the key @@ -92,11 +93,11 @@ public async Task ClearRemovesEntries() using var workspace = TemporaryWorkspace.Create(outputHelper); var cache = CreateCache(workspace); var key = "query=clear|prerelease=False|take=10|skip=0|nugetConfigHash=jkl|cliVersion=1.0"; - await cache.SetAsync(key, "VALUE", CancellationToken.None); - var before = await cache.GetAsync(key, CancellationToken.None); + await cache.SetAsync(key, "VALUE", CancellationToken.None).DefaultTimeout(); + var before = await cache.GetAsync(key, CancellationToken.None).DefaultTimeout(); Assert.NotNull(before); - await cache.ClearAsync(CancellationToken.None); - var after = await cache.GetAsync(key, CancellationToken.None); + await cache.ClearAsync(CancellationToken.None).DefaultTimeout(); + var after = await cache.GetAsync(key, CancellationToken.None).DefaultTimeout(); Assert.Null(after); } @@ -111,7 +112,7 @@ public async Task OldFilesBeyondMaxAgeAreDeletedOnAccess() cfg["PackageSearchMaxCacheAgeSeconds"] = "1"; // small }); var key = "query=cleanup|prerelease=False|take=10|skip=0|nugetConfigHash=mno|cliVersion=1.0"; - await cache.SetAsync(key, "VALUE-X", CancellationToken.None); + await cache.SetAsync(key, "VALUE-X", CancellationToken.None).DefaultTimeout(); // Manually adjust timestamp older than max age by renaming file var diskPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "cache", "nuget-search"); @@ -127,7 +128,7 @@ public async Task OldFilesBeyondMaxAgeAreDeletedOnAccess() File.Move(current, oldName, overwrite: true); // Trigger Get which should treat it as too old and delete - var val = await cache.GetAsync(key, CancellationToken.None); + var val = await cache.GetAsync(key, CancellationToken.None).DefaultTimeout(); Assert.Null(val); // treated as miss after cleanup Assert.False(File.Exists(oldName)); } diff --git a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs index a35015b1959..31ddae68a91 100644 --- a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Certificates; @@ -38,7 +39,7 @@ public async Task EnsureCertificatesTrustedAsync_WithFullyTrustedCert_ReturnsEmp var cs = sp.GetRequiredService(); var runner = sp.GetRequiredService(); - var result = await cs.EnsureCertificatesTrustedAsync(runner, TestContext.Current.CancellationToken).WaitAsync(CliTestConstants.DefaultTimeout); + var result = await cs.EnsureCertificatesTrustedAsync(runner, TestContext.Current.CancellationToken).DefaultTimeout(); Assert.NotNull(result); Assert.Empty(result.EnvironmentVariables); @@ -89,7 +90,7 @@ public async Task EnsureCertificatesTrustedAsync_WithNotTrustedCert_RunsTrustOpe var cs = sp.GetRequiredService(); var runner = sp.GetRequiredService(); - var result = await cs.EnsureCertificatesTrustedAsync(runner, TestContext.Current.CancellationToken).WaitAsync(CliTestConstants.DefaultTimeout); + var result = await cs.EnsureCertificatesTrustedAsync(runner, TestContext.Current.CancellationToken).DefaultTimeout(); Assert.True(trustCalled); Assert.NotNull(result); @@ -128,7 +129,7 @@ public async Task EnsureCertificatesTrustedAsync_WithPartiallyTrustedCert_SetsSs var cs = sp.GetRequiredService(); var runner = sp.GetRequiredService(); - var result = await cs.EnsureCertificatesTrustedAsync(runner, TestContext.Current.CancellationToken).WaitAsync(CliTestConstants.DefaultTimeout); + var result = await cs.EnsureCertificatesTrustedAsync(runner, TestContext.Current.CancellationToken).DefaultTimeout(); Assert.NotNull(result); Assert.True(result.EnvironmentVariables.ContainsKey("SSL_CERT_DIR")); @@ -180,7 +181,7 @@ public async Task EnsureCertificatesTrustedAsync_WithNoCertificates_RunsTrustOpe var cs = sp.GetRequiredService(); var runner = sp.GetRequiredService(); - var result = await cs.EnsureCertificatesTrustedAsync(runner, TestContext.Current.CancellationToken).WaitAsync(CliTestConstants.DefaultTimeout); + var result = await cs.EnsureCertificatesTrustedAsync(runner, TestContext.Current.CancellationToken).DefaultTimeout(); Assert.True(trustCalled); Assert.NotNull(result); @@ -219,7 +220,7 @@ public async Task EnsureCertificatesTrustedAsync_TrustOperationFails_DisplaysWar var runner = sp.GetRequiredService(); // If this does not throw then the code is behaving correctly. - var result = await cs.EnsureCertificatesTrustedAsync(runner, TestContext.Current.CancellationToken).WaitAsync(CliTestConstants.DefaultTimeout); + var result = await cs.EnsureCertificatesTrustedAsync(runner, TestContext.Current.CancellationToken).DefaultTimeout(); Assert.NotNull(result); } } diff --git a/tests/Aspire.Cli.Tests/CliSmokeTests.cs b/tests/Aspire.Cli.Tests/CliSmokeTests.cs index 0fa3cf4715c..89eacdebae4 100644 --- a/tests/Aspire.Cli.Tests/CliSmokeTests.cs +++ b/tests/Aspire.Cli.Tests/CliSmokeTests.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 Microsoft.AspNetCore.InternalTesting; using Microsoft.DotNet.RemoteExecutor; namespace Aspire.Cli.Tests; @@ -19,7 +20,7 @@ public class CliSmokeTests(ITestOutputHelper outputHelper) [InlineData(new[] { "--version" }, ExitCodeConstants.Success)] public async Task MainReturnsExpectedExitCode(string[] args, int expectedExitCode) { - var exitCode = await Program.Main(args); + var exitCode = await Program.Main(args).DefaultTimeout(); Assert.Equal(expectedExitCode, exitCode); } @@ -41,7 +42,7 @@ public void LocaleOverrideReturnsExitCode(string locale, bool isValid, string en Environment.SetEnvironmentVariable(envVar, loc); // Suppress first-time use notice to avoid extra lines in stderr Environment.SetEnvironmentVariable(CliConfigNames.NoLogo, "true"); - await Program.Main([]); + await Program.Main([]).DefaultTimeout(); Environment.SetEnvironmentVariable(envVar, null); Environment.SetEnvironmentVariable(CliConfigNames.NoLogo, null); Console.SetError(oldErrorOutput); @@ -75,7 +76,7 @@ public void DebugOutputWritesToStderr() var oldErrorOutput = Console.Error; Console.SetError(errorWriter); - await Program.Main(["-d", "--help"]); + await Program.Main(["-d", "--help"]).DefaultTimeout(); Console.SetError(oldErrorOutput); var errorOutput = errorWriter.ToString(); diff --git a/tests/Aspire.Cli.Tests/CliTestConstants.cs b/tests/Aspire.Cli.Tests/CliTestConstants.cs deleted file mode 100644 index e3eeb3a109a..00000000000 --- a/tests/Aspire.Cli.Tests/CliTestConstants.cs +++ /dev/null @@ -1,10 +0,0 @@ -// 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.Tests; - -public static class CliTestConstants -{ - public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); - public static readonly TimeSpan LongTimeout = TimeSpan.FromSeconds(10); -} \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 8399c59a160..465b26ef4e4 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -9,6 +9,7 @@ using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; using NuGetPackage = Aspire.Shared.NuGetPackageCli; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -24,7 +25,7 @@ public async Task AddCommandWithHelpArgumentReturnsZero() var command = provider.GetRequiredService(); var result = command.Parse("add --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -89,7 +90,7 @@ public async Task AddCommandInteractiveFlowSmokeTest() var command = provider.GetRequiredService(); var result = command.Parse("add"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -164,7 +165,7 @@ public async Task AddCommandDoesNotPromptForIntegrationArgumentIfSpecifiedOnComm var command = provider.GetRequiredService(); var result = command.Parse("add docker"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); Assert.False(promptedForIntegrationPackages); } @@ -247,7 +248,7 @@ public async Task AddCommandDoesNotPromptForVersionIfSpecifiedOnCommandLine() var command = provider.GetRequiredService(); var result = command.Parse("add docker --version 9.2.0"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); Assert.False(promptedForIntegrationPackages); Assert.False(promptedForVersion); @@ -327,7 +328,7 @@ public async Task AddCommandPromptsForDisambiguation() var command = provider.GetRequiredService(); var result = command.Parse("add red"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); Assert.Collection( promptedPackages!, @@ -397,7 +398,7 @@ public async Task AddCommandPreservesSourceArgumentInBothCommands() var command = provider.GetRequiredService(); var result = command.Parse($"add redis --source {expectedSource}"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -440,7 +441,7 @@ public async Task AddCommand_EmptyPackageList_DisplaysErrorMessage() var command = provider.GetRequiredService(); var result = command.Parse("add"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToAddPackage, exitCode); Assert.Contains(AddCommandStrings.NoIntegrationPackagesFound, displayedErrorMessage); } @@ -513,7 +514,7 @@ public async Task AddCommand_NoMatchingPackages_DisplaysNoMatchesMessage() var command = provider.GetRequiredService(); var result = command.Parse("add nonexistentpackage"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); Assert.True(promptedForIntegration); Assert.Equal(string.Format(AddCommandStrings.NoPackagesMatchedSearchTerm, "nonexistentpackage"), displayedSubtleMessage); @@ -579,7 +580,7 @@ public async Task AddCommandPrompter_FiltersToHighestVersionPerPackageId() }; // Act - await prompter.PromptForIntegrationAsync(packages, CancellationToken.None); + await prompter.PromptForIntegrationAsync(packages, CancellationToken.None).DefaultTimeout(); // Assert - should only show highest version (9.2.0) for the package ID Assert.NotNull(displayedPackages); @@ -628,7 +629,7 @@ public async Task AddCommandPrompter_FiltersToHighestVersionPerChannel() }; // Act - var result = await prompter.PromptForIntegrationVersionAsync(packages, CancellationToken.None); + var result = await prompter.PromptForIntegrationVersionAsync(packages, CancellationToken.None).DefaultTimeout(); // Assert - For implicit channel with no explicit channels, should automatically select highest version without prompting Assert.Null(displayedChoices); // No prompt should be shown @@ -680,7 +681,7 @@ public async Task AddCommandPrompter_ShowsHighestVersionPerChannelWhenMultipleCh }; // Act - await prompter.PromptForIntegrationVersionAsync(packages, CancellationToken.None); + await prompter.PromptForIntegrationVersionAsync(packages, CancellationToken.None).DefaultTimeout(); // Assert - should show 2 root choices: one for implicit channel, one submenu for explicit channel Assert.NotNull(displayedChoices); @@ -738,7 +739,7 @@ public async Task AddCommand_WithoutHives_UsesImplicitChannelWithoutPrompting() var command = provider.GetRequiredService(); var result = command.Parse("add redis"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -844,7 +845,7 @@ public async Task AddCommand_WithStartsWith_FindsMatchUsingFuzzySearch() // Use "postgre" instead of "postgresql" - should still find it via fuzzy search var result = command.Parse("add postgre"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); // Verify that PostgreSQL package was added through fuzzy matching Assert.Equal("Aspire.Hosting.PostgreSQL", addedPackage); @@ -927,7 +928,7 @@ public async Task AddCommand_WithPartialMatch_FiltersUsingFuzzySearch() // Use "sql" - should match both PostgreSQL and MySql, but not Redis or RabbitMQ var result = command.Parse("add sql"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); // Should have prompted with packages that fuzzy match "sql" Assert.True(promptedPackages.Count > 0); @@ -997,7 +998,7 @@ public async Task AddCommand_WithTypo_FindsMatchUsingFuzzySearch() // Use "azureapp" (Azure AppContainers) - should find Azure.AppContainers via fuzzy search var result = command.Parse("add azureapp"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); // Verify that Azure AppContainers package was found and added through fuzzy matching Assert.Equal("Aspire.Hosting.Azure.AppContainers", addedPackage); diff --git a/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs index 4d58a372f10..8bd2d2fab37 100644 --- a/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using System.Text.Json.Nodes; using Aspire.Cli.Tests.TestServices; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -35,7 +36,7 @@ public async Task ConfigCommand_WithExtensionMode_Works() var command = provider.GetRequiredService(); var result = command.Parse("config"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -49,7 +50,7 @@ public async Task ConfigCommandReturnsInvalidCommandExitCode() var command = provider.GetRequiredService(); var result = command.Parse("config"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); } @@ -63,7 +64,7 @@ public async Task ConfigSetCommand_WithFlatKey_CreatesSimpleProperty() var command = provider.GetRequiredService(); var result = command.Parse("config set foo bar"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); // Verify the settings file was created correctly @@ -86,7 +87,7 @@ public async Task ConfigSetCommand_WithDotNotation_CreatesNestedObject() var command = provider.GetRequiredService(); var result = command.Parse("config set foo.bar baz"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); // Verify the settings file was created correctly @@ -111,7 +112,7 @@ public async Task ConfigSetCommand_WithDeepDotNotation_CreatesDeeplyNestedObject var command = provider.GetRequiredService(); var result = command.Parse("config set foo.bar.baz hello"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); // Verify the settings file was created correctly @@ -140,12 +141,12 @@ public async Task ConfigSetCommand_ReplacesPrimitiveWithObject() // First set a primitive value var result1 = command.Parse("config set foo primitive"); - var exitCode1 = await result1.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode1 = await result1.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode1); // Then set a nested value that should replace the primitive var result2 = command.Parse("config set foo.bar nested"); - var exitCode2 = await result2.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode2 = await result2.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode2); // Verify the primitive was replaced with an object @@ -170,7 +171,7 @@ public async Task ConfigGetCommand_WithFlatKey_ReturnsValue() // First set a value var setResult = command1.Parse("config set testkey testvalue"); - var setExitCode = await setResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var setExitCode = await setResult.InvokeAsync().DefaultTimeout(); Assert.Equal(0, setExitCode); // Create a new service collection to reload config. @@ -181,7 +182,7 @@ public async Task ConfigGetCommand_WithFlatKey_ReturnsValue() // Then get the value var getResult = command2.Parse("config get testkey"); - var getExitCode = await getResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var getExitCode = await getResult.InvokeAsync().DefaultTimeout(); Assert.Equal(0, getExitCode); } @@ -196,7 +197,7 @@ public async Task ConfigGetCommand_WithDotNotation_ReturnsNestedValue() // First set a nested value var setResult = command1.Parse("config set level1.level2.level3 nestedvalue"); - var setExitCode = await setResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var setExitCode = await setResult.InvokeAsync().DefaultTimeout(); Assert.Equal(0, setExitCode); var services2 = CliTestHelper.CreateServiceCollection(workspace, outputHelper); @@ -206,7 +207,7 @@ public async Task ConfigGetCommand_WithDotNotation_ReturnsNestedValue() // Then get the nested value var getResult = command2.Parse("config get level1.level2.level3"); - var getExitCode = await getResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var getExitCode = await getResult.InvokeAsync().DefaultTimeout(); Assert.Equal(0, getExitCode); } @@ -220,7 +221,7 @@ public async Task ConfigGetCommand_WithNonExistentKey_ReturnsError() var command = provider.GetRequiredService(); var result = command.Parse("config get nonexistent.key"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(10, exitCode); } @@ -235,17 +236,17 @@ public async Task ConfigDeleteCommand_WithFlatKey_RemovesValue() // First set a value var setResult = command.Parse("config set deletekey deletevalue"); - var setExitCode = await setResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var setExitCode = await setResult.InvokeAsync().DefaultTimeout(); Assert.Equal(0, setExitCode); // Then delete the value var deleteResult = command.Parse("config delete deletekey"); - var deleteExitCode = await deleteResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var deleteExitCode = await deleteResult.InvokeAsync().DefaultTimeout(); Assert.Equal(0, deleteExitCode); // Verify it's deleted var getResult = command.Parse("config get deletekey"); - var getExitCode = await getResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var getExitCode = await getResult.InvokeAsync().DefaultTimeout(); Assert.Equal(10, getExitCode); // Should return error for missing key } @@ -260,12 +261,12 @@ public async Task ConfigDeleteCommand_CleansUpEmptyParentObjects() // Set a deeply nested value var setResult = command.Parse("config set deep.nested.value test"); - var setExitCode = await setResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var setExitCode = await setResult.InvokeAsync().DefaultTimeout(); Assert.Equal(0, setExitCode); // Delete the nested value var deleteResult = command.Parse("config delete deep.nested.value"); - var deleteExitCode = await deleteResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var deleteExitCode = await deleteResult.InvokeAsync().DefaultTimeout(); Assert.Equal(0, deleteExitCode); // Verify the entire deep.nested structure is cleaned up @@ -289,20 +290,20 @@ public async Task ConfigListCommand_ShowsFlattenedDotNotationKeys() // Set various values var setResult1 = command.Parse("config set flatkey flatvalue"); - var setExitCode1 = await setResult1.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var setExitCode1 = await setResult1.InvokeAsync().DefaultTimeout(); Assert.Equal(0, setExitCode1); var setResult2 = command.Parse("config set nested.key nestedvalue"); - var setExitCode2 = await setResult2.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var setExitCode2 = await setResult2.InvokeAsync().DefaultTimeout(); Assert.Equal(0, setExitCode2); var setResult3 = command.Parse("config set deep.nested.key deepvalue"); - var setExitCode3 = await setResult3.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var setExitCode3 = await setResult3.InvokeAsync().DefaultTimeout(); Assert.Equal(0, setExitCode3); // List all configuration var listResult = command.Parse("config list"); - var listExitCode = await listResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var listExitCode = await listResult.InvokeAsync().DefaultTimeout(); Assert.Equal(0, listExitCode); } @@ -320,7 +321,7 @@ public async Task FeatureFlags_WhenSetToTrue_ReturnsTrue() // Set the feature flag to true var command = provider.GetRequiredService(); var setResult = command.Parse($"config set {KnownFeatures.FeaturePrefix}.testFeature true"); - var setExitCode = await setResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var setExitCode = await setResult.InvokeAsync().DefaultTimeout(); Assert.Equal(0, setExitCode); // Check the feature flag @@ -338,7 +339,7 @@ public async Task FeatureFlags_WhenSetToFalse_ReturnsFalse() // Set the feature flag to false var command = provider.GetRequiredService(); var setResult = command.Parse($"config set {KnownFeatures.FeaturePrefix}.testFeature false"); - var setExitCode = await setResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var setExitCode = await setResult.InvokeAsync().DefaultTimeout(); Assert.Equal(0, setExitCode); // Check the feature flag @@ -362,7 +363,7 @@ public async Task FeatureFlags_WhenSetToInvalidValue_ReturnsFalse() // Set the feature flag to an invalid value var command = provider.GetRequiredService(); var setResult = command.Parse($"config set {KnownFeatures.FeaturePrefix}.testFeature invalid"); - var setExitCode = await setResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var setExitCode = await setResult.InvokeAsync().DefaultTimeout(); Assert.Equal(0, setExitCode); // Check the feature flag @@ -395,7 +396,7 @@ public async Task ShowDeprecatedPackages_CanBeConfiguredViaCommandLine() // Set the show deprecated packages feature flag to true var setResult = command.Parse($"config set {KnownFeatures.FeaturePrefix}.{KnownFeatures.ShowDeprecatedPackages} true"); - var setExitCode = await setResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var setExitCode = await setResult.InvokeAsync().DefaultTimeout(); Assert.Equal(0, setExitCode); // Create new service provider to pick up the configuration change diff --git a/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs index e525e8623c8..2283ba66494 100644 --- a/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs @@ -172,7 +172,7 @@ public async Task DeployCommandSucceedsWithoutOutputPath() RequestStopAsyncCalled = deployModeCompleted }; backchannelCompletionSource?.SetResult(backchannel); - await deployModeCompleted.Task; + await deployModeCompleted.Task.DefaultTimeout(); return 0; // Simulate successful run } }; @@ -241,7 +241,7 @@ public async Task DeployCommandSucceedsEndToEnd() RequestStopAsyncCalled = deployModeCompleted }; backchannelCompletionSource?.SetResult(backchannel); - await deployModeCompleted.Task; + await deployModeCompleted.Task.DefaultTimeout(); return 0; // Simulate successful run } }; @@ -313,7 +313,7 @@ public async Task DeployCommandIncludesDeployFlagInArguments() RequestStopAsyncCalled = deployModeCompleted }; backchannelCompletionSource?.SetResult(backchannel); - await deployModeCompleted.Task; + await deployModeCompleted.Task.DefaultTimeout(); return 0; } }; @@ -373,7 +373,7 @@ public async Task DeployCommandReturnsNonZeroExitCodeWhenDeploymentFails() GetPublishingActivitiesAsyncCallback = GetFailedDeploymentActivities }; backchannelCompletionSource?.SetResult(backchannel); - await deployModeCompleted.Task; + await deployModeCompleted.Task.DefaultTimeout(); return 0; // AppHost exits with 0 even though deployment failed } }; diff --git a/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs index 759fee0af58..0049e802c40 100644 --- a/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs @@ -6,6 +6,7 @@ using Aspire.Cli.Tests.TestServices; using Microsoft.Extensions.DependencyInjection; using Aspire.Cli.Utils; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -22,7 +23,7 @@ public async Task DoCommandWithHelpArgumentReturnsZero() var command = provider.GetRequiredService(); var result = command.Parse("do --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -64,7 +65,7 @@ public async Task DoCommandWithStepArgumentSucceeds() RequestStopAsyncCalled = completed }; backchannelCompletionSource?.SetResult(backchannel); - await completed.Task; + await completed.Task.DefaultTimeout(); return 0; } }; @@ -78,7 +79,7 @@ public async Task DoCommandWithStepArgumentSucceeds() // Act var result = command.Parse("do my-custom-step"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -117,7 +118,7 @@ public async Task DoCommandWithDeployStepSucceeds() RequestStopAsyncCalled = completed }; backchannelCompletionSource?.SetResult(backchannel); - await completed.Task; + await completed.Task.DefaultTimeout(); return 0; } }; @@ -131,7 +132,7 @@ public async Task DoCommandWithDeployStepSucceeds() // Act var result = command.Parse("do deploy"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -170,7 +171,7 @@ public async Task DoCommandWithPublishStepSucceeds() RequestStopAsyncCalled = completed }; backchannelCompletionSource?.SetResult(backchannel); - await completed.Task; + await completed.Task.DefaultTimeout(); return 0; } }; @@ -184,7 +185,7 @@ public async Task DoCommandWithPublishStepSucceeds() // Act var result = command.Parse("do publish"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -228,7 +229,7 @@ public async Task DoCommandPassesOutputPathWhenSpecified() RequestStopAsyncCalled = completed }; backchannelCompletionSource?.SetResult(backchannel); - await completed.Task; + await completed.Task.DefaultTimeout(); return 0; } }; @@ -242,7 +243,7 @@ public async Task DoCommandPassesOutputPathWhenSpecified() // Act var result = command.Parse("do my-step --output-path test-output"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -274,7 +275,7 @@ public async Task DoCommandFailsWithInvalidProjectFile() // Act var result = command.Parse("do my-step --project invalid.csproj"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); diff --git a/tests/Aspire.Cli.Tests/Commands/DoctorCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DoctorCommandTests.cs index ab7a6d20110..01ae5e66d9e 100644 --- a/tests/Aspire.Cli.Tests/Commands/DoctorCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DoctorCommandTests.cs @@ -3,6 +3,7 @@ using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -18,7 +19,7 @@ public async Task DoctorCommand_Help_Works() var command = provider.GetRequiredService(); var result = command.Parse("doctor --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Help should return success Assert.Equal(ExitCodeConstants.Success, exitCode); diff --git a/tests/Aspire.Cli.Tests/Commands/ExecCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ExecCommandTests.cs index 992f6244bed..3a07e800bed 100644 --- a/tests/Aspire.Cli.Tests/Commands/ExecCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ExecCommandTests.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; using RootCommand = Aspire.Cli.Commands.RootCommand; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -32,7 +33,7 @@ public async Task ExecCommandWithHelpArgumentReturnsZero() var result = command.Parse("exec --help"); - var exitCode = await result.InvokeAsync(invokeConfiguration).WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync(invokeConfiguration).DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -50,7 +51,7 @@ public async Task ExecCommand_WhenNoProjectFileFound_ReturnsFailedToFindProject( var command = provider.GetRequiredService(); var result = command.Parse("exec --resource api cmd"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); } @@ -68,7 +69,7 @@ public async Task ExecCommand_WhenMultipleProjectFilesFound_ReturnsFailedToFindP var command = provider.GetRequiredService(); var result = command.Parse("exec --resource api cmd"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); } @@ -86,7 +87,7 @@ public async Task ExecCommand_WhenProjectFileDoesNotExist_ReturnsFailedToFindPro var command = provider.GetRequiredService(); var result = command.Parse("exec --resource api cmd"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); } @@ -107,7 +108,7 @@ public async Task ExecCommand_WhenFeatureFlagEnabled_CommandAvailable() var result = command.Parse("exec --help"); - var exitCode = await result.InvokeAsync(invokeConfiguration).WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync(invokeConfiguration).DefaultTimeout(); // Should succeed because exec command is registered when feature flag is enabled Assert.Equal(ExitCodeConstants.Success, exitCode); @@ -130,7 +131,7 @@ public async Task ExecCommand_WhenTargetResourceNotSpecified_ReturnsInvalidComma var result = command.Parse("exec --project test.csproj echo hello"); - var exitCode = await result.InvokeAsync(invokeConfiguration).WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync(invokeConfiguration).DefaultTimeout(); Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); // attempt to find app host should not happen @@ -161,7 +162,7 @@ public async Task ExecCommand_ExecutesSuccessfully() var command = provider.GetRequiredService(); var result = command.Parse("exec --project test.csproj --resource myresource --command echo"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } diff --git a/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs index 34b0c082151..85dc01e97f3 100644 --- a/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.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 System.Text.Json; @@ -8,6 +8,7 @@ using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; using Aspire.TestUtilities; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -35,7 +36,7 @@ public async Task ExtensionInternalCommand_WithHelpArgument_ReturnsZero() var command = provider.GetRequiredService(); var result = command.Parse("extension --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -49,7 +50,7 @@ public async Task ExtensionInternalCommand_WithNoSubcommand_ReturnsZero() var command = provider.GetRequiredService(); var result = command.Parse("extension"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -71,7 +72,7 @@ public async Task GetAppHostsCommand_WithSingleProject_ReturnsSuccessWithValidJs var command = provider.GetRequiredService(); var result = command.Parse("extension get-apphosts"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); // Join all captured output and deserialize @@ -114,7 +115,7 @@ public async Task GetAppHostsCommand_WithMultipleProjects_ReturnsSuccessWithAllC var command = provider.GetRequiredService(); var result = command.Parse("extension get-apphosts"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); // Join all captured output and deserialize @@ -153,7 +154,7 @@ public async Task GetAppHostsCommand_WithNoProjects_ReturnsFailureExitCode() var command = provider.GetRequiredService(); var result = command.Parse("extension get-apphosts"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); } @@ -171,7 +172,7 @@ public async Task GetAppHostsCommand_WhenProjectLocatorThrows_ReturnsFailureExit var command = provider.GetRequiredService(); var result = command.Parse("extension get-apphosts"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); } @@ -185,7 +186,7 @@ public async Task GetAppHostsCommand_WithHelpArgument_ReturnsZero() var command = provider.GetRequiredService(); var result = command.Parse("extension get-apphosts --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index 68b05c30d26..985ffa8bf4b 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -103,7 +104,7 @@ public async Task InitCommand_WhenGetSolutionProjectsFails_SetsOutputCollectorAn // Act - Invoke init command var parseResult = initCommand.Parse("init"); - var exitCode = await parseResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(1, exitCode); // Should return the error exit code @@ -185,7 +186,7 @@ public async Task InitCommand_WhenNewProjectFails_SetsOutputCollectorAndCallsCal // Act - Invoke init command var parseResult = initCommand.Parse("init"); - var exitCode = await parseResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(1, exitCode); // Should return the error exit code @@ -281,7 +282,7 @@ public async Task InitCommand_WithSingleFileAppHost_DoesNotPromptForProjectNameO // Act - Invoke init command var parseResult = initCommand.Parse("init"); - var exitCode = await parseResult.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -414,7 +415,7 @@ public async Task InitCommandWithChannelOptionUsesSpecifiedChannel() var command = provider.GetRequiredService(); var result = command.Parse("init --channel stable"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -439,7 +440,7 @@ public async Task InitCommandWithInvalidChannelShowsError() var command = provider.GetRequiredService(); var result = command.Parse("init --channel invalid-channel"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert - should fail with non-zero exit code for invalid channel Assert.NotEqual(0, exitCode); diff --git a/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs index 8fc4131407d..2dddef0ad14 100644 --- a/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs @@ -5,6 +5,7 @@ using Aspire.Cli.Commands; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -20,7 +21,7 @@ public async Task LogsCommand_Help_Works() var command = provider.GetRequiredService(); var result = command.Parse("logs --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Help should return success Assert.Equal(ExitCodeConstants.Success, exitCode); @@ -122,7 +123,7 @@ public async Task LogsCommand_WithInvalidTailValue_ReturnsError(int tailValue) var command = provider.GetRequiredService(); var result = command.Parse($"logs --tail {tailValue}"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Should fail validation Assert.NotEqual(ExitCodeConstants.Success, exitCode); @@ -143,7 +144,7 @@ public async Task LogsCommand_WithValidTailValue_PassesValidation(int tailValue) // Use --help to avoid needing a running AppHost var result = command.Parse($"logs --tail {tailValue} --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Help should succeed (validation passed) Assert.Equal(ExitCodeConstants.Success, exitCode); @@ -160,7 +161,7 @@ public async Task LogsCommand_WhenNoAppHostRunning_ReturnsSuccess() // Without --follow and no running AppHost, should succeed (like Unix ps with no processes) var result = command.Parse("logs myresource"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Should succeed - no running AppHost is not an error Assert.Equal(ExitCodeConstants.Success, exitCode); @@ -180,7 +181,7 @@ public async Task LogsCommand_FormatOption_IsCaseInsensitive(string format) // Use --help to verify the option is parsed correctly var result = command.Parse($"logs --format {format} --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -198,7 +199,7 @@ public async Task LogsCommand_FormatOption_AcceptsTable(string format) var command = provider.GetRequiredService(); var result = command.Parse($"logs --format {format} --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -213,7 +214,7 @@ public async Task LogsCommand_FormatOption_RejectsInvalidValue() var command = provider.GetRequiredService(); var result = command.Parse("logs --format invalid"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Invalid format should cause parsing error Assert.NotEqual(ExitCodeConstants.Success, exitCode); @@ -229,7 +230,7 @@ public async Task LogsCommand_FollowOption_CanBeCombinedWithTail() var command = provider.GetRequiredService(); var result = command.Parse("logs --follow --tail 50 --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -244,7 +245,7 @@ public async Task LogsCommand_AllOptions_CanBeCombined() var command = provider.GetRequiredService(); var result = command.Parse("logs myresource --follow --tail 100 --format json --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -260,7 +261,7 @@ public async Task LogsCommand_ShortFormOptions_Work() // -f is short for --follow, -n is short for --tail var result = command.Parse("logs -f -n 10 --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } diff --git a/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs index aa651e6237f..ee4354cd54a 100644 --- a/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs @@ -4,6 +4,7 @@ using Aspire.Cli.Commands; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -19,7 +20,7 @@ public async Task McpCommandWithHelpArgumentReturnsZero() var command = provider.GetRequiredService(); var result = command.Parse("mcp --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -33,7 +34,7 @@ public async Task McpStartCommandWithHelpArgumentReturnsZero() var command = provider.GetRequiredService(); var result = command.Parse("mcp start --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -91,7 +92,7 @@ public async Task AgentCommandWithHelpArgumentReturnsZero() var command = provider.GetRequiredService(); var result = command.Parse("agent --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -105,7 +106,7 @@ public async Task AgentMcpCommandWithHelpArgumentReturnsZero() var command = provider.GetRequiredService(); var result = command.Parse("agent mcp --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -119,7 +120,7 @@ public async Task AgentInitCommandWithHelpArgumentReturnsZero() var command = provider.GetRequiredService(); var result = command.Parse("agent init --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index e87592a7c3a..cad6b4c7795 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.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.Backchannel; @@ -31,7 +31,7 @@ public async Task NewCommandWithHelpArgumentReturnsZero() var command = provider.GetRequiredService(); var result = command.Parse("new --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -131,7 +131,7 @@ public async Task NewCommandDerivesOutputPathFromProjectNameForStarterTemplate() var command = provider.GetRequiredService(); var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -184,7 +184,7 @@ public async Task NewCommandDoesNotPromptForProjectNameIfSpecifiedOnCommandLine( var command = provider.GetRequiredService(); var result = command.Parse("new aspire-starter --name MyApp --output . --use-redis-cache --test-framework None"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); Assert.False(promptedForName); } @@ -239,7 +239,7 @@ public async Task NewCommandDoesNotPromptForOutputPathIfSpecifiedOnCommandLine() var command = provider.GetRequiredService(); var result = command.Parse("new aspire-starter --output notsrc --use-redis-cache --test-framework None"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); Assert.False(promptedForPath); } @@ -317,7 +317,7 @@ public async Task NewCommandWithChannelOptionUsesSpecifiedChannel() var command = provider.GetRequiredService(); var result = command.Parse("new aspire-starter --channel stable --use-redis-cache --test-framework None"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -394,7 +394,7 @@ public async Task NewCommandWithChannelOptionAutoSelectsHighestVersion() var command = provider.GetRequiredService(); var result = command.Parse("new aspire-starter --channel stable --use-redis-cache --test-framework None"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -452,7 +452,7 @@ public async Task NewCommandDoesNotPromptForTemplateIfSpecifiedOnCommandLine() var command = provider.GetRequiredService(); var result = command.Parse("new aspire-starter --name MyApp --output . --use-redis-cache --test-framework None"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); Assert.False(promptedForTemplate); } @@ -506,7 +506,7 @@ public async Task NewCommandDoesNotPromptForTemplateVersionIfSpecifiedOnCommandL var command = provider.GetRequiredService(); var result = command.Parse("new aspire-starter --name MyApp --output . --use-redis-cache --test-framework None --version 9.2.0"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.LongTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(TestConstants.LongTimeoutDuration); Assert.Equal(0, exitCode); Assert.False(promptedForTemplateVersion); } @@ -540,7 +540,7 @@ public async Task NewCommand_EmptyPackageList_DisplaysErrorMessage() var command = provider.GetRequiredService(); var result = command.Parse("new"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToCreateNewProject, exitCode); Assert.Contains(TemplatingStrings.NoTemplateVersionsFound, displayedErrorMessage); @@ -595,7 +595,7 @@ public async Task NewCommand_WhenCertificateServiceThrows_ReturnsNonZeroExitCode var command = provider.GetRequiredService(); var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToTrustCertificates, exitCode); } @@ -648,7 +648,7 @@ public async Task NewCommandWithExitCode73ShowsUserFriendlyError() var command = provider.GetRequiredService(); var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToCreateNewProject, exitCode); } @@ -715,7 +715,7 @@ public async Task NewCommandPromptsForTemplateVersionBeforeTemplateOptions() var command = provider.GetRequiredService(); var result = command.Parse("new aspire-starter --name TestApp --output ."); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); // Verify that template version was prompted before template options @@ -793,7 +793,7 @@ public async Task NewCommandEscapesMarkupInProjectNameAndOutputPath() var command = provider.GetRequiredService(); var result = command.Parse("new aspire-starter --use-redis-cache --test-framework None"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); // Verify that the default output path was derived from the project name with markup characters diff --git a/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs index d7d1935e8c4..1fab6c70d01 100644 --- a/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs @@ -4,6 +4,7 @@ using Aspire.Cli.Commands; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -19,7 +20,7 @@ public async Task PsCommand_Help_Works() var command = provider.GetRequiredService(); var result = command.Parse("ps --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -34,7 +35,7 @@ public async Task PsCommand_WhenNoAppHostRunning_ReturnsSuccess() var command = provider.GetRequiredService(); var result = command.Parse("ps"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // ps should succeed even with no running AppHosts (just shows empty list) Assert.Equal(ExitCodeConstants.Success, exitCode); @@ -53,7 +54,7 @@ public async Task PsCommand_FormatOption_IsCaseInsensitive(string format) var command = provider.GetRequiredService(); var result = command.Parse($"ps --format {format}"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -71,7 +72,7 @@ public async Task PsCommand_FormatOption_AcceptsTable(string format) var command = provider.GetRequiredService(); var result = command.Parse($"ps --format {format}"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -86,7 +87,7 @@ public async Task PsCommand_FormatOption_RejectsInvalidValue() var command = provider.GetRequiredService(); var result = command.Parse("ps --format invalid"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.NotEqual(ExitCodeConstants.Success, exitCode); } @@ -105,7 +106,7 @@ public async Task PsCommand_JsonFormat_ReturnsValidJson() var command = provider.GetRequiredService(); var result = command.Parse("ps --format json"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index ad39c42cd6f..02646fd80c4 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -13,6 +13,7 @@ using Spectre.Console; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -45,7 +46,7 @@ public async Task PublishCommand_TextInputPrompt_SendsCorrectKeyPresses() // Act var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -91,7 +92,7 @@ public async Task PublishCommand_SecretTextPrompt_SendsCorrectKeyPresses() // Act var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -143,7 +144,7 @@ public async Task PublishCommand_ChoicePrompt_SendsCorrectSelection() // Act var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -190,7 +191,7 @@ public async Task PublishCommand_BooleanPrompt_SendsCorrectAnswer() // Act var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -236,7 +237,7 @@ public async Task PublishCommand_NumberPrompt_SendsCorrectNumericValue() // Act var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -294,7 +295,7 @@ public async Task PublishCommand_MultiplePrompts_HandlesSequentialInteractions() // Act var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -373,7 +374,7 @@ public async Task PublishCommand_SinglePromptWithMultipleInputs_HandlesAllInputs // Act var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -441,7 +442,7 @@ public async Task PublishCommand_TextInputWithDefaultValue_UsesDefaultCorrectly( // Act var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -493,7 +494,7 @@ public async Task PublishCommand_TextInputWithValidationErrors_UsesValidationErr // Act var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -548,7 +549,7 @@ public async Task PublishCommand_MarkdownPromptText_ConvertsToSpectreMarkup() // Act var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -585,7 +586,7 @@ private static TestDotNetCliRunner CreateTestRunnerWithPromptBackchannel(TestPro runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, cancellationToken) => { backchannelCompletionSource?.SetResult(promptBackchannel); - await promptBackchannel.WaitForCompletion(); + await promptBackchannel.WaitForCompletion().DefaultTimeout(); return 0; }; @@ -619,7 +620,7 @@ public async Task PublishCommand_DebugMode_HandlesPromptsWithoutProgressUI() // Act - use the --debug flag var result = command.Parse("publish --debug"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -663,7 +664,7 @@ public async Task PublishCommand_SingleInputPrompt_ShowsBothStatusTextAndLabel() // Act var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -706,7 +707,7 @@ public async Task PublishCommand_SingleInputPrompt_WhenStatusTextEqualsLabel_Sho // Act var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -747,7 +748,7 @@ public async Task PublishCommand_SingleInputPrompt_EscapesSpectreMarkupInLabels( // Act var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -820,7 +821,7 @@ public async IAsyncEnumerable GetPublishingActivitiesAsync([ } }; - await completionSource.Task.WaitAsync(cancellationToken); + await completionSource.Task.WaitAsync(cancellationToken).DefaultTimeout(); } _completionSource.SetResult(); diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandTests.cs index afd3c24ca06..9f6e8bc1690 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandTests.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.Commands; @@ -194,7 +194,7 @@ public async Task PublishCommandSucceedsEndToEnd() var backchannel = new TestAppHostBackchannel(); backchannel.RequestStopAsyncCalled = inspectModeCompleted; backchannelCompletionSource?.SetResult(backchannel); - await inspectModeCompleted.Task; + await inspectModeCompleted.Task.DefaultTimeout(); return 0; } else @@ -203,7 +203,7 @@ public async Task PublishCommandSucceedsEndToEnd() var backchannel = new TestAppHostBackchannel(); backchannel.RequestStopAsyncCalled = publishModeCompleted; backchannelCompletionSource?.SetResult(backchannel); - await publishModeCompleted.Task; + await publishModeCompleted.Task.DefaultTimeout(); return 0; // Simulate successful run } }; diff --git a/tests/Aspire.Cli.Tests/Commands/ResourcesCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ResourcesCommandTests.cs index 39f8d7a2f00..2586cfee7dd 100644 --- a/tests/Aspire.Cli.Tests/Commands/ResourcesCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ResourcesCommandTests.cs @@ -5,6 +5,7 @@ using Aspire.Cli.Tests.Utils; using Aspire.Shared.Model.Serialization; using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -20,7 +21,7 @@ public async Task ResourcesCommand_Help_Works() var command = provider.GetRequiredService(); var result = command.Parse("resources --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -35,7 +36,7 @@ public async Task ResourcesCommand_WhenNoAppHostRunning_ReturnsSuccess() var command = provider.GetRequiredService(); var result = command.Parse("resources"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Should succeed - no running AppHost is not an error (like Unix ps with no processes) Assert.Equal(ExitCodeConstants.Success, exitCode); @@ -54,7 +55,7 @@ public async Task ResourcesCommand_FormatOption_IsCaseInsensitive(string format) var command = provider.GetRequiredService(); var result = command.Parse($"resources --format {format} --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -72,7 +73,7 @@ public async Task ResourcesCommand_FormatOption_AcceptsTable(string format) var command = provider.GetRequiredService(); var result = command.Parse($"resources --format {format} --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -87,7 +88,7 @@ public async Task ResourcesCommand_FormatOption_RejectsInvalidValue() var command = provider.GetRequiredService(); var result = command.Parse("resources --format invalid"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.NotEqual(ExitCodeConstants.Success, exitCode); } @@ -102,7 +103,7 @@ public async Task ResourcesCommand_WatchOption_CanBeParsed() var command = provider.GetRequiredService(); var result = command.Parse("resources --watch --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -117,7 +118,7 @@ public async Task ResourcesCommand_WatchAndFormat_CanBeCombined() var command = provider.GetRequiredService(); var result = command.Parse("resources --watch --format json --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -132,7 +133,7 @@ public async Task ResourcesCommand_ResourceNameArgument_CanBeParsed() var command = provider.GetRequiredService(); var result = command.Parse("resources myresource --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -147,7 +148,7 @@ public async Task ResourcesCommand_AllOptions_CanBeCombined() var command = provider.GetRequiredService(); var result = command.Parse("resources myresource --watch --format json --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } diff --git a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs index 133ef40cf6a..c8bd67c700f 100644 --- a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -22,7 +23,7 @@ public async Task RootCommandWithHelpArgumentReturnsZero() var command = provider.GetRequiredService(); var result = command.Parse("--help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -36,7 +37,7 @@ public async Task RootCommandWithNoLogoArgumentReturnsZero() var command = provider.GetRequiredService(); var result = command.Parse("--nologo --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -69,7 +70,7 @@ public async Task NoLogoEnvironmentVariable_ParsedCorrectly(string? value, bool // Also verify command still works with the configuration var command = provider.GetRequiredService(); var result = command.Parse("--help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 4bb563f009c..39e7d71292e 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -31,7 +31,7 @@ public async Task RunCommandWithHelpArgumentReturnsZero() var command = provider.GetRequiredService(); var result = command.Parse("run --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -48,7 +48,7 @@ public async Task RunCommand_WhenNoProjectFileFound_ReturnsNonZeroExitCode() var command = provider.GetRequiredService(); var result = command.Parse("run"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); } @@ -65,7 +65,7 @@ public async Task RunCommand_WhenMultipleProjectFilesFound_ReturnsNonZeroExitCod var command = provider.GetRequiredService(); var result = command.Parse("run"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); } @@ -82,7 +82,7 @@ public async Task RunCommand_WhenProjectFileDoesNotExist_ReturnsNonZeroExitCode( var command = provider.GetRequiredService(); var result = command.Parse("run --project /tmp/doesnotexist.csproj"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); } @@ -128,7 +128,7 @@ public async Task RunCommand_WhenCertificateServiceThrows_ReturnsNonZeroExitCode var command = provider.GetRequiredService(); var result = command.Parse("run"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToTrustCertificates, exitCode); } @@ -245,7 +245,7 @@ public async Task RunCommand_CompletesSuccessfully() // Simulate CTRL-C. cts.Cancel(); - var exitCode = await pendingRun.WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await pendingRun.DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -300,7 +300,7 @@ public async Task RunCommand_WithNoResources_CompletesSuccessfully() // Simulate CTRL-C. cts.Cancel(); - var exitCode = await pendingRun.WaitAsync(CliTestConstants.LongTimeout); + var exitCode = await pendingRun.DefaultTimeout(TestConstants.LongTimeoutDuration); Assert.Equal(ExitCodeConstants.Success, exitCode); } @@ -370,7 +370,7 @@ public async Task RunCommand_WhenDashboardFailsToStart_ReturnsNonZeroExitCodeWit var command = provider.GetRequiredService(); var result = command.Parse("run"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert that the command returns the expected failure exit code Assert.Equal(ExitCodeConstants.DashboardFailure, exitCode); @@ -399,7 +399,7 @@ public async Task AppHostHelper_BuildAppHostAsync_IncludesRelativePathInStatusMe File.WriteAllText(appHostProjectFile.FullName, ""); var options = new DotNetCliRunnerInvocationOptions(); - await AppHostHelper.BuildAppHostAsync(testRunner, testInteractionService, appHostProjectFile, options, workspace.WorkspaceRoot, CancellationToken.None); + await AppHostHelper.BuildAppHostAsync(testRunner, testInteractionService, appHostProjectFile, options, workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout(); } [Fact] @@ -466,7 +466,7 @@ public async Task RunCommand_SkipsBuild_WhenExtensionDevKitCapabilityIsAvailable using var cts = new CancellationTokenSource(); var pendingRun = result.InvokeAsync(cancellationToken: cts.Token); cts.Cancel(); - var exitCode = await pendingRun.WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await pendingRun.DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); Assert.False(buildCalled, "Build should be skipped when extension DevKit capability is available."); @@ -536,7 +536,7 @@ public async Task RunCommand_SkipsBuild_WhenRunningInExtension_AndNoBuildInCliCa using var cts = new CancellationTokenSource(); var pendingRun = result.InvokeAsync(cancellationToken: cts.Token); cts.Cancel(); - var exitCode = await pendingRun.WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await pendingRun.DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); Assert.False(buildCalled, "Build should be skipped when running in extension."); @@ -607,10 +607,10 @@ public async Task RunCommand_Builds_WhenExtensionHasBuildDotnetUsingCliCapabilit var pendingRun = result.InvokeAsync(cancellationToken: cts.Token); // Wait for the build to be called before cancelling - await buildCalledTcs.Task.WaitAsync(CliTestConstants.DefaultTimeout); + await buildCalledTcs.Task.DefaultTimeout(); cts.Cancel(); - var exitCode = await pendingRun.WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await pendingRun.DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); Assert.True(buildCalled, "Build should be called when extension has build-dotnet-using-cli capability."); @@ -668,7 +668,7 @@ public async Task RunCommand_WhenSingleFileAppHostAndDefaultWatchEnabled_DoesNot using var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromSeconds(2)); - var exitCode = await result.InvokeAsync(cancellationToken: cts.Token); + var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout(); Assert.False(watchModeUsed, "Expected watch mode to be disabled for single file apps even when DefaultWatchEnabled feature flag is true"); } @@ -727,7 +727,7 @@ public async Task RunCommand_WhenDefaultWatchEnabledFeatureFlagIsTrue_UsesWatchM using var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromSeconds(2)); - var exitCode = await result.InvokeAsync(cancellationToken: cts.Token); + var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout(); Assert.True(watchModeUsed, "Expected watch mode to be enabled when defaultWatchEnabled feature flag is true"); } @@ -786,7 +786,7 @@ public async Task RunCommand_WhenDefaultWatchEnabledFeatureFlagIsFalse_DoesNotUs using var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromSeconds(2)); - var exitCode = await result.InvokeAsync(cancellationToken: cts.Token); + var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout(); Assert.False(watchModeUsed, "Expected watch mode to be disabled when defaultWatchEnabled feature flag is false"); } @@ -845,7 +845,7 @@ public async Task RunCommand_WhenDefaultWatchEnabledFeatureFlagNotSet_DefaultsTo using var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromSeconds(2)); - var exitCode = await result.InvokeAsync(cancellationToken: cts.Token); + var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout(); Assert.False(watchModeUsed, "Expected watch mode to be disabled by default when defaultWatchEnabled feature flag is not set"); } diff --git a/tests/Aspire.Cli.Tests/Commands/SdkInstallerTests.cs b/tests/Aspire.Cli.Tests/Commands/SdkInstallerTests.cs index 6a59f89037a..8b765bff3b1 100644 --- a/tests/Aspire.Cli.Tests/Commands/SdkInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/SdkInstallerTests.cs @@ -5,6 +5,7 @@ using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -28,7 +29,7 @@ public async Task RunCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() var command = provider.GetRequiredService(); var result = command.Parse("run"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.SdkNotInstalled, exitCode); } @@ -53,7 +54,7 @@ public async Task AddCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() var command = provider.GetRequiredService(); var result = command.Parse("add"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.SdkNotInstalled, exitCode); } @@ -75,7 +76,7 @@ public async Task NewCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() var command = provider.GetRequiredService(); var result = command.Parse("new"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.SdkNotInstalled, exitCode); } @@ -97,7 +98,7 @@ public async Task PublishCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() var command = provider.GetRequiredService(); var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.SdkNotInstalled, exitCode); } @@ -119,7 +120,7 @@ public async Task DeployCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() var command = provider.GetRequiredService(); var result = command.Parse("deploy"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.SdkNotInstalled, exitCode); } @@ -142,7 +143,7 @@ public async Task ExecCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() var command = provider.GetRequiredService(); var result = command.Parse("exec"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.SdkNotInstalled, exitCode); } @@ -164,7 +165,7 @@ public async Task RunCommand_WhenSdkInstalled_ContinuesNormalExecution() var command = provider.GetRequiredService(); var result = command.Parse("run"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Should fail at project location, not SDK check Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); } diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index ba25b2e8c60..19114dcf36e 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -13,6 +13,7 @@ using Aspire.Cli.Utils; using Microsoft.Extensions.DependencyInjection; using Spectre.Console; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -28,7 +29,7 @@ public async Task UpdateCommandWithHelpArgumentReturnsZero() var command = provider.GetRequiredService(); var result = command.Parse("update --help"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -69,7 +70,7 @@ public async Task UpdateCommand_WhenProjectOptionSpecified_PassesProjectFileToPr var command = provider.GetRequiredService(); var result = command.Parse($"update --project AppHost.csproj"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -198,7 +199,7 @@ public async Task UpdateCommand_WhenNoProjectFound_PromptsForCliSelfUpdate() var command = provider.GetRequiredService(); var result = command.Parse("update"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.True(confirmCallbackInvoked, "Confirm prompt should have been shown"); @@ -271,7 +272,7 @@ public async Task UpdateCommand_WhenProjectUpdatedSuccessfully_AndChannelSupport var command = provider.GetRequiredService(); var result = command.Parse("update --project AppHost.csproj"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.True(confirmCallbackInvoked, "Confirm prompt should have been shown after successful project update"); @@ -342,7 +343,7 @@ public async Task UpdateCommand_WhenChannelHasNoCliDownloadUrl_DoesNotPromptForC var command = provider.GetRequiredService(); var result = command.Parse("update --project AppHost.csproj"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.False(confirmCallbackInvoked, "Confirm prompt should NOT have been shown for channels without CLI download support"); @@ -389,7 +390,7 @@ public async Task UpdateCommand_SelfUpdate_WithChannelOption_DoesNotPromptForCha var command = provider.GetRequiredService(); var result = command.Parse("update --self --channel daily"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.False(promptForSelectionInvoked, "Channel prompt should not be shown when --channel is provided"); @@ -436,7 +437,7 @@ public async Task UpdateCommand_SelfUpdate_WithQualityOption_DoesNotPromptForQua var command = provider.GetRequiredService(); var result = command.Parse("update --self --quality daily"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.False(promptForSelectionInvoked, "Quality prompt should not be shown when --quality is provided"); @@ -478,7 +479,7 @@ public async Task UpdateCommand_SelfUpdate_WithChannelOption_TracksChannelParame var result = command.Parse("update --self --channel daily"); // Note: exitCode will be non-zero because extraction fails, but that's okay for this test - await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + await result.InvokeAsync().DefaultTimeout(); // Assert - verify the channel parameter was correctly passed through Assert.Equal("daily", capturedChannel); @@ -542,7 +543,7 @@ public async Task UpdateCommand_ProjectUpdate_WithChannelOption_DoesNotPromptFor var command = provider.GetRequiredService(); var result = command.Parse("update --channel daily"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.False(promptForSelectionInvoked, "Channel prompt should not be shown when --channel is provided"); @@ -609,7 +610,7 @@ public async Task UpdateCommand_ProjectUpdate_WithQualityOption_DoesNotPromptFor var command = provider.GetRequiredService(); var result = command.Parse("update --quality daily"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.False(promptForSelectionInvoked, "Channel prompt should not be shown when --quality is provided"); @@ -667,7 +668,7 @@ public async Task UpdateCommand_ProjectUpdate_WithInvalidQuality_DisplaysError() var command = provider.GetRequiredService(); var result = command.Parse("update --quality invalid"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.True(errorDisplayed, "Error should be displayed for invalid quality"); @@ -734,7 +735,7 @@ public async Task UpdateCommand_ProjectUpdate_ChannelTakesPrecedenceOverQuality( var command = provider.GetRequiredService(); var result = command.Parse("update --channel stable --quality daily"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert - should use "stable" from --channel, not "daily" from --quality Assert.False(promptForSelectionInvoked, "Channel prompt should not be shown"); @@ -794,7 +795,7 @@ public async Task UpdateCommand_ProjectUpdate_WhenCancelled_DisplaysCancellation var command = provider.GetRequiredService(); var result = command.Parse("update"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.True(cancellationMessageDisplayed, "Cancellation message should have been displayed"); @@ -856,7 +857,7 @@ public async Task UpdateCommand_WithoutHives_UsesImplicitChannelWithoutPrompting var command = provider.GetRequiredService(); var result = command.Parse("update"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.Equal(0, exitCode); @@ -898,7 +899,7 @@ public async Task UpdateCommand_SelfUpdate_WhenCancelled_DisplaysCancellationMes var command = provider.GetRequiredService(); var result = command.Parse("update --self"); - var exitCode = await result.InvokeAsync().WaitAsync(CliTestConstants.DefaultTimeout); + var exitCode = await result.InvokeAsync().DefaultTimeout(); // Assert Assert.True(cancellationMessageDisplayed, "Cancellation message should have been displayed"); diff --git a/tests/Aspire.Cli.Tests/DotNetSdkInstallerTests.cs b/tests/Aspire.Cli.Tests/DotNetSdkInstallerTests.cs index ff8d136d6fc..d56ed37a41a 100644 --- a/tests/Aspire.Cli.Tests/DotNetSdkInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/DotNetSdkInstallerTests.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 Microsoft.AspNetCore.InternalTesting; using System.Globalization; using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; @@ -42,7 +43,7 @@ public async Task CheckAsync_WhenDotNetIsAvailable_ReturnsTrue() var installer = new DotNetSdkInstaller(new MinimumSdkCheckFeature(), CreateEmptyConfiguration(), CreateTestExecutionContext(), CreateTestDotNetCliRunner(), CreateTestLogger()); // This test assumes the test environment has .NET SDK installed - var (success, _, _, _) = await installer.CheckAsync(); + var (success, _, _, _) = await installer.CheckAsync().DefaultTimeout(); Assert.True(success); } @@ -56,7 +57,7 @@ public async Task CheckAsync_WithMinimumVersion_WhenDotNetIsAvailable_ReturnsTru var installer = new DotNetSdkInstaller(features, configuration, CreateTestExecutionContext(), CreateTestDotNetCliRunner(), CreateTestLogger()); // This test assumes the test environment has .NET SDK installed with a version >= 8.0.0 - var (success, _, _, _) = await installer.CheckAsync(); + var (success, _, _, _) = await installer.CheckAsync().DefaultTimeout(); Assert.True(success); } @@ -71,7 +72,7 @@ public async Task CheckAsync_WithActualMinimumVersion_BehavesCorrectly() // Use the actual minimum version constant and check the behavior // Since this test environment has 8.0.117, it should return false for 9.0.302 - var (success, _, _, _) = await installer.CheckAsync(); + var (success, _, _, _) = await installer.CheckAsync().DefaultTimeout(); // Don't assert the specific result, just ensure the method doesn't throw // The behavior will depend on what SDK versions are actually installed @@ -87,7 +88,7 @@ public async Task CheckAsync_WithHighMinimumVersion_ReturnsFalse() var installer = new DotNetSdkInstaller(features, configuration, CreateTestExecutionContext(), CreateTestDotNetCliRunner(), CreateTestLogger()); // Use an unreasonably high version that should not exist - var (success, _, _, _) = await installer.CheckAsync(); + var (success, _, _, _) = await installer.CheckAsync().DefaultTimeout(); Assert.False(success); } @@ -101,7 +102,7 @@ public async Task CheckAsync_WithInvalidMinimumVersion_ReturnsFalse() var installer = new DotNetSdkInstaller(features, configuration, CreateTestExecutionContext(), CreateTestDotNetCliRunner(), CreateTestLogger()); // Use an invalid version string - var (success, _, _, _) = await installer.CheckAsync(); + var (success, _, _, _) = await installer.CheckAsync().DefaultTimeout(); Assert.False(success); } @@ -164,7 +165,7 @@ public async Task CheckReturnsTrueIfFeatureDisabled() var installer = new DotNetSdkInstaller(features, configuration, CreateTestExecutionContext(), CreateTestDotNetCliRunner(), CreateTestLogger()); // Use an invalid version string - var (success, _, _, _) = await installer.CheckAsync(); + var (success, _, _, _) = await installer.CheckAsync().DefaultTimeout(); Assert.True(success); } @@ -179,7 +180,7 @@ public async Task CheckAsync_UsesArchitectureSpecificCommand() // This test verifies that the architecture-specific command is used // Since the implementation adds --arch flag, it should still work correctly - var (success, _, _, _) = await installer.CheckAsync(); + var (success, _, _, _) = await installer.CheckAsync().DefaultTimeout(); // The test should pass if the command with --arch flag works Assert.True(success); @@ -192,7 +193,7 @@ public async Task CheckAsync_UsesOverrideMinimumSdkVersion_WhenConfigured() var installer = new DotNetSdkInstaller(new MinimumSdkCheckFeature(), configuration, CreateTestExecutionContext(), CreateTestDotNetCliRunner(), CreateTestLogger()); // The installer should use the override version instead of the constant - var (success, _, _, _) = await installer.CheckAsync(); + var (success, _, _, _) = await installer.CheckAsync().DefaultTimeout(); // Should use 8.0.0 instead of 9.0.302, which should be available in test environment Assert.True(success); @@ -204,7 +205,7 @@ public async Task CheckAsync_UsesDefaultMinimumSdkVersion_WhenNotConfigured() var installer = new DotNetSdkInstaller(new MinimumSdkCheckFeature(), CreateEmptyConfiguration(), CreateTestExecutionContext(), CreateTestDotNetCliRunner(), CreateTestLogger()); // Call the parameterless method that should use the default constant - var (success, _, _, _) = await installer.CheckAsync(); + var (success, _, _, _) = await installer.CheckAsync().DefaultTimeout(); // The result depends on whether 9.0.302 is installed, but the test ensures no exception is thrown Assert.True(success == true || success == false); @@ -219,7 +220,7 @@ public async Task CheckAsync_UsesMinimumSdkVersion() var installer = new DotNetSdkInstaller(features, CreateEmptyConfiguration(), context, CreateTestDotNetCliRunner(), CreateTestLogger()); // Call the parameterless method that should use the minimum SDK version - var (success, _, _, _) = await installer.CheckAsync(); + var (success, _, _, _) = await installer.CheckAsync().DefaultTimeout(); // The result depends on whether 10.0.100 is installed, but the test ensures no exception is thrown Assert.True(success == true || success == false); @@ -234,7 +235,7 @@ public async Task CheckAsync_UsesOverrideVersion_WhenOverrideConfigured() var installer = new DotNetSdkInstaller(features, configuration, CreateTestExecutionContext(), CreateTestDotNetCliRunner(), CreateTestLogger()); // The installer should use the override version instead of the baseline constant - var (success, _, _, _) = await installer.CheckAsync(); + var (success, _, _, _) = await installer.CheckAsync().DefaultTimeout(); // Should use 8.0.0 instead of 10.0.100, which should be available in test environment Assert.True(success); diff --git a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs index e7a01e17502..88c4c9a1658 100644 --- a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.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 Microsoft.AspNetCore.InternalTesting; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Utils; @@ -194,7 +195,7 @@ public async Task ShowStatusAsync_InDebugMode_DisplaysSubtleMessageInsteadOfSpin var result = "test result"; // Act - var actualResult = await interactionService.ShowStatusAsync(statusText, () => Task.FromResult(result)); + var actualResult = await interactionService.ShowStatusAsync(statusText, () => Task.FromResult(result)).DefaultTimeout(); // Assert Assert.Equal(result, actualResult); diff --git a/tests/Aspire.Cli.Tests/Mcp/Docs/LlmsTxtParserTests.cs b/tests/Aspire.Cli.Tests/Mcp/Docs/LlmsTxtParserTests.cs index e6df0773ad7..57476b475ca 100644 --- a/tests/Aspire.Cli.Tests/Mcp/Docs/LlmsTxtParserTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/Docs/LlmsTxtParserTests.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 Microsoft.AspNetCore.InternalTesting; using Aspire.Cli.Mcp.Docs; namespace Aspire.Cli.Tests.Mcp.Docs; @@ -10,7 +11,7 @@ public class LlmsTxtParserTests [Fact] public async Task ParseAsync_WithEmptyString_ReturnsEmptyList() { - var result = await LlmsTxtParser.ParseAsync(""); + var result = await LlmsTxtParser.ParseAsync("").DefaultTimeout(); Assert.Empty(result); } @@ -18,7 +19,7 @@ public async Task ParseAsync_WithEmptyString_ReturnsEmptyList() [Fact] public async Task ParseAsync_WithWhitespaceOnly_ReturnsEmptyList() { - var result = await LlmsTxtParser.ParseAsync(" \n\t\n "); + var result = await LlmsTxtParser.ParseAsync(" \n\t\n ").DefaultTimeout(); Assert.Empty(result); } @@ -32,7 +33,7 @@ No headers here. ## This is H2 but no H1 """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Empty(result); } @@ -47,7 +48,7 @@ public async Task ParseAsync_WithSingleDocument_ParsesCorrectly() Some body content here. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); var doc = result[0]; @@ -75,7 +76,7 @@ Second content. Third content without summary. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Equal(3, result.Count); Assert.Equal("First Document", result[0].Title); @@ -100,7 +101,7 @@ Section two content. Subsection content. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); var doc = result[0]; @@ -132,7 +133,7 @@ Child content. Another content. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); var doc = result[0]; var parentSection = doc.Sections.First(s => s.Heading == "Parent Section"); @@ -153,7 +154,7 @@ public async Task ParseAsync_WithNoSummary_SummaryIsNull() Just regular content, no blockquote. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); Assert.Null(result[0].Summary); @@ -167,7 +168,7 @@ public async Task ParseAsync_SlugGeneration_HandlesSpecialCharacters() Content. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); Assert.Equal("hello-world-hows-it-going", result[0].Slug); @@ -181,7 +182,7 @@ public async Task ParseAsync_SlugGeneration_HandlesMultipleSpaces() Content. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); // Multiple spaces should become single hyphens @@ -196,7 +197,7 @@ public async Task ParseAsync_SlugGeneration_TrimsHyphens() Content. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); // Leading and trailing hyphens should be trimmed @@ -220,7 +221,7 @@ public async Task ParseAsync_WithCodeBlocks_PreservesContent() More text. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); Assert.Contains("```csharp", result[0].Content); @@ -236,7 +237,7 @@ public async Task ParseAsync_H1WithoutSpace_NotRecognizedAsDocument() Content. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Empty(result); } @@ -249,7 +250,7 @@ public async Task ParseAsync_H2AtStart_NotRecognizedAsDocument() Content. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Empty(result); } @@ -262,7 +263,7 @@ public async Task ParseAsync_WithLeadingWhitespace_StillParsesH1() Content. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); Assert.Equal("Document With Leading Spaces", result[0].Title); @@ -281,7 +282,7 @@ Line 2. Line 3. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); Assert.Contains("\n", result[0].Content); @@ -298,7 +299,7 @@ First content with ## inside text. Second content. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Equal(2, result.Count); Assert.Contains("## inside text", result[0].Content); @@ -344,7 +345,7 @@ dotnet workload install aspire ``` """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Equal(2, result.Count); @@ -396,7 +397,7 @@ Content without title. Valid content. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); Assert.Equal("Valid Title", result[0].Title); @@ -413,7 +414,7 @@ public async Task ParseAsync_MultipleSummaryBlockquotes_UsesFirst() Content. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); Assert.Equal("First summary.", result[0].Summary); @@ -429,7 +430,7 @@ public async Task ParseAsync_BlockquoteAfterSection_NotUsedAsSummary() > This blockquote is in a section, not a summary. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); Assert.Null(result[0].Summary); @@ -447,7 +448,7 @@ public async Task ParseAsync_SlugGeneration_VariousCases(string title, string ex { var content = $"# {title}\nContent."; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); Assert.Equal(expectedSlug, result[0].Slug); @@ -459,7 +460,7 @@ public async Task ParseAsync_InlineSections_ParsesCorrectly() // Minified content with inline sections using [Section titled...] markers (like aspire.dev format) var content = "# Document Title\n> Summary text. ## First Section [Section titled \"First Section\"] Content for first section. ## Second Section [Section titled \"Second Section\"] Content for second section. ### Subsection [Section titled \"Subsection\"] Subsection content."; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); var doc = result[0]; @@ -493,7 +494,7 @@ Some content. Real section content. """; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); var doc = result[0]; @@ -510,7 +511,7 @@ public async Task ParseAsync_SectionTitledMarker_StrippedFromHeading() // Content with [Section titled...] markers like aspire.dev uses var content = "# Main Doc\n> Summary. ## Getting Started [Section titled \"Getting Started\"] This section explains..."; - var result = await LlmsTxtParser.ParseAsync(content); + var result = await LlmsTxtParser.ParseAsync(content).DefaultTimeout(); Assert.Single(result); var doc = result[0]; @@ -521,7 +522,7 @@ public async Task ParseAsync_SectionTitledMarker_StrippedFromHeading() [Fact] public async Task ParseAsync_AspireDotDevContent_ParsesFourDocuments() { - var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample); + var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample).DefaultTimeout(); Assert.Equal(4, result.Count); } @@ -529,7 +530,7 @@ public async Task ParseAsync_AspireDotDevContent_ParsesFourDocuments() [Fact] public async Task ParseAsync_AspireDotDevContent_ParsesDocumentTitlesCorrectly() { - var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample); + var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample).DefaultTimeout(); // Note: First article starts after a blank line following the tag Assert.Equal("Certificate configuration", result[0].Title); @@ -541,7 +542,7 @@ public async Task ParseAsync_AspireDotDevContent_ParsesDocumentTitlesCorrectly() [Fact] public async Task ParseAsync_AspireDotDevContent_GeneratesCorrectSlugs() { - var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample); + var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample).DefaultTimeout(); Assert.Equal("certificate-configuration", result[0].Slug); Assert.Equal("apphost-configuration", result[1].Slug); @@ -552,7 +553,7 @@ public async Task ParseAsync_AspireDotDevContent_GeneratesCorrectSlugs() [Fact] public async Task ParseAsync_AspireDotDevContent_ParsesSummariesCorrectly() { - var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample); + var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample).DefaultTimeout(); Assert.Equal("Learn how to configure HTTPS endpoints and certificate trust for resources in Aspire to enable secure communication.", result[0].Summary); Assert.Equal("Learn about the Aspire AppHost configuration options.", result[1].Summary); @@ -563,7 +564,7 @@ public async Task ParseAsync_AspireDotDevContent_ParsesSummariesCorrectly() [Fact] public async Task ParseAsync_AspireDotDevContent_ParsesSectionsForCertificatesDoc() { - var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample); + var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample).DefaultTimeout(); var certificatesDoc = result[0]; Assert.True(certificatesDoc.Sections.Count > 0, "Certificate doc should have sections"); @@ -583,7 +584,7 @@ public async Task ParseAsync_AspireDotDevContent_ParsesSectionsForCertificatesDo [Fact] public async Task ParseAsync_AspireDotDevContent_ParsesSectionsForAppHostConfigDoc() { - var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample); + var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample).DefaultTimeout(); var appHostConfigDoc = result[1]; Assert.True(appHostConfigDoc.Sections.Count > 0, "AppHost config doc should have sections"); @@ -599,7 +600,7 @@ public async Task ParseAsync_AspireDotDevContent_ParsesSectionsForAppHostConfigD [Fact] public async Task ParseAsync_AspireDotDevContent_ParsesSectionsForDockerComposeDoc() { - var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample); + var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample).DefaultTimeout(); var dockerComposeDoc = result[2]; Assert.True(dockerComposeDoc.Sections.Count > 0, "Docker Compose doc should have sections"); @@ -615,7 +616,7 @@ public async Task ParseAsync_AspireDotDevContent_ParsesSectionsForDockerComposeD [Fact] public async Task ParseAsync_AspireDotDevContent_ParsesSectionsForEventingDoc() { - var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample); + var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample).DefaultTimeout(); var eventingDoc = result[3]; Assert.True(eventingDoc.Sections.Count > 0, "Eventing doc should have sections"); @@ -635,7 +636,7 @@ public async Task ParseAsync_AspireDotDevContent_ParsesSectionsForEventingDoc() [Fact] public async Task ParseAsync_AspireDotDevContent_ContentContainsCodeBlocks() { - var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample); + var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample).DefaultTimeout(); // HTTPS certificates doc should have C# code examples Assert.Contains("```csharp", result[0].Content); @@ -656,7 +657,7 @@ public async Task ParseAsync_AspireDotDevContent_ContentContainsCodeBlocks() [Fact] public async Task ParseAsync_AspireDotDevContent_DocumentBoundariesAreCorrect() { - var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample); + var result = await LlmsTxtParser.ParseAsync(AspireDotDevFourArticleExample).DefaultTimeout(); // Each document's content should not contain other documents' titles Assert.DoesNotContain("# AppHost configuration", result[0].Content); diff --git a/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs index d28b97c4686..8d7738f7698 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.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 Microsoft.AspNetCore.InternalTesting; using Aspire.Cli.Backchannel; using Aspire.Cli.Mcp.Tools; using Aspire.Cli.Tests.TestServices; @@ -19,7 +20,7 @@ public async Task ListAppHostsTool_ReturnsEmptyListWhenNoConnections() var executionContext = CreateCliExecutionContext(workspace.WorkspaceRoot); var tool = new ListAppHostsTool(monitor, executionContext); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None); + var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); Assert.Null(result.IsError); Assert.NotNull(result.Content); @@ -54,7 +55,7 @@ public async Task ListAppHostsTool_ReturnsInScopeAppHosts() monitor.AddConnection("hash1", "socket.hash1", connection); var tool = new ListAppHostsTool(monitor, executionContext); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None); + var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); Assert.Null(result.IsError); var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; @@ -86,7 +87,7 @@ public async Task ListAppHostsTool_ReturnsOutOfScopeAppHosts() monitor.AddConnection("hash2", "socket.hash2", connection); var tool = new ListAppHostsTool(monitor, executionContext); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None); + var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); Assert.Null(result.IsError); var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; @@ -129,7 +130,7 @@ public async Task ListAppHostsTool_SeparatesInScopeAndOutOfScopeAppHosts() monitor.AddConnection("hash2", "socket.hash2", outOfScopeConnection); var tool = new ListAppHostsTool(monitor, executionContext); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None); + var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); Assert.Null(result.IsError); var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; @@ -153,12 +154,12 @@ public async Task ListAppHostsTool_CallsScanAsyncBeforeReturningResults() Assert.Equal(0, monitor.ScanCallCount); var tool = new ListAppHostsTool(monitor, executionContext); - await tool.CallToolAsync(null!, null, CancellationToken.None); + await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); Assert.Equal(1, monitor.ScanCallCount); // Call again to verify it scans each time - await tool.CallToolAsync(null!, null, CancellationToken.None); + await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); Assert.Equal(2, monitor.ScanCallCount); } diff --git a/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs index d1c2d6fa209..ecb7e9ee88c 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.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 Microsoft.AspNetCore.InternalTesting; using System.Text.Json; using Aspire.Cli.Mcp.Tools; @@ -48,7 +49,7 @@ public async Task ListIntegrationsTool_CallToolAsync_ReturnsEmptyJsonArray_WhenN var mockPackagingService = new MockPackagingService(); var tool = new ListIntegrationsTool(mockPackagingService, TestExecutionContextFactory.CreateTestContext(), new MockAuxiliaryBackchannelMonitor()); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None); + var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); Assert.NotNull(result.Content); @@ -73,7 +74,7 @@ public async Task ListIntegrationsTool_CallToolAsync_ReturnsJsonWithPackages_Whe }); var tool = new ListIntegrationsTool(mockPackagingService, TestExecutionContextFactory.CreateTestContext(), new MockAuxiliaryBackchannelMonitor()); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None); + var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); Assert.NotNull(result.Content); @@ -111,7 +112,7 @@ public async Task ListIntegrationsTool_UsesDefaultChannelOnly() }); var tool = new ListIntegrationsTool(mockPackagingService, TestExecutionContextFactory.CreateTestContext(), new MockAuxiliaryBackchannelMonitor()); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None); + var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); diff --git a/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs b/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs index df41df2b214..27ef3717480 100644 --- a/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs +++ b/tests/Aspire.Cli.Tests/NuGet/NuGetPackageCacheTests.cs @@ -6,6 +6,7 @@ using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; using NuGetPackage = Aspire.Shared.NuGetPackageCli; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.NuGet; @@ -36,7 +37,7 @@ public async Task NonAspireCliPackagesWillNotBeConsidered() var provider = services.BuildServiceProvider(); var nuGetPackageCache = provider.GetRequiredService(); - var packages = await nuGetPackageCache.GetCliPackagesAsync(workspace.WorkspaceRoot, prerelease: true, nugetConfigFile: null, CancellationToken.None).WaitAsync(CliTestConstants.DefaultTimeout); + var packages = await nuGetPackageCache.GetCliPackagesAsync(workspace.WorkspaceRoot, prerelease: true, nugetConfigFile: null, CancellationToken.None).DefaultTimeout(); Assert.Collection( packages, @@ -70,7 +71,7 @@ public async Task DeprecatedPackagesAreFilteredByDefault() var provider = services.BuildServiceProvider(); var nuGetPackageCache = provider.GetRequiredService(); - var packages = await nuGetPackageCache.GetPackagesAsync(workspace.WorkspaceRoot, "Aspire.Hosting", null, prerelease: false, nugetConfigFile: null, useCache: true, CancellationToken.None).WaitAsync(CliTestConstants.DefaultTimeout); + var packages = await nuGetPackageCache.GetPackagesAsync(workspace.WorkspaceRoot, "Aspire.Hosting", null, prerelease: false, nugetConfigFile: null, useCache: true, CancellationToken.None).DefaultTimeout(); // Should include regular packages but exclude deprecated Dapr package var packageIds = packages.Select(p => p.Id).ToList(); @@ -108,7 +109,7 @@ public async Task DeprecatedPackagesAreIncludedWhenShowDeprecatedPackagesEnabled var provider = services.BuildServiceProvider(); var nuGetPackageCache = provider.GetRequiredService(); - var packages = await nuGetPackageCache.GetPackagesAsync(workspace.WorkspaceRoot, "Aspire.Hosting", null, prerelease: false, nugetConfigFile: null, useCache: true, CancellationToken.None).WaitAsync(CliTestConstants.DefaultTimeout); + var packages = await nuGetPackageCache.GetPackagesAsync(workspace.WorkspaceRoot, "Aspire.Hosting", null, prerelease: false, nugetConfigFile: null, useCache: true, CancellationToken.None).DefaultTimeout(); // Should include all packages including deprecated Dapr package when showing deprecated is enabled var packageIds = packages.Select(p => p.Id).ToList(); @@ -152,7 +153,7 @@ public async Task CustomFilterBypassesDeprecatedPackageFiltering() prerelease: false, nugetConfigFile: null, useCache: true, - CancellationToken.None).WaitAsync(CliTestConstants.DefaultTimeout); + CancellationToken.None).DefaultTimeout(); // Custom filter should bypass deprecated package filtering var packageIds = packages.Select(p => p.Id).ToList(); @@ -187,7 +188,7 @@ public async Task DeprecatedPackageFilteringIsCaseInsensitive() var provider = services.BuildServiceProvider(); var nuGetPackageCache = provider.GetRequiredService(); - var packages = await nuGetPackageCache.GetPackagesAsync(workspace.WorkspaceRoot, "Aspire.Hosting", null, prerelease: false, nugetConfigFile: null, useCache: true, CancellationToken.None).WaitAsync(CliTestConstants.DefaultTimeout); + var packages = await nuGetPackageCache.GetPackagesAsync(workspace.WorkspaceRoot, "Aspire.Hosting", null, prerelease: false, nugetConfigFile: null, useCache: true, CancellationToken.None).DefaultTimeout(); // Should filter out all case variations of deprecated package var packageIds = packages.Select(p => p.Id).ToList(); diff --git a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs index a7215d02ca2..e1fc3f227f5 100644 --- a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.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 Microsoft.AspNetCore.InternalTesting; using System.Xml.Linq; using Aspire.Cli.Packaging; using Aspire.Cli.NuGet; @@ -76,12 +77,12 @@ await WriteConfigAsync(root, """); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Filter to explicit channels here so we never select the implicit ("default") channel // which has no mappings and would produce a no-op merge (nothing meaningful to snapshot). var channel = channels.First(c => c.Type is PackageChannelType.Explicit && string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var updated = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var xmlString = updated.ToString(); @@ -138,12 +139,12 @@ await WriteConfigAsync(root, """); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Filter to explicit channels here so we never select the implicit ("default") channel // which has no mappings and would produce a no-op merge (nothing meaningful to snapshot). var channel = channels.First(c => c.Type is PackageChannelType.Explicit && string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var updated = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var xmlString = updated.ToString(); @@ -199,12 +200,12 @@ await WriteConfigAsync(root, """); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Filter to explicit channels here so we never select the implicit ("default") channel // which has no mappings and would produce a no-op merge (nothing meaningful to snapshot). var channel = channels.First(c => c.Type is PackageChannelType.Explicit && string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var updated = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var xmlString = updated.ToString(); @@ -258,12 +259,12 @@ await WriteConfigAsync(root, """); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Filter to explicit channels here so we never select the implicit ("default") channel // which has no mappings and would produce a no-op merge (nothing meaningful to snapshot). var channel = channels.First(c => c.Type is PackageChannelType.Explicit && string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var updated = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var xmlString = updated.ToString(); @@ -322,12 +323,12 @@ await WriteConfigAsync(root, """); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Filter to explicit channels here so we never select the implicit ("default") channel // which has no mappings and would produce a no-op merge (nothing meaningful to snapshot). var channel = channels.First(c => c.Type is PackageChannelType.Explicit && string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var updated = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var xmlString = updated.ToString(); diff --git a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs index c37fe335630..3f648c69a8c 100644 --- a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.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 Microsoft.AspNetCore.InternalTesting; using System.Xml.Linq; using System.Xml; using Aspire.Cli.Packaging; @@ -60,7 +61,7 @@ public async Task CreateOrUpdateAsync_CreatesConfigFromMappings_WhenNoExistingCo }; var channel = CreateChannel(mappings); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var targetConfigPath = Path.Combine(root.FullName, "nuget.config"); Assert.True(File.Exists(targetConfigPath)); @@ -84,7 +85,7 @@ public async Task CreateOrUpdateAsync_GeneratesConfigFromMappings_WhenChannelPro }; var channel = CreateChannel(mappings); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var targetConfigPath = Path.Combine(root.FullName, "nuget.config"); Assert.True(File.Exists(targetConfigPath)); @@ -128,7 +129,7 @@ await WriteConfigAsync(root, }; var channel = CreateChannel(mappings); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var packageSources = xml.Root!.Element("packageSources")!; @@ -167,7 +168,7 @@ await WriteConfigAsync(root, }; var channel = CreateChannel(mappings); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var packageSources = xml.Root!.Element("packageSources")!; @@ -207,7 +208,7 @@ await WriteConfigAsync(root, }; var channel = CreateChannel(mappings); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var psm = xml.Root!.Element("packageSourceMapping"); @@ -317,7 +318,7 @@ await WriteConfigAsync(root, }; var channel = CreateChannel(mappings); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var packageSources = xml.Root!.Element("packageSources")!; @@ -367,7 +368,7 @@ await WriteConfigAsync(root, }; var channel = CreateChannel(mappings); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var packageSources = xml.Root!.Element("packageSources")!; @@ -436,7 +437,7 @@ await WriteConfigAsync(root, }; var channel = CreateChannel(mappings); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var packageSources = xml.Root!.Element("packageSources")!; @@ -502,7 +503,7 @@ await WriteConfigAsync(root, }; var channel = CreateChannel(mappings); - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); var packageSources = xml.Root!.Element("packageSources")!; @@ -627,7 +628,7 @@ public async Task CreateOrUpdateAsync_CallbackInvokedForExistingConfig() """; - await WriteConfigAsync(root, existingConfig); + await WriteConfigAsync(root, existingConfig).DefaultTimeout(); var mappings = new[] { @@ -678,8 +679,8 @@ public async Task CreateOrUpdateAsync_CallbackCanPreventExistingConfigUpdate() """; - await WriteConfigAsync(root, existingConfig); - var originalContent = await File.ReadAllTextAsync(Path.Combine(root.FullName, "nuget.config")); + await WriteConfigAsync(root, existingConfig).DefaultTimeout(); + var originalContent = await File.ReadAllTextAsync(Path.Combine(root.FullName, "nuget.config")).DefaultTimeout(); var mappings = new[] { @@ -719,7 +720,7 @@ public async Task CreateOrUpdateAsync_WorksWithoutCallback() var channel = CreateChannel(mappings); // Call without callback - should work as before - await NuGetConfigMerger.CreateOrUpdateAsync(root, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); // Verify file was created var targetConfigPath = Path.Combine(root.FullName, "nuget.config"); diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 658b68e796b..d68f78f1229 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.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 Microsoft.AspNetCore.InternalTesting; using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; @@ -51,7 +52,7 @@ public async Task GetChannelsAsync_WhenStagingChannelDisabled_DoesNotIncludeStag var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); // Act - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Assert var channelNames = channels.Select(c => c.Name).ToList(); @@ -95,7 +96,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabled_IncludesStagingChan var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); // Act - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Assert var channelNames = channels.Select(c => c.Name).ToList(); @@ -139,7 +140,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithOverrideFeed_Use var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); // Act - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Assert var stagingChannel = channels.First(c => c.Name == "staging"); @@ -172,7 +173,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithAzureDevOpsFeedO var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); // Act - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Assert var stagingChannel = channels.First(c => c.Name == "staging"); @@ -205,7 +206,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithInvalidOverrideF var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); // Act - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Assert // When invalid URL is provided, staging channel should not be created (falls back to default behavior which returns null) @@ -237,7 +238,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithQualityOverride_ var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); // Act - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Assert var stagingChannel = channels.First(c => c.Name == "staging"); @@ -268,7 +269,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithQualityBoth_Uses var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); // Act - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Assert var stagingChannel = channels.First(c => c.Name == "staging"); @@ -299,7 +300,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithInvalidQuality_D var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); // Act - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Assert var stagingChannel = channels.First(c => c.Name == "staging"); @@ -329,7 +330,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithoutQualityOverri var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); // Act - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Assert var stagingChannel = channels.First(c => c.Name == "staging"); @@ -359,11 +360,11 @@ public async Task NuGetConfigMerger_WhenChannelRequiresGlobalPackagesFolder_Adds features, configuration); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var stagingChannel = channels.First(c => c.Name == "staging"); // Act - await NuGetConfigMerger.CreateOrUpdateAsync(tempDir, stagingChannel); + await NuGetConfigMerger.CreateOrUpdateAsync(tempDir, stagingChannel).DefaultTimeout(); // Assert var nugetConfigPath = Path.Combine(tempDir.FullName, "nuget.config"); @@ -413,7 +414,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabled_StagingAppearsAfter var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); // Act - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Assert var channelNames = channels.Select(c => c.Name).ToList(); @@ -463,7 +464,7 @@ public async Task GetChannelsAsync_WhenStagingChannelDisabled_OrderIsDefaultStab var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); // Act - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); // Assert var channelNames = channels.Select(c => c.Name).ToList(); diff --git a/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs b/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs index 12f7810a2c7..d3641ba4bfe 100644 --- a/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs @@ -108,4 +108,4 @@ public async Task CreateAsync_WithNoMappings_CreatesValidConfig() var packageSourceMappingNode = xmlDoc.SelectSingleNode("//packageSourceMapping"); Assert.Null(packageSourceMappingNode); } -} \ No newline at end of file +} diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index df776799509..0b5675479d0 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.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 Microsoft.AspNetCore.InternalTesting; using System.Xml.Linq; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; @@ -59,7 +60,7 @@ public async Task CreateProjectFiles_ProductionCsproj_MatchesSnapshot() }; // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages); + var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); // Assert var csprojContent = await File.ReadAllTextAsync(projectPath); @@ -83,7 +84,7 @@ public async Task CreateProjectFiles_AppSettingsJson_MatchesSnapshot() }; // Act - await project.CreateProjectFilesAsync("13.1.0", packages); + await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); // Assert var appSettingsPath = Path.Combine(project.ProjectModelPath, "appsettings.json"); @@ -104,7 +105,7 @@ public async Task CreateProjectFiles_ProgramCs_MatchesSnapshot() }; // Act - await project.CreateProjectFilesAsync("13.1.0", packages); + await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); // Assert var programCsPath = Path.Combine(project.ProjectModelPath, "Program.cs"); @@ -129,7 +130,7 @@ public async Task CreateProjectFiles_GeneratesProductionCsproj_WithAspireSdk() }; // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages); + var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); // Assert Assert.True(File.Exists(projectPath)); @@ -155,7 +156,7 @@ public async Task CreateProjectFiles_ProductionMode_FiltersOutImplicitPackages() }; // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages); + var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); // Assert var doc = XDocument.Load(projectPath); @@ -197,7 +198,7 @@ public async Task CreateProjectFiles_ProductionMode_CorrectlyFiltersPackages(str }; // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages); + var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); // Assert var doc = XDocument.Load(projectPath); @@ -228,7 +229,7 @@ public async Task CreateProjectFiles_ProductionMode_AlwaysAddsRemoteHost() }; // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages); + var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); // Assert var doc = XDocument.Load(projectPath); @@ -252,7 +253,7 @@ public async Task CreateProjectFiles_GeneratesProgramCs() }; // Act - await project.CreateProjectFilesAsync("13.1.0", packages); + await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); // Assert var programCs = Path.Combine(project.ProjectModelPath, "Program.cs"); @@ -275,7 +276,7 @@ public async Task CreateProjectFiles_GeneratesAppSettingsJson_WithAtsAssemblies( }; // Act - await project.CreateProjectFilesAsync("13.1.0", packages); + await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); // Assert var appSettingsPath = Path.Combine(project.ProjectModelPath, "appsettings.json"); @@ -299,7 +300,7 @@ public async Task CreateProjectFiles_ProductionMode_HasMinimalProperties() }; // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages); + var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); // Assert var doc = XDocument.Load(projectPath); @@ -332,7 +333,7 @@ public async Task CreateProjectFiles_ProductionMode_DisablesCodeGeneration() }; // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages); + var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); // Assert var doc = XDocument.Load(projectPath); @@ -354,7 +355,7 @@ public async Task CreateProjectFiles_CopiesAppSettingsToOutput() }; // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages); + var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); // Assert var doc = XDocument.Load(projectPath); @@ -418,7 +419,7 @@ public async Task CreateProjectFiles_UsesSdkVersionInPackageAttribute() }; // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.2.0", packages); + var (projectPath, _) = await project.CreateProjectFilesAsync("13.2.0", packages).DefaultTimeout(); // Assert var doc = XDocument.Load(projectPath); @@ -439,7 +440,7 @@ public async Task CreateProjectFiles_PackageVersionsMatchSdkVersion() }; // Act - var (projectPath, _) = await project.CreateProjectFilesAsync(sdkVersion, packages); + var (projectPath, _) = await project.CreateProjectFilesAsync(sdkVersion, packages).DefaultTimeout(); // Assert var doc = XDocument.Load(projectPath); @@ -521,7 +522,7 @@ await File.WriteAllTextAsync(settingsJson, """ }; // Act - await project.CreateProjectFilesAsync("13.1.0", packages); + await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); // Dump workspace directory tree for debugging outputHelper.WriteLine("=== Workspace Directory Tree ==="); diff --git a/tests/Aspire.Cli.Tests/Projects/DefaultLanguageDiscoveryTests.cs b/tests/Aspire.Cli.Tests/Projects/DefaultLanguageDiscoveryTests.cs index d1752760f94..20d0b654d7e 100644 --- a/tests/Aspire.Cli.Tests/Projects/DefaultLanguageDiscoveryTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/DefaultLanguageDiscoveryTests.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 Microsoft.AspNetCore.InternalTesting; using Aspire.Cli.Projects; namespace Aspire.Cli.Tests.Projects; @@ -12,7 +13,7 @@ public async Task GetAvailableLanguagesAsync_ReturnsCSharpLanguage() { var discovery = new DefaultLanguageDiscovery(); - var languages = await discovery.GetAvailableLanguagesAsync(); + var languages = await discovery.GetAvailableLanguagesAsync().DefaultTimeout(); var csharp = languages.FirstOrDefault(l => l.LanguageId.Value == KnownLanguageId.CSharp); Assert.NotNull(csharp); @@ -28,7 +29,7 @@ public async Task GetAvailableLanguagesAsync_CSharpLanguageHasExpectedDetectionP { var discovery = new DefaultLanguageDiscovery(); - var languages = await discovery.GetAvailableLanguagesAsync(); + var languages = await discovery.GetAvailableLanguagesAsync().DefaultTimeout(); var csharp = languages.First(l => l.LanguageId.Value == KnownLanguageId.CSharp); Assert.Contains(expectedPattern, csharp.DetectionPatterns); @@ -39,7 +40,7 @@ public async Task GetAvailableLanguagesAsync_ReturnsTypeScriptLanguage() { var discovery = new DefaultLanguageDiscovery(); - var languages = await discovery.GetAvailableLanguagesAsync(); + var languages = await discovery.GetAvailableLanguagesAsync().DefaultTimeout(); var typescript = languages.FirstOrDefault(l => l.LanguageId.Value == "typescript/nodejs"); Assert.NotNull(typescript); @@ -52,7 +53,7 @@ public async Task GetAvailableLanguagesAsync_ReturnsPythonLanguage() { var discovery = new DefaultLanguageDiscovery(); - var languages = await discovery.GetAvailableLanguagesAsync(); + var languages = await discovery.GetAvailableLanguagesAsync().DefaultTimeout(); var python = languages.FirstOrDefault(l => l.LanguageId.Value == KnownLanguageId.Python); Assert.NotNull(python); diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index 97d20ccbf14..ece3256a945 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.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 Microsoft.AspNetCore.InternalTesting; using System.Text.Json; using System.Text.Json.Serialization; using Aspire.Cli.Configuration; @@ -39,7 +40,7 @@ public async Task UseOrFindAppHostProjectFileThrowsIfExplicitProjectFileDoesNotE var projectLocator = CreateProjectLocator(executionContext); var ex = await Assert.ThrowsAsync(async () => { - await projectLocator.UseOrFindAppHostProjectFileAsync(projectFile, createSettingsFile: true); + await projectLocator.UseOrFindAppHostProjectFileAsync(projectFile, createSettingsFile: true).DefaultTimeout(); }); Assert.Equal(ErrorStrings.ProjectFileDoesntExist, ex.Message); @@ -71,7 +72,7 @@ public async Task UseOrFindAppHostProjectFileUsesAppHostSpecifiedInSettings() var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var projectLocator = CreateProjectLocator(executionContext); - var foundAppHost = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true); + var foundAppHost = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true).DefaultTimeout(); Assert.Equal(targetAppHostProjectFile.FullName, foundAppHost?.FullName); } @@ -105,7 +106,7 @@ public async Task UseOrFindAppHostProjectFileUsesAppHostSpecifiedInSettingsWalks var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var projectLocator = CreateProjectLocator(executionContext); - var foundAppHost = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true); + var foundAppHost = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true).DefaultTimeout(); Assert.Equal(targetAppHostProjectFile.FullName, foundAppHost?.FullName); } @@ -146,7 +147,7 @@ public async Task UseOrFindAppHostProjectFileFallsBackWhenSettingsFileSpecifiesN var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); // This should fallback to scanning and find the real apphost project - var foundAppHost = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true); + var foundAppHost = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true).DefaultTimeout(); Assert.Equal(realAppHostProjectFile.FullName, foundAppHost?.FullName); } @@ -177,7 +178,7 @@ public async Task UseOrFindAppHostProjectFileNormalizesForwardSlashesInSettings( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var projectLocator = CreateProjectLocator(executionContext); - var foundAppHost = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true); + var foundAppHost = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true).DefaultTimeout(); // Should successfully find the file even though the path in settings uses forward slashes Assert.Equal(targetAppHostProjectFile.FullName, foundAppHost?.FullName); @@ -196,7 +197,7 @@ public async Task UseOrFindAppHostProjectFilePromptsWhenMultipleFilesFound() var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var projectLocator = CreateProjectLocator(executionContext); - var selectedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true); + var selectedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true).DefaultTimeout(); Assert.Equal(projectFile1.FullName, selectedProjectFile!.FullName); } @@ -225,7 +226,7 @@ public async Task UseOrFindAppHostProjectFileOnlyConsidersValidAppHostProjects() var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var projectLocator = CreateProjectLocator(executionContext, projectFactory: projectFactory); - var foundAppHost = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true); + var foundAppHost = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true).DefaultTimeout(); Assert.Equal(appHostProject.FullName, foundAppHost?.FullName); } @@ -238,7 +239,7 @@ public async Task UseOrFindAppHostProjectFileThrowsIfNoProjectWasFound() var projectLocator = CreateProjectLocator(executionContext); var ex = await Assert.ThrowsAsync(async () =>{ - await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true); + await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true).DefaultTimeout(); }); Assert.Equal(ErrorStrings.NoProjectFileFound, ex.Message); @@ -257,7 +258,7 @@ public async Task UseOrFindAppHostProjectFileReturnsExplicitProjectIfExistsAndPr var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var projectLocator = CreateProjectLocator(executionContext); - var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(projectFile, createSettingsFile: true); + var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(projectFile, createSettingsFile: true).DefaultTimeout(); Assert.Equal(projectFile, returnedProjectFile); } @@ -272,7 +273,7 @@ public async Task UseOrFindAppHostProjectFileReturnsProjectFileInDirectoryIfNotE var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var projectLocator = CreateProjectLocator(executionContext); - var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true); + var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true).DefaultTimeout(); Assert.Equal(projectFile.FullName, returnedProjectFile!.FullName); } @@ -301,7 +302,7 @@ public async Task CreateSettingsFileIfNotExistsAsync_UsesForwardSlashPathSeparat var locator = CreateProjectLocator(executionContext, configurationService: configurationService, projectFactory: projectFactory); - await locator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true, CancellationToken.None); + await locator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true, CancellationToken.None).DefaultTimeout(); var settingsFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "settings.json")); Assert.True(settingsFile.Exists, "Settings file should exist."); @@ -334,7 +335,7 @@ await File.WriteAllTextAsync( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var projectLocator = CreateProjectLocatorWithSingleFileEnabled(executionContext); - var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None); + var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None).DefaultTimeout(); Assert.Single(foundFiles); Assert.Equal(appHostFile.FullName, foundFiles[0].FullName); @@ -359,7 +360,7 @@ await File.WriteAllTextAsync( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var projectLocator = CreateProjectLocatorWithSingleFileEnabled(executionContext); - var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None); + var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None).DefaultTimeout(); Assert.Single(foundFiles); Assert.Equal(appHostFile.FullName, foundFiles[0].FullName); @@ -408,7 +409,7 @@ await File.WriteAllTextAsync( return new AppHostValidationResult(IsValid: true); }); - var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None); + var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None).DefaultTimeout(); // Should find the valid single-file apphost (from OnlyAppHost directory) // and the potentially unbuildable .csproj (from WithBoth directory due to sibling apphost.cs) @@ -428,14 +429,14 @@ public async Task FindAppHostProjectFilesAsync_IgnoresSingleFileAppHostWithoutDi // Create an apphost.cs file without the required directive var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs")); - await File.WriteAllTextAsync(appHostFile.FullName, @"using Aspire.Hosting; + await File.WriteAllTextAsync(appHostFile.FullName, @"using Aspire.Hosting var builder = DistributedApplication.CreateBuilder(args); builder.Build().Run();"); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var projectLocator = CreateProjectLocatorWithSingleFileEnabled(executionContext); - var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None); + var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None).DefaultTimeout(); Assert.Empty(foundFiles); } @@ -472,7 +473,7 @@ await File.WriteAllTextAsync( return new AppHostValidationResult(IsValid: true); }); - var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None); + var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None).DefaultTimeout(); Assert.Equal(2, foundFiles.Count); // Verify deterministic ordering (sorted by FullName) @@ -501,7 +502,7 @@ await File.WriteAllTextAsync( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var projectLocator = CreateProjectLocatorWithSingleFileEnabled(executionContext); - var result = await projectLocator.UseOrFindAppHostProjectFileAsync(appHostFile, createSettingsFile: true, CancellationToken.None); + var result = await projectLocator.UseOrFindAppHostProjectFileAsync(appHostFile, createSettingsFile: true, CancellationToken.None).DefaultTimeout(); Assert.Equal(appHostFile.FullName, result!.FullName); } @@ -522,7 +523,7 @@ public async Task UseOrFindAppHostProjectFileAsync_RejectsInvalidSingleFileAppHo var ex = await Assert.ThrowsAsync(async () => { - await projectLocator.UseOrFindAppHostProjectFileAsync(appHostFile, createSettingsFile: true, CancellationToken.None); + await projectLocator.UseOrFindAppHostProjectFileAsync(appHostFile, createSettingsFile: true, CancellationToken.None).DefaultTimeout(); }); Assert.Equal(ErrorStrings.ProjectFileDoesntExist, ex.Message); @@ -551,7 +552,7 @@ await File.WriteAllTextAsync( var projectLocator = CreateProjectLocatorWithSingleFileEnabled(executionContext); // Allow the single-file apphost to be used explicitly even with sibling .csproj - await projectLocator.UseOrFindAppHostProjectFileAsync(appHostFile, createSettingsFile: true, CancellationToken.None); + await projectLocator.UseOrFindAppHostProjectFileAsync(appHostFile, createSettingsFile: true, CancellationToken.None).DefaultTimeout(); } [Fact] @@ -567,7 +568,7 @@ public async Task UseOrFindAppHostProjectFileAsync_RejectsInvalidFileExtension() var ex = await Assert.ThrowsAsync(async () => { - await projectLocator.UseOrFindAppHostProjectFileAsync(txtFile, createSettingsFile: true, CancellationToken.None); + await projectLocator.UseOrFindAppHostProjectFileAsync(txtFile, createSettingsFile: true, CancellationToken.None).DefaultTimeout(); }); Assert.Equal(ErrorStrings.ProjectFileDoesntExist, ex.Message); @@ -605,7 +606,7 @@ await File.WriteAllTextAsync( }); // This should trigger the multiple projects selection, the test service will select the first one - var result = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true, CancellationToken.None); + var result = await projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true, CancellationToken.None).DefaultTimeout(); // The test interaction service returns the first item Assert.NotNull(result); @@ -713,7 +714,7 @@ public async Task UseOrFindAppHostProjectFileAcceptsDirectoryPathWithSingleProje // Pass directory as FileInfo (this is how System.CommandLine would parse it) var directoryAsFileInfo = new FileInfo(projectDirectory.FullName); - var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(directoryAsFileInfo, createSettingsFile: true); + var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(directoryAsFileInfo, createSettingsFile: true).DefaultTimeout(); Assert.Equal(projectFile.FullName, returnedProjectFile!.FullName); } @@ -734,7 +735,7 @@ public async Task UseOrFindAppHostProjectFileThrowsWhenDirectoryHasNoProjects() var ex = await Assert.ThrowsAsync(async () => { - await projectLocator.UseOrFindAppHostProjectFileAsync(directoryAsFileInfo, createSettingsFile: true); + await projectLocator.UseOrFindAppHostProjectFileAsync(directoryAsFileInfo, createSettingsFile: true).DefaultTimeout(); }); Assert.Equal(ErrorStrings.ProjectFileDoesntExist, ex.Message); @@ -771,7 +772,7 @@ public async Task UseOrFindAppHostProjectFilePromptsWhenDirectoryHasMultipleProj // Pass directory as FileInfo var directoryAsFileInfo = new FileInfo(projectDirectory.FullName); - var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(directoryAsFileInfo, createSettingsFile: true); + var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(directoryAsFileInfo, createSettingsFile: true).DefaultTimeout(); // Should return the first project file (TestConsoleInteractionService returns the first choice) Assert.Equal(projectFile1.FullName, returnedProjectFile!.FullName); @@ -799,7 +800,7 @@ await File.WriteAllTextAsync( // Pass directory as FileInfo (this is how System.CommandLine would parse it) var directoryAsFileInfo = new FileInfo(projectDirectory.FullName); - var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(directoryAsFileInfo, createSettingsFile: true); + var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(directoryAsFileInfo, createSettingsFile: true).DefaultTimeout(); Assert.Equal(appHostFile.FullName, returnedProjectFile!.FullName); } @@ -832,7 +833,7 @@ public async Task UseOrFindAppHostProjectFileAcceptsDirectoryPathWithRecursiveSe // Pass top directory as FileInfo - should find project in subdirectory var directoryAsFileInfo = new FileInfo(topDirectory.FullName); - var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(directoryAsFileInfo, createSettingsFile: true); + var returnedProjectFile = await projectLocator.UseOrFindAppHostProjectFileAsync(directoryAsFileInfo, createSettingsFile: true).DefaultTimeout(); Assert.Equal(projectFile.FullName, returnedProjectFile!.FullName); } @@ -874,7 +875,7 @@ await File.WriteAllTextAsync(appHostCsFile.FullName, """ return new AppHostValidationResult(IsValid: false); }); - var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None); + var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None).DefaultTimeout(); // Should find only the .csproj file, NOT the AppHost.cs file Assert.Single(foundFiles); @@ -918,7 +919,7 @@ await File.WriteAllTextAsync(appHostCsFile.FullName, """ return new AppHostValidationResult(IsValid: false); }); - var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None); + var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None).DefaultTimeout(); // Should find only the .csproj file Assert.Single(foundFiles); @@ -947,7 +948,7 @@ await File.WriteAllTextAsync(appHostCsFile.FullName, """ // Use default validation - no callback var projectLocator = CreateProjectLocatorWithSingleFileEnabled(executionContext); - var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None); + var foundFiles = await projectLocator.FindAppHostProjectFilesAsync(workspace.WorkspaceRoot.FullName, CancellationToken.None).DefaultTimeout(); // Should find the single-file apphost Assert.Single(foundFiles); diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs index 8c43bb61e03..76f97f60422 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Projects; @@ -118,13 +119,13 @@ await File.WriteAllTextAsync( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); // If this throws then it means that the updater prompted // for confirmation to do an update when no update was required! var projectUpdater = provider.GetRequiredService(); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); Assert.False(updateResult.UpdatedApplied); } @@ -230,13 +231,13 @@ await File.WriteAllTextAsync( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "daily"); // If this throws then it means that the updater prompted // for confirmation to do an update when no update was required! var projectUpdater = provider.GetRequiredService(); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); Assert.True(updateResult.UpdatedApplied); // Note: Aspire.Hosting.AppHost is not updated because it's removed during SDK migration @@ -369,13 +370,13 @@ await File.WriteAllTextAsync( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "stable"); // If this throws then it means that the updater prompted // for confirmation to do an update when no update was required! var projectUpdater = provider.GetRequiredService(); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); Assert.True(updateResult.UpdatedApplied); // Note: Aspire.Hosting.AppHost is not updated because it's removed during SDK migration @@ -521,11 +522,11 @@ await File.WriteAllTextAsync( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = provider.GetRequiredService(); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); Assert.True(updateResult.UpdatedApplied); @@ -664,11 +665,11 @@ await File.WriteAllTextAsync( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = provider.GetRequiredService(); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); Assert.True(updateResult.UpdatedApplied); @@ -769,11 +770,11 @@ await File.WriteAllTextAsync( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = provider.GetRequiredService(); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); Assert.True(updateResult.UpdatedApplied); @@ -872,11 +873,11 @@ await File.WriteAllTextAsync( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = provider.GetRequiredService(); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); Assert.False(updateResult.UpdatedApplied); @@ -1015,11 +1016,11 @@ await File.WriteAllTextAsync( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = provider.GetRequiredService(); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); Assert.True(updateResult.UpdatedApplied); @@ -1138,11 +1139,11 @@ await File.WriteAllTextAsync( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = provider.GetRequiredService(); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); Assert.True(updateResult.UpdatedApplied); @@ -1252,7 +1253,7 @@ await File.WriteAllTextAsync( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = provider.GetRequiredService(); @@ -1260,7 +1261,7 @@ await File.WriteAllTextAsync( // This should throw a ProjectUpdaterException var exception = await Assert.ThrowsAsync(async () => { - await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); }); Assert.Contains("Unable to resolve MSBuild property 'InvalidVersionProperty' to a valid semantic version", exception.Message); @@ -1362,7 +1363,7 @@ await File.WriteAllTextAsync( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = provider.GetRequiredService(); @@ -1370,7 +1371,7 @@ await File.WriteAllTextAsync( // This should throw a ProjectUpdaterException var exception = await Assert.ThrowsAsync(async () => { - await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); }); Assert.Contains("Unable to resolve MSBuild property 'NonExistentProperty' to a valid semantic version", exception.Message); @@ -1461,11 +1462,11 @@ await File.WriteAllTextAsync( var fallbackParser = provider.GetRequiredService(); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); // Should not throw ProjectUpdaterException; should produce update steps including AppHost SDK Assert.True(updateResult.UpdatedApplied); @@ -1563,11 +1564,11 @@ await File.WriteAllTextAsync( var fallbackParser = provider.GetRequiredService(); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); // Should discover package reference (version may be absent) and not crash Assert.True(updateResult.UpdatedApplied); @@ -1642,14 +1643,14 @@ await File.WriteAllTextAsync( var fallbackParser = provider.GetRequiredService(); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser); // Should throw ProjectUpdaterException due to invalid XML await Assert.ThrowsAsync(() => - projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout)); + projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout()); } [Fact] @@ -1724,11 +1725,11 @@ await File.WriteAllTextAsync( var fallbackParser = provider.GetRequiredService(); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); // Normal path unaffected - no updates needed since version is already current Assert.False(updateResult.UpdatedApplied); @@ -1803,11 +1804,11 @@ await File.WriteAllTextAsync( var fallbackParser = provider.GetRequiredService(); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostFile, selectedChannel).DefaultTimeout(); Assert.True(updateResult.UpdatedApplied); @@ -1886,11 +1887,11 @@ await File.WriteAllTextAsync( var fallbackParser = provider.GetRequiredService(); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostFile, selectedChannel).DefaultTimeout(); Assert.True(updateResult.UpdatedApplied); @@ -1974,13 +1975,13 @@ await File.WriteAllTextAsync( var fallbackParser = provider.GetRequiredService(); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser); // This should not throw and should handle the * version gracefully - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); Assert.True(updateResult.UpdatedApplied); } @@ -2049,11 +2050,11 @@ await File.WriteAllTextAsync( var provider = services.BuildServiceProvider(); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = provider.GetRequiredService(); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); Assert.True(updateResult.UpdatedApplied); @@ -2128,11 +2129,11 @@ await File.WriteAllTextAsync( var provider = services.BuildServiceProvider(); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = provider.GetRequiredService(); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); Assert.True(updateResult.UpdatedApplied); @@ -2219,11 +2220,11 @@ await File.WriteAllTextAsync( var provider = services.BuildServiceProvider(); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = provider.GetRequiredService(); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); // Both packages should be updated - range expression is treated like wildcard Assert.True(updateResult.UpdatedApplied); @@ -2352,11 +2353,11 @@ await File.WriteAllTextAsync( var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); var packagingService = provider.GetRequiredService(); - var channels = await packagingService.GetChannelsAsync(); + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); var selectedChannel = channels.Single(c => c.Name == "default"); var projectUpdater = provider.GetRequiredService(); - var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).WaitAsync(CliTestConstants.DefaultTimeout); + var updateResult = await projectUpdater.UpdateProjectAsync(appHostProjectFile, selectedChannel).DefaultTimeout(); Assert.True(updateResult.UpdatedApplied); @@ -2394,7 +2395,7 @@ public async Task UpdateSdkVersionInCsprojAppHostAsync_MigratesFromOldFormatToNe var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" }; // Act - await ProjectUpdater.UpdateSdkVersionInCsprojAppHostAsync(new FileInfo(projectFile), package); + await ProjectUpdater.UpdateSdkVersionInCsprojAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout(); // Assert var updatedContent = await File.ReadAllTextAsync(projectFile); @@ -2421,7 +2422,7 @@ public async Task UpdateSdkVersionInCsprojAppHostAsync_UpdatesExistingNewFormat( var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" }; // Act - await ProjectUpdater.UpdateSdkVersionInCsprojAppHostAsync(new FileInfo(projectFile), package); + await ProjectUpdater.UpdateSdkVersionInCsprojAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout(); // Assert var updatedContent = await File.ReadAllTextAsync(projectFile); @@ -2453,7 +2454,7 @@ public async Task UpdateSdkVersionInCsprojAppHostAsync_RemovesAspireHostingAppHo var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" }; // Act - await ProjectUpdater.UpdateSdkVersionInCsprojAppHostAsync(new FileInfo(projectFile), package); + await ProjectUpdater.UpdateSdkVersionInCsprojAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout(); // Assert var updatedContent = await File.ReadAllTextAsync(projectFile); @@ -2484,7 +2485,7 @@ public async Task UpdateSdkVersionInCsprojAppHostAsync_RemovesEmptyItemGroupAfte var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" }; // Act - await ProjectUpdater.UpdateSdkVersionInCsprojAppHostAsync(new FileInfo(projectFile), package); + await ProjectUpdater.UpdateSdkVersionInCsprojAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout(); // Assert var updatedContent = await File.ReadAllTextAsync(projectFile); @@ -2511,7 +2512,7 @@ public async Task UpdateSdkVersionInCsprojAppHostAsync_PreservesOtherSdksInAttri var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" }; // Act - await ProjectUpdater.UpdateSdkVersionInCsprojAppHostAsync(new FileInfo(projectFile), package); + await ProjectUpdater.UpdateSdkVersionInCsprojAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout(); // Assert var updatedContent = await File.ReadAllTextAsync(projectFile); @@ -2539,7 +2540,7 @@ public async Task UpdateSdkVersionInCsprojAppHostAsync_DoesNotMatchSimilarSdkNam var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" }; // Act - await ProjectUpdater.UpdateSdkVersionInCsprojAppHostAsync(new FileInfo(projectFile), package); + await ProjectUpdater.UpdateSdkVersionInCsprojAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout(); // Assert var updatedContent = await File.ReadAllTextAsync(projectFile); diff --git a/tests/Aspire.Cli.Tests/Telemetry/AspireCliTelemetryTests.cs b/tests/Aspire.Cli.Tests/Telemetry/AspireCliTelemetryTests.cs index 4eb4ff980a2..d5c6f317517 100644 --- a/tests/Aspire.Cli.Tests/Telemetry/AspireCliTelemetryTests.cs +++ b/tests/Aspire.Cli.Tests/Telemetry/AspireCliTelemetryTests.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 Microsoft.AspNetCore.InternalTesting; using System.Diagnostics; using Aspire.Cli.Telemetry; using Microsoft.Extensions.Logging; @@ -239,7 +240,7 @@ public async Task InitializeAsync_IsIdempotent() var ciDetector = new TelemetryFixture.TestCIEnvironmentDetector(); var telemetry = new AspireCliTelemetry(NullLogger.Instance, provider, ciDetector); - await telemetry.InitializeAsync(); + await telemetry.InitializeAsync().DefaultTimeout(); var tagsAfterFirstInit = telemetry.GetDefaultTags().Count; await telemetry.InitializeAsync(); // Should not throw diff --git a/tests/Aspire.Cli.Tests/Telemetry/LinuxInformationProviderTests.cs b/tests/Aspire.Cli.Tests/Telemetry/LinuxInformationProviderTests.cs index db3b5b5d877..4e858ce456a 100644 --- a/tests/Aspire.Cli.Tests/Telemetry/LinuxInformationProviderTests.cs +++ b/tests/Aspire.Cli.Tests/Telemetry/LinuxInformationProviderTests.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 Microsoft.AspNetCore.InternalTesting; using System.Runtime.InteropServices; using System.Runtime.Versioning; using Aspire.Cli.Telemetry; @@ -21,14 +22,14 @@ public async Task GetOrCreateDeviceId_WorksCorrectly() var provider = new LinuxMachineInformationProvider(NullLogger.Instance); // Act - var deviceId = await provider.GetOrCreateDeviceId(); + var deviceId = await provider.GetOrCreateDeviceId().DefaultTimeout(); // Assert Assert.NotNull(deviceId); Assert.NotEmpty(deviceId); // Verify it's persisted by calling again - var deviceId2 = await provider.GetOrCreateDeviceId(); + var deviceId2 = await provider.GetOrCreateDeviceId().DefaultTimeout(); Assert.Equal(deviceId, deviceId2); } } diff --git a/tests/Aspire.Cli.Tests/Telemetry/MacOSXInformationProviderTests.cs b/tests/Aspire.Cli.Tests/Telemetry/MacOSXInformationProviderTests.cs index 5b01e16e8aa..4cf8f9d28cd 100644 --- a/tests/Aspire.Cli.Tests/Telemetry/MacOSXInformationProviderTests.cs +++ b/tests/Aspire.Cli.Tests/Telemetry/MacOSXInformationProviderTests.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 Microsoft.AspNetCore.InternalTesting; using System.Runtime.InteropServices; using System.Runtime.Versioning; using Aspire.Cli.Telemetry; @@ -21,14 +22,14 @@ public async Task GetOrCreateDeviceId_WorksCorrectly() var provider = new MacOSXMachineInformationProvider(NullLogger.Instance); // Act - var deviceId = await provider.GetOrCreateDeviceId(); + var deviceId = await provider.GetOrCreateDeviceId().DefaultTimeout(); // Assert Assert.NotNull(deviceId); Assert.NotEmpty(deviceId); // Verify it's persisted by calling again - var deviceId2 = await provider.GetOrCreateDeviceId(); + var deviceId2 = await provider.GetOrCreateDeviceId().DefaultTimeout(); Assert.Equal(deviceId, deviceId2); } } diff --git a/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs b/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs index f8c42f79d4a..91cbdd14af9 100644 --- a/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs +++ b/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs @@ -2,6 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Telemetry; +#if DEBUG +using Microsoft.AspNetCore.InternalTesting; +#endif using Microsoft.Extensions.DependencyInjection; namespace Aspire.Cli.Tests.Telemetry; @@ -77,7 +80,7 @@ public async Task DiagnosticProvider_IncludesReportedActivitySource() Assert.True(telemetryManager.HasDiagnosticProvider); var telemetry = host.Services.GetRequiredService(); - await telemetry.InitializeAsync(); + await telemetry.InitializeAsync().DefaultTimeout(); // The diagnostic provider should listen to both activity sources. // Verify reported activities are captured by starting one and checking it's not null. diff --git a/tests/Aspire.Cli.Tests/Telemetry/WindowsInformationProviderTests.cs b/tests/Aspire.Cli.Tests/Telemetry/WindowsInformationProviderTests.cs index 2c0de8dce0c..fc3e2734d67 100644 --- a/tests/Aspire.Cli.Tests/Telemetry/WindowsInformationProviderTests.cs +++ b/tests/Aspire.Cli.Tests/Telemetry/WindowsInformationProviderTests.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 Microsoft.AspNetCore.InternalTesting; using System.Runtime.InteropServices; using System.Runtime.Versioning; using Aspire.Cli.Telemetry; @@ -21,14 +22,14 @@ public async Task GetOrCreateDeviceId_WorksCorrectly() var provider = new WindowsMachineInformationProvider(NullLogger.Instance); // Act - var deviceId = await provider.GetOrCreateDeviceId(); + var deviceId = await provider.GetOrCreateDeviceId().DefaultTimeout(); // Assert Assert.NotNull(deviceId); Assert.NotEmpty(deviceId); // Verify it's persisted by calling again - var deviceId2 = await provider.GetOrCreateDeviceId(); + var deviceId2 = await provider.GetOrCreateDeviceId().DefaultTimeout(); Assert.Equal(deviceId, deviceId2); } } diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 11a1f9cf3b6..7193d5a2436 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.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 Microsoft.AspNetCore.InternalTesting; using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Certificates; @@ -73,7 +74,7 @@ public async Task NuGetConfigMerger_InPlaceCreation_WithoutExistingConfig_Create var channel = CreateExplicitChannel(mappings); // Act - Simulate in-place creation: output directory same as working directory - await NuGetConfigMerger.CreateOrUpdateAsync(workingDir, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(workingDir, channel).DefaultTimeout(); // Assert var nugetConfigPath = Path.Combine(workingDir.FullName, "nuget.config"); @@ -105,7 +106,7 @@ await WriteNuGetConfigAsync(workingDir, var channel = CreateExplicitChannel(mappings); // Act - Simulate in-place creation: output directory same as working directory - await NuGetConfigMerger.CreateOrUpdateAsync(workingDir, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(workingDir, channel).DefaultTimeout(); // Assert var nugetConfigPath = Path.Combine(workingDir.FullName, "nuget.config"); @@ -133,7 +134,7 @@ public async Task NuGetConfigMerger_SubdirectoryCreation_WithParentConfig_Ignore """; - await WriteNuGetConfigAsync(workingDir, parentConfigContent); + await WriteNuGetConfigAsync(workingDir, parentConfigContent).DefaultTimeout(); var mappings = new[] { @@ -142,7 +143,7 @@ public async Task NuGetConfigMerger_SubdirectoryCreation_WithParentConfig_Ignore var channel = CreateExplicitChannel(mappings); // Act - Simulate subdirectory creation: output directory different from working directory - await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel).DefaultTimeout(); // Assert // Parent nuget.config should remain unchanged @@ -185,7 +186,7 @@ await WriteNuGetConfigAsync(outputDir, var channel = CreateExplicitChannel(mappings); // Act - Simulate subdirectory creation: merge into existing config in output directory - await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel).DefaultTimeout(); // Assert var outputConfigPath = Path.Combine(outputDir.FullName, "nuget.config"); @@ -211,7 +212,7 @@ public async Task NuGetConfigMerger_SubdirectoryCreation_WithoutAnyConfig_Create var channel = CreateExplicitChannel(mappings); // Act - Simulate subdirectory creation: create new config in output directory - await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel).DefaultTimeout(); // Assert // No nuget.config should exist in working directory @@ -237,7 +238,7 @@ public async Task NuGetConfigMerger_ImplicitChannel_DoesNothing() var channel = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache()); // Act - await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel).DefaultTimeout(); // Assert // No nuget.config should be created anywhere @@ -258,7 +259,7 @@ public async Task NuGetConfigMerger_ExplicitChannelWithoutMappings_DoesNothing() var channel = CreateExplicitChannel([]); // No mappings // Act - await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel); + await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel).DefaultTimeout(); // Assert // No nuget.config should be created anywhere @@ -513,4 +514,4 @@ public Task PromptForOutputPath(string defaultPath, CancellationToken ca public Task PromptForTemplateAsync(ITemplate[] templates, CancellationToken cancellationToken) => throw new NotImplementedException(); } -} \ No newline at end of file +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs index 6c331aca138..59c3bb860c6 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.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 Microsoft.AspNetCore.InternalTesting; using Aspire.Cli.Backchannel; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -229,7 +230,7 @@ public async Task HasCapabilityAsync(string capability, CancellationToken } // Default behavior: check if capability is in the capabilities returned by GetCapabilitiesAsync - var capabilities = await GetCapabilitiesAsync(cancellationToken); + var capabilities = await GetCapabilitiesAsync(cancellationToken).DefaultTimeout(); return capabilities.Contains(capability); } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs b/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs index 865e51aec75..634ffc24a17 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestProjectLocator.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 Microsoft.AspNetCore.InternalTesting; using Aspire.Cli.Projects; namespace Aspire.Cli.Tests.TestServices; @@ -36,7 +37,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F } // Fallback behavior - var appHostFile = await UseOrFindAppHostProjectFileAsync(projectFile, createSettingsFile, cancellationToken); + var appHostFile = await UseOrFindAppHostProjectFileAsync(projectFile, createSettingsFile, cancellationToken).DefaultTimeout(); if (appHostFile is null) { return new AppHostProjectSearchResult(null, []); diff --git a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs index 881c82dd10e..ec634093d30 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging; using Semver; using NuGetPackage = Aspire.Shared.NuGetPackageCli; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Utils; @@ -65,9 +66,9 @@ public async Task PrereleaseWillRecommendUpgradeToPrereleaseOnSameVersionFamily( var provider = services.BuildServiceProvider(); var notifier = provider.GetRequiredService(); - await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).WaitAsync(CliTestConstants.DefaultTimeout); + await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout(); notifier.NotifyIfUpdateAvailable(); - var suggestedVersion = await suggestedVersionTcs.Task.WaitAsync(CliTestConstants.DefaultTimeout); + var suggestedVersion = await suggestedVersionTcs.Task.DefaultTimeout(); Assert.Equal("9.4.0-preview", suggestedVersion); } @@ -89,7 +90,7 @@ public async Task PrereleaseWillRecommendUpgradeToStableInCurrentVersionFamily() new NuGetPackage { Id = "Aspire.Cli", Version = "9.4.0", Source = "nuget.org" }, // Should be ignored because its prerelease but in a higher version family. - new NuGetPackage { Id = "Aspire.Cli", Version = "9.5.0-preview", Source = "nuget.org" }, + new NuGetPackage { Id = "Aspire.Cli", Version = "9.5.0-preview", Source = "nuget.org" }, ]); return cache; @@ -120,9 +121,9 @@ public async Task PrereleaseWillRecommendUpgradeToStableInCurrentVersionFamily() var provider = services.BuildServiceProvider(); var notifier = provider.GetRequiredService(); - await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).WaitAsync(CliTestConstants.DefaultTimeout); + await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout(); notifier.NotifyIfUpdateAvailable(); - var suggestedVersion = await suggestedVersionTcs.Task.WaitAsync(CliTestConstants.DefaultTimeout); + var suggestedVersion = await suggestedVersionTcs.Task.DefaultTimeout(); Assert.Equal("9.4.0", suggestedVersion); } @@ -144,7 +145,7 @@ public async Task StableWillOnlyRecommendGoingToNewerStable() new NuGetPackage { Id = "Aspire.Cli", Version = "9.5.0", Source = "nuget.org" }, // Should be ignored because its prerelease but in a (even) higher version family. - new NuGetPackage { Id = "Aspire.Cli", Version = "9.6.0-preview", Source = "nuget.org" }, + new NuGetPackage { Id = "Aspire.Cli", Version = "9.6.0-preview", Source = "nuget.org" }, ]); return cache; @@ -175,9 +176,9 @@ public async Task StableWillOnlyRecommendGoingToNewerStable() var provider = services.BuildServiceProvider(); var notifier = provider.GetRequiredService(); - await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).WaitAsync(CliTestConstants.DefaultTimeout); + await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout(); notifier.NotifyIfUpdateAvailable(); - var suggestedVersion = await suggestedVersionTcs.Task.WaitAsync(CliTestConstants.DefaultTimeout); + var suggestedVersion = await suggestedVersionTcs.Task.DefaultTimeout(); Assert.Equal("9.5.0", suggestedVersion); } @@ -194,8 +195,8 @@ public async Task StableWillNotRecommendUpdatingToPreview() { var cache = new TestNuGetPackageCache(); cache.SetMockCliPackages([ - new NuGetPackage { Id = "Aspire.Cli", Version = "9.4.0-preview", Source = "nuget.org" }, - new NuGetPackage { Id = "Aspire.Cli", Version = "9.5.0-preview", Source = "nuget.org" }, + new NuGetPackage { Id = "Aspire.Cli", Version = "9.4.0-preview", Source = "nuget.org" }, + new NuGetPackage { Id = "Aspire.Cli", Version = "9.5.0-preview", Source = "nuget.org" }, ]); return cache; @@ -226,7 +227,7 @@ public async Task StableWillNotRecommendUpdatingToPreview() var provider = services.BuildServiceProvider(); var notifier = provider.GetRequiredService(); - await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).WaitAsync(CliTestConstants.DefaultTimeout); + await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout(); notifier.NotifyIfUpdateAvailable(); } @@ -236,14 +237,14 @@ public async Task NotifyIfUpdateAvailableAsync_WithNewerStableVersion_DoesNotThr // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - + // Replace the NuGetPackageCache with our test implementation services.AddSingleton(); services.AddSingleton(); - + var provider = services.BuildServiceProvider(); var service = provider.GetRequiredService(); - + // Mock packages with a newer stable version var nugetCache = provider.GetRequiredService() as TestNuGetPackageCache; nugetCache?.SetMockCliPackages([ @@ -251,7 +252,7 @@ public async Task NotifyIfUpdateAvailableAsync_WithNewerStableVersion_DoesNotThr ]); // Act & Assert (should not throw) - await service.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None); + await service.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout(); service.NotifyIfUpdateAvailable(); } @@ -261,16 +262,16 @@ public async Task NotifyIfUpdateAvailableAsync_WithEmptyPackages_DoesNotThrow() // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); - + // Replace the NuGetPackageCache with our test implementation services.AddSingleton(); services.AddSingleton(); - + var provider = services.BuildServiceProvider(); var service = provider.GetRequiredService(); // Act & Assert (should not throw) - await service.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None); + await service.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout(); service.NotifyIfUpdateAvailable(); } } diff --git a/tests/Aspire.Cli.Tests/Utils/OutputCollectorTests.cs b/tests/Aspire.Cli.Tests/Utils/OutputCollectorTests.cs index adae99a7c72..40a54f55d0b 100644 --- a/tests/Aspire.Cli.Tests/Utils/OutputCollectorTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/OutputCollectorTests.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 Microsoft.AspNetCore.InternalTesting; using Aspire.Cli.Utils; namespace Aspire.Cli.Tests.Utils; @@ -36,7 +37,7 @@ public async Task OutputCollector_ThreadSafety_MultipleThreadsAddingLines() }); } - await Task.WhenAll(tasks); + await Task.WhenAll(tasks).DefaultTimeout(); // Assert - Should have all lines without any exceptions var lines = collector.GetLines().ToList(); @@ -45,7 +46,7 @@ public async Task OutputCollector_ThreadSafety_MultipleThreadsAddingLines() // Check that we have both stdout and stderr entries var stdoutLines = lines.Where(l => l.Stream == "stdout").ToList(); var stderrLines = lines.Where(l => l.Stream == "stderr").ToList(); - + Assert.Equal(threadCount * linesPerThread / 2, stdoutLines.Count); Assert.Equal(threadCount * linesPerThread / 2, stderrLines.Count); } @@ -65,7 +66,7 @@ public void OutputCollector_GetLines_ReturnsSnapshotNotLiveReference() Assert.Single(snapshot); Assert.Equal("initial line", snapshot[0].Line); Assert.Equal("stdout", snapshot[0].Stream); - + // New call should include the additional line var newSnapshot = collector.GetLines().ToList(); Assert.Equal(2, newSnapshot.Count); @@ -95,10 +96,10 @@ public async Task OutputCollector_ConcurrentReadWrite_ShouldNotCrash() }); // Act & Assert - Should complete without exceptions - await Task.WhenAll(readerTask, writerTask); - + await Task.WhenAll(readerTask, writerTask).DefaultTimeout(); + // Verify final state var finalLines = collector.GetLines().ToList(); Assert.Equal(100, finalLines.Count); } -} \ No newline at end of file +} From af0e27eb5b5af9dbd78889865f005e2d25746d4d Mon Sep 17 00:00:00 2001 From: David Pine Date: Sat, 31 Jan 2026 10:55:49 -0600 Subject: [PATCH 005/256] =?UTF-8?q?Expose=20doc=20slug=20in=20search=5Fdoc?= =?UTF-8?q?s=20tool=20result=20so=20that=20if=20the=20LLM=20decides?= =?UTF-8?q?=E2=80=A6=20(#14268)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Expose doc slug in search_docs tool result so that if the LLM decides to call get_doc it works. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Agents/CommonAgentApplicators.cs | 17 + src/Aspire.Cli/Mcp/Docs/DocsSearchService.cs | 21 +- .../McpDocsE2ETests.cs | 39 +++ .../Mcp/Docs/DocsSearchServiceTests.cs | 8 +- .../Mcp/E2E/McpDocsE2ETests.cs | 311 ++++++++++++++++++ 5 files changed, 390 insertions(+), 6 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Mcp/E2E/McpDocsE2ETests.cs diff --git a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs index 465d5524667..3b751536ec8 100644 --- a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs +++ b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs @@ -203,6 +203,23 @@ dotnet tool install --global dotnet-outdated-tool IMPORTANT! The aspire workload is obsolete. You should never attempt to install or use the Aspire workload. + ## Aspire Documentation Tools + + IMPORTANT! The Aspire MCP server provides tools to search and retrieve official Aspire documentation directly. Use these tools to find accurate, up-to-date information about Aspire features, APIs, and integrations: + + 1. **list_docs**: Lists all available documentation pages from aspire.dev. Returns titles, slugs, and summaries. Use this to discover available topics. + + 2. **search_docs**: Searches the documentation using keywords. Returns ranked results with titles, slugs, and matched content. Use this when looking for specific features, APIs, or concepts. + + 3. **get_doc**: Retrieves the full content of a documentation page by its slug. After using `list_docs` or `search_docs` to find a relevant page, pass the slug to `get_doc` to retrieve the complete documentation. + + ### Recommended workflow for documentation + + 1. Use `search_docs` with relevant keywords to find documentation about a topic + 2. Review the search results - each result includes a **Slug** that identifies the page + 3. Use `get_doc` with the slug to retrieve the full documentation content + 4. Optionally use the `section` parameter with `get_doc` to retrieve only a specific section + ## Official documentation IMPORTANT! Always prefer official documentation when available. The following sites contain the official documentation for Aspire and related components diff --git a/src/Aspire.Cli/Mcp/Docs/DocsSearchService.cs b/src/Aspire.Cli/Mcp/Docs/DocsSearchService.cs index 638a637ff12..6ea62bd2ff5 100644 --- a/src/Aspire.Cli/Mcp/Docs/DocsSearchService.cs +++ b/src/Aspire.Cli/Mcp/Docs/DocsSearchService.cs @@ -62,13 +62,15 @@ public string FormatAsMarkdown(string? title = null, bool showScores = false) if (showScores) { - sb.AppendLine(CultureInfo.InvariantCulture, $"## Result {i + 1} (Score: {result.Score:F3})"); + sb.AppendLine(CultureInfo.InvariantCulture, $"## {result.Title} (Score: {result.Score:F3})"); } else { - sb.AppendLine(CultureInfo.InvariantCulture, $"## Result {i + 1}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"## {result.Title}"); } + sb.AppendLine(CultureInfo.InvariantCulture, $"**Slug:** `{result.Slug}`"); + if (!string.IsNullOrEmpty(result.Section)) { sb.AppendLine(CultureInfo.InvariantCulture, $"**Section:** {result.Section}"); @@ -78,9 +80,10 @@ public string FormatAsMarkdown(string? title = null, bool showScores = false) sb.AppendLine(result.Content); sb.AppendLine(); sb.AppendLine("---"); - sb.AppendLine(); } + sb.AppendLine("Use `get_doc` with the slug to retrieve the full content of this page."); + sb.AppendLine(); return sb.ToString(); } } @@ -90,6 +93,16 @@ public string FormatAsMarkdown(string? title = null, bool showScores = false) /// internal sealed class SearchResult { + /// + /// Gets the title of the matched document. + /// + public required string Title { get; init; } + + /// + /// Gets the slug of the matched document, used with get_doc to retrieve full content. + /// + public required string Slug { get; init; } + /// /// Gets the matched content. /// @@ -126,6 +139,8 @@ internal sealed class DocsSearchService( // Convert DocsSearchResult to SearchResult var results = searchResults.Select(r => new SearchResult { + Title = r.Title, + Slug = r.Slug, Content = r.Summary ?? r.Title, Section = r.MatchedSection, Score = r.Score diff --git a/tests/Aspire.Cli.EndToEnd.Tests/McpDocsE2ETests.cs b/tests/Aspire.Cli.EndToEnd.Tests/McpDocsE2ETests.cs index 222efc1b4f4..d08b4c1dca0 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/McpDocsE2ETests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/McpDocsE2ETests.cs @@ -107,6 +107,8 @@ public async Task SearchDocs_FindsRelevantContent() var text = GetResultText(result); // Should find Redis-related documentation Assert.Contains("Search Results", text); + // Should include slug for use with get_doc + Assert.Contains("Slug:", text); } [Fact] @@ -128,6 +130,43 @@ public async Task SearchDocs_RespectsTopKParameter() Assert.Contains("Search Results", text); } + [Fact] + public async Task SearchDocs_SlugCanBeUsedWithGetDoc() + { + Assert.NotNull(_mcpClient); + + var cancellationToken = TestContext.Current.CancellationToken; + + // Search for documentation + var searchResult = await _mcpClient.CallToolAsync( + "search_docs", + new Dictionary { ["query"] = "redis" }, + cancellationToken: cancellationToken); + + Assert.NotNull(searchResult); + Assert.True(searchResult.IsError is null or false, $"Tool returned error: {GetResultText(searchResult)}"); + + var searchText = GetResultText(searchResult); + + // Extract a slug from the search results + var slugMatch = SlugRegex().Match(searchText); + Assert.True(slugMatch.Success, "Could not find a slug in search_docs output. Search results should include slugs for use with get_doc."); + + var slug = slugMatch.Groups[1].Value; + + // Use the slug with get_doc to retrieve full content + var docResult = await _mcpClient.CallToolAsync( + "get_doc", + new Dictionary { ["slug"] = slug }, + cancellationToken: cancellationToken); + + Assert.NotNull(docResult); + Assert.True(docResult.IsError is null or false, $"get_doc returned error for slug '{slug}': {GetResultText(docResult)}"); + + var docText = GetResultText(docResult); + Assert.NotEmpty(docText); + } + [Fact] public async Task SearchDocs_WithEmptyQuery_ReturnsError() { diff --git a/tests/Aspire.Cli.Tests/Mcp/Docs/DocsSearchServiceTests.cs b/tests/Aspire.Cli.Tests/Mcp/Docs/DocsSearchServiceTests.cs index 4885ddee566..7ff25752cd5 100644 --- a/tests/Aspire.Cli.Tests/Mcp/Docs/DocsSearchServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/Docs/DocsSearchServiceTests.cs @@ -69,7 +69,8 @@ Redis content with details. var markdown = response.FormatAsMarkdown("Test Results"); Assert.Contains("# Test Results", markdown); - Assert.Contains("## Result 1", markdown); + Assert.Contains("## Redis Integration", markdown); + Assert.Contains("**Slug:**", markdown); } [Fact] @@ -264,8 +265,9 @@ Redis content. var markdown = response.FormatAsMarkdown(""); - // Verify the content contains the search result header and result info - Assert.Contains("## Result", markdown); + // Verify the content contains the document title and slug + Assert.Contains("## Redis Integration", markdown); + Assert.Contains("**Slug:**", markdown); Assert.Contains("Redis", markdown); } diff --git a/tests/Aspire.Cli.Tests/Mcp/E2E/McpDocsE2ETests.cs b/tests/Aspire.Cli.Tests/Mcp/E2E/McpDocsE2ETests.cs new file mode 100644 index 00000000000..babe10dd2fe --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/E2E/McpDocsE2ETests.cs @@ -0,0 +1,311 @@ +// 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.RegularExpressions; +using Aspire.TestUtilities; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Tests.Mcp.E2E; + +/// +/// End-to-end tests for MCP docs-based tooling. +/// These tests exercise the full MCP protocol flow by launching the CLI as a subprocess. +/// +/// +/// These tests require network access to fetch documentation from aspire.dev. +/// They are marked as outerloop tests to avoid slowing down regular CI. +/// +[OuterloopTest("Requires network access to fetch aspire.dev documentation")] +public partial class McpDocsE2ETests : IAsyncLifetime +{ + private McpClient? _mcpClient; + + public async ValueTask InitializeAsync() + { + // Find the Aspire.Cli project relative to the test assembly + var cliProjectPath = FindCliProjectPath(); + + var options = new StdioClientTransportOptions + { + Name = "aspire-mcp-e2e-test", + Command = "dotnet", + Arguments = ["run", "--project", cliProjectPath, "--no-build", "--", "agent", "mcp"] + }; + + var transport = new StdioClientTransport(options); + _mcpClient = await McpClient.CreateAsync(transport); + } + + public async ValueTask DisposeAsync() + { + if (_mcpClient is not null) + { + await _mcpClient.DisposeAsync(); + } + } + + [Fact] + public async Task ListTools_IncludesDocsTools() + { + Assert.NotNull(_mcpClient); + + var tools = await _mcpClient.ListToolsAsync(); + + Assert.Contains(tools, t => t.Name == "list_docs"); + Assert.Contains(tools, t => t.Name == "search_docs"); + Assert.Contains(tools, t => t.Name == "get_doc"); + } + + [Fact] + public async Task ListDocs_ReturnsDocumentation() + { + Assert.NotNull(_mcpClient); + + var result = await _mcpClient.CallToolAsync("list_docs"); + + Assert.NotNull(result); + Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}"); + + var text = GetResultText(result); + Assert.Contains("Aspire Documentation Pages", text); + Assert.Contains("Slug:", text); + } + + [Fact] + public async Task SearchDocs_FindsRelevantContent() + { + Assert.NotNull(_mcpClient); + + var result = await _mcpClient.CallToolAsync( + "search_docs", + new Dictionary { ["query"] = "redis" }); + + Assert.NotNull(result); + Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}"); + + var text = GetResultText(result); + // Should find Redis-related documentation + Assert.Contains("Search Results", text); + // Should include slug for use with get_doc + Assert.Contains("Slug:", text); + } + + [Fact] + public async Task SearchDocs_RespectsTopKParameter() + { + Assert.NotNull(_mcpClient); + + var result = await _mcpClient.CallToolAsync( + "search_docs", + new Dictionary { ["query"] = "aspire", ["topK"] = 3 }); + + Assert.NotNull(result); + Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}"); + + var text = GetResultText(result); + // Should contain search results but limited count + Assert.Contains("Search Results", text); + } + + [Fact] + public async Task SearchDocs_SlugCanBeUsedWithGetDoc() + { + Assert.NotNull(_mcpClient); + + // Search for documentation + var searchResult = await _mcpClient.CallToolAsync( + "search_docs", + new Dictionary { ["query"] = "redis" }); + + Assert.NotNull(searchResult); + Assert.True(searchResult.IsError is null or false, $"Tool returned error: {GetResultText(searchResult)}"); + + var searchText = GetResultText(searchResult); + + // Extract a slug from the search results + var slugMatch = SlugRegex().Match(searchText); + Assert.True(slugMatch.Success, "Could not find a slug in search_docs output. Search results should include slugs for use with get_doc."); + + var slug = slugMatch.Groups[1].Value; + + // Use the slug with get_doc to retrieve full content + var docResult = await _mcpClient.CallToolAsync( + "get_doc", + new Dictionary { ["slug"] = slug }); + + Assert.NotNull(docResult); + Assert.True(docResult.IsError is null or false, $"get_doc returned error for slug '{slug}': {GetResultText(docResult)}"); + + var docText = GetResultText(docResult); + Assert.NotEmpty(docText); + } + + [Fact] + public async Task SearchDocs_WithEmptyQuery_ReturnsError() + { + Assert.NotNull(_mcpClient); + + var result = await _mcpClient.CallToolAsync( + "search_docs", + new Dictionary { ["query"] = "" }); + + Assert.NotNull(result); + Assert.True(result.IsError is true, "Expected an error response for empty query"); + } + + [Fact] + public async Task GetDoc_RetrievesDocumentContent() + { + Assert.NotNull(_mcpClient); + + // First list docs to get a valid slug + var listResult = await _mcpClient.CallToolAsync("list_docs"); + Assert.True(listResult.IsError is null or false); + + var listText = GetResultText(listResult); + + // Extract a slug from the list + // Format: **Slug:** `some-slug` + var slugMatch = SlugRegex().Match(listText); + + Assert.True(slugMatch.Success, "Could not find a slug in list_docs output"); + + var slug = slugMatch.Groups[1].Value; + + // Now get that specific document + var result = await _mcpClient.CallToolAsync( + "get_doc", + new Dictionary { ["slug"] = slug }); + + Assert.NotNull(result); + Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}"); + + var text = GetResultText(result); + Assert.NotEmpty(text); + } + + [Fact] + public async Task GetDoc_WithInvalidSlug_ReturnsError() + { + Assert.NotNull(_mcpClient); + + var result = await _mcpClient.CallToolAsync( + "get_doc", + new Dictionary { ["slug"] = "nonexistent-doc-that-does-not-exist" }); + + Assert.NotNull(result); + Assert.True(result.IsError is true, "Expected an error response for invalid slug"); + Assert.Contains("No documentation found", GetResultText(result), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetDoc_WithSection_ReturnsSpecificSection() + { + Assert.NotNull(_mcpClient); + + // First list docs to get a valid slug + var listResult = await _mcpClient.CallToolAsync("list_docs"); + Assert.True(listResult.IsError is null or false); + + var listText = GetResultText(listResult); + + // Extract a slug from the list + var slugMatch = SlugRegex().Match(listText); + + Assert.True(slugMatch.Success, "Could not find a slug in list_docs output"); + + var slug = slugMatch.Groups[1].Value; + + // Get document with a section filter (use a common section name) + var result = await _mcpClient.CallToolAsync( + "get_doc", + new Dictionary + { + ["slug"] = slug, + ["section"] = "Configuration" // Common section name in docs + }); + + Assert.NotNull(result); + // Even if section doesn't exist, should still return content + Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}"); + } + + [Fact] + public async Task ListTools_ToolSchemas_AreValid() + { + Assert.NotNull(_mcpClient); + + var tools = await _mcpClient.ListToolsAsync(); + + var docTools = tools.Where(t => t.Name is "list_docs" or "search_docs" or "get_doc").ToList(); + + foreach (var tool in docTools) + { + // Verify schema is valid JSON + var schemaString = tool.ProtocolTool.InputSchema.ToString(); + Assert.NotEmpty(schemaString); + + // Should be parseable JSON + using var parsed = JsonDocument.Parse(schemaString); + Assert.NotNull(parsed); + } + } + + [Fact] + public async Task SearchDocs_ToolDescription_IsInformative() + { + Assert.NotNull(_mcpClient); + + var tools = await _mcpClient.ListToolsAsync(); + + var searchTool = tools.FirstOrDefault(t => t.Name == "search_docs"); + Assert.NotNull(searchTool); + Assert.NotEmpty(searchTool.Description); + Assert.Contains("search", searchTool.Description, StringComparison.OrdinalIgnoreCase); + } + + private static string GetResultText(CallToolResult result) + { + if (result.Content is not { Count: > 0 }) + { + return string.Empty; + } + + return result.Content + .OfType() + .Select(c => c.Text) + .FirstOrDefault() ?? string.Empty; + } + + private static string FindCliProjectPath() + { + // Navigate from test output to find the CLI project + var currentDir = AppContext.BaseDirectory; + + // Walk up to find the repo root (contains Aspire.slnx) + var dir = new DirectoryInfo(currentDir); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Aspire.slnx"))) + { + dir = dir.Parent; + } + + if (dir is null) + { + throw new InvalidOperationException( + "Could not find repository root. Ensure Aspire.slnx exists in the repo root."); + } + + var cliProjectPath = Path.Combine(dir.FullName, "src", "Aspire.Cli", "Aspire.Cli.csproj"); + if (!File.Exists(cliProjectPath)) + { + throw new InvalidOperationException($"Could not find CLI project at: {cliProjectPath}"); + } + + return cliProjectPath; + } + + [GeneratedRegex(@"\*\*Slug:\*\* `([^`]+)`")] + private static partial Regex SlugRegex(); +} From 8e954f1fbac02c98c0f86499c51f30d22e45e0bc Mon Sep 17 00:00:00 2001 From: Alex Crome Date: Sun, 1 Feb 2026 17:35:15 +0000 Subject: [PATCH 006/256] Fix typo in comment for DcpExecutor.cs (#14276) --- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 27c57963bd2..88a90c892c2 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -152,7 +152,7 @@ public async Task RunApplicationAsync(CancellationToken cancellationToken = defa // This is why we create objects in very specific order here. // // In future we should be able to make the model more flexible and streamline the DCP object creation logic by: - // 1. Asynchronously publish AllocatdEndpoints as the Services associated with them transition to Ready state. + // 1. Asynchronously publish AllocatedEndpoints as the Services associated with them transition to Ready state. // 2. Asynchronously create Executables and Containers as soon as all their dependencies are ready. try From 3dcf1cc955750aba1074b31a158574668256e2f2 Mon Sep 17 00:00:00 2001 From: Alex Crome Date: Sun, 1 Feb 2026 17:35:59 +0000 Subject: [PATCH 007/256] Refactor to use existing sdk detection logic. (#14264) --- .../DotnetTool/DotnetTool.AppHost/AppHost.cs | 5 ++- .../IncompatibleSdk/global.json | 8 +++++ src/Aspire.Hosting/Aspire.Hosting.csproj | 1 + .../DotnetToolResourceExtensions.cs | 35 ++++++++++++++----- .../AddDotnetToolTests.cs | 26 ++++++++++++++ 5 files changed, 66 insertions(+), 9 deletions(-) create mode 100644 playground/DotnetTool/DotnetTool.AppHost/IncompatibleSdk/global.json diff --git a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs index 0bb329bcca5..c16701620c9 100644 --- a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs +++ b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs @@ -68,7 +68,7 @@ builder.AddDotnetTool("offlinePrerelease", "dotnet-ef") .WithToolPrerelease() - .WithParentRelationship(offline) + .WithParentRelationship(offline) .WithToolSource(fakeSourcesPath) .WithToolIgnoreExistingFeeds() .WithToolIgnoreFailedSources(); @@ -80,6 +80,9 @@ .WithArgs("--version") .WithArgs(secret); +builder.AddDotnetTool("incompatibleSdk", "dotnet-ef") + .WithWorkingDirectory(Path.Combine(Projects.DotnetTool_AppHost.ProjectPath, "IncompatibleSdk")); + // Some issues only show up when installing for first time, rather than using existing downloaded versions // Use a specific NUGET_PACKAGES path for these playground tools, so we can easily reset them builder.Eventing.Subscribe(async (evt, _) => diff --git a/playground/DotnetTool/DotnetTool.AppHost/IncompatibleSdk/global.json b/playground/DotnetTool/DotnetTool.AppHost/IncompatibleSdk/global.json new file mode 100644 index 00000000000..5c86277ccf1 --- /dev/null +++ b/playground/DotnetTool/DotnetTool.AppHost/IncompatibleSdk/global.json @@ -0,0 +1,8 @@ +{ + // DO NOT UPDATE THE SDK VERSION IN THIS FILE + // It is very explicitly set to simulate an SDK version without `dotnet tool exec` + "sdk": { + "version": "9.0.0", + "rollForward": "latestFeature" + } +} diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 360d58799f3..c23da5425a8 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -114,6 +114,7 @@ + diff --git a/src/Aspire.Hosting/DotnetToolResourceExtensions.cs b/src/Aspire.Hosting/DotnetToolResourceExtensions.cs index 74616db6cde..731e54fcbf2 100644 --- a/src/Aspire.Hosting/DotnetToolResourceExtensions.cs +++ b/src/Aspire.Hosting/DotnetToolResourceExtensions.cs @@ -5,6 +5,7 @@ using Aspire.Dashboard.Model; using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.DependencyInjection; +using Aspire.Hosting.Utils; namespace Aspire.Hosting; @@ -46,7 +47,7 @@ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationB .WithIconName("Toolbox") .WithCommand("dotnet") .WithArgs(BuildToolExecArguments) - .OnBeforeResourceStarted(BuildToolProperties); + .OnBeforeResourceStarted(BeforeResourceStarted); void BuildToolExecArguments(CommandLineArgsCallbackContext x) { @@ -89,7 +90,7 @@ void BuildToolExecArguments(CommandLineArgsCallbackContext x) } //TODO: Move to WithConfigurationFinalizer once merged - https://github.com/dotnet/aspire/pull/13200 - async Task BuildToolProperties(T resource, BeforeResourceStartedEvent evt, CancellationToken ct) + async Task BeforeResourceStarted(T resource, BeforeResourceStartedEvent evt, CancellationToken ct) { var rns = evt.Services.GetRequiredService(); var toolConfig = resource.ToolConfiguration; @@ -100,14 +101,17 @@ async Task BuildToolProperties(T resource, BeforeResourceStartedEvent evt, Cance await rns.PublishUpdateAsync(resource, x => x with { - Properties = [ - ..x.Properties, - new (KnownProperties.Tool.Package, toolConfig.PackageId), - new (KnownProperties.Tool.Version, toolConfig.Version), - new (KnownProperties.Resource.Source, resource.ToolConfiguration?.PackageId) - ] + Properties = x.Properties.SetResourcePropertyRange([ + new (KnownProperties.Tool.Package, toolConfig.PackageId), + new (KnownProperties.Tool.Version, toolConfig.Version), + new (KnownProperties.Resource.Source, resource.ToolConfiguration?.PackageId) + ]) }).ConfigureAwait(false); + + var version = await DotnetSdkUtils.TryGetVersionAsync(resource.WorkingDirectory).ConfigureAwait(false); + ValidateDotnetSdkVersion(version, resource.WorkingDirectory); } + } /// @@ -190,4 +194,19 @@ public static IResourceBuilder WithToolIgnoreFailedSources(this IResourceB builder.Resource.ToolConfiguration?.IgnoreFailedSources = true; return builder; } + + internal static void ValidateDotnetSdkVersion(Version? version, string workingDirectory) + { + if (version is null) + { + // This most likely means something is majorly wrong with the dotnet sdk + // Which will show up as an error once dcp runs `dotnet tool exec` + return; + } + + if (version.Major < 10) + { + throw new DistributedApplicationException($"DotnetToolResource requires dotnet SDK 10 or higher to run. Detected version: {version} for working directory {workingDirectory}"); + } + } } diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs index 37f4d51d389..4ffa3d4172b 100644 --- a/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs +++ b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs @@ -377,4 +377,30 @@ public async Task AddDotnetToolGeneratesCorrectManifest() Assert.Equal(expectedManifest, manifest.ToString()); } + + [Theory] + [InlineData("11.1.0", true)] + [InlineData("10.0.0", true)] + [InlineData("9.0.999", false)] + public void ValidateDotnetSdkVersion_ValidatesVersionCorrectly(string versionString, bool isAllowed) + { + var version = Version.Parse(versionString); + + if (isAllowed) + { + DotnetToolResourceExtensions.ValidateDotnetSdkVersion(version, ""); + } + else + { + Assert.Throws(() => + DotnetToolResourceExtensions.ValidateDotnetSdkVersion(version, "")); + } + } + + [Fact] + public void ValidateDotnetSdkVersion_WithNullVersion_DoesNotThrow() + { + // Should not throw - null is treated as "unable to determine version" + DotnetToolResourceExtensions.ValidateDotnetSdkVersion(null, ""); + } } From 96ef462dd2413f2ae391a118586fe9ebf2a142de Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 10:01:41 -0800 Subject: [PATCH 008/256] Improve docs search ranking with slug matching and changelog penalties (#14275) * Initial plan * Add slug matching bonuses and what's-new penalty to docs search algorithm Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Fix integer division in slug matching and improve code comments Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Fix single-word query getting excessive slug match bonus Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Fix hyphenated queries, smart changelog penalty, and pre-compute slug segments Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Address code review feedback: smart changelog detection, contiguous segment matching, and optimizations Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Pre-compute queryAsSlug once before scoring loop to avoid repeated allocation Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- docs/specs/mcp-docs-search.md | 12 + src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs | 168 ++++++++++- .../Mcp/Docs/DocsIndexServiceTests.cs | 265 ++++++++++++++++++ 3 files changed, 435 insertions(+), 10 deletions(-) diff --git a/docs/specs/mcp-docs-search.md b/docs/specs/mcp-docs-search.md index 16090b8e731..c3b2a877748 100644 --- a/docs/specs/mcp-docs-search.md +++ b/docs/specs/mcp-docs-search.md @@ -254,6 +254,16 @@ Search uses weighted field scoring for relevance ranking: - Code blocks: 5.0x - Body text: 1.0x +**Slug Matching Bonuses (helps dedicated docs rank higher):** +- Exact slug match: +50.0 (e.g., query "service-discovery" matches slug "service-discovery") +- Full phrase in slug: +30.0 (e.g., query "service discovery" matches slug "service-discovery") +- Partial slug match: +10.0 (proportional to matching segments) + +**Changelog/What's New Penalty:** +- Pages with "whats-new" or "changelog" in slug: 0.3x multiplier for generic queries +- Multiplier is skipped when the query indicates changelog intent (e.g., includes "changelog", "what's new", or similar terms) +- Prevents release notes from outranking dedicated documentation for non-changelog-focused queries + **Scoring Bonuses:** - Word boundary match: +0.5 - Multiple occurrences: +0.25 per occurrence (max 3) @@ -261,6 +271,8 @@ Search uses weighted field scoring for relevance ranking: **Implementation optimizations:** - Pre-computed lowercase text in `IndexedDocument` and `IndexedSection` classes +- Pre-computed lowercase slug for fast slug matching +- Pre-computed slug segments (`SlugSegments`) to avoid per-score allocations during slug relevance scoring - Span-based `CountOccurrences` method for zero-allocation matching - Static lambdas to avoid closure allocations - Pre-extracted code spans and identifiers diff --git a/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs b/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs index 98f3015845b..c5eb455b26e 100644 --- a/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs +++ b/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs @@ -104,6 +104,14 @@ internal sealed partial class DocsIndexService(IDocsFetcher docsFetcher, ILogger private const float CodeIdentifierBonus = 0.5f; private const int MinTokenLength = 2; + // Slug matching bonuses - helps dedicated docs rank higher than incidental mentions + private const float ExactSlugMatchBonus = 50.0f; // Query exactly matches slug (e.g., "service-discovery" matches service-discovery) + private const float FullPhraseInSlugBonus = 30.0f; // All query words in slug (e.g., "service discovery" -> service-discovery) + private const float PartialSlugMatchBonus = 10.0f; // Some query words in slug + + // Changelog/What's New penalty - these pages mention many terms and shouldn't outrank dedicated docs + private const float WhatsNewPenaltyMultiplier = 0.3f; // Apply 0.3x to whats-new pages + private readonly IDocsFetcher _docsFetcher = docsFetcher; private readonly ILogger _logger = logger; @@ -188,11 +196,14 @@ public async ValueTask> SearchAsync(string query return []; } + // Pre-compute queryAsSlug once to avoid repeated allocation in hot path + var queryAsSlug = string.Join("-", queryTokens); + var results = new List(); foreach (var doc in _indexedDocuments) { - var (score, matchedSection) = ScoreDocument(doc, queryTokens); + var (score, matchedSection) = ScoreDocument(doc, queryTokens, queryAsSlug); if (score > 0) { @@ -257,12 +268,16 @@ .. results }; } - private static (float Score, string? MatchedSection) ScoreDocument(IndexedDocument doc, string[] queryTokens) + private static (float Score, string? MatchedSection) ScoreDocument(IndexedDocument doc, string[] queryTokens, string queryAsSlug) { var score = 0.0f; string? matchedSection = null; var bestSectionScore = 0.0f; + // Score slug matching - this is key for finding dedicated docs + // e.g., query "service discovery" should match slug "service-discovery" with high score + score += ScoreSlugMatch(doc.SlugLower, doc.SlugSegments, queryTokens, queryAsSlug); + // Score H1 title score += ScoreField(doc.TitleLower, queryTokens) * TitleWeight; @@ -291,9 +306,126 @@ private static (float Score, string? MatchedSection) ScoreDocument(IndexedDocume score += bestSectionScore; + // Apply penalty for "What's New" / changelog pages + // These pages mention many features and shouldn't outrank dedicated documentation + // BUT: Skip penalty when user is explicitly searching for changelog content + // Note: "what's" tokenizes to "what" due to apostrophe splitting, so we check for both "what" and "new" together + var hasChangelogToken = queryTokens.Any(static t => t is "changelog" or "whats-new"); + var hasWhatsNewTokens = queryTokens.Contains("what") && queryTokens.Contains("new"); + var queryIsAboutChangelog = hasChangelogToken || hasWhatsNewTokens; + if (!queryIsAboutChangelog && (doc.SlugLower.Contains("whats-new") || doc.SlugLower.Contains("changelog"))) + { + score *= WhatsNewPenaltyMultiplier; + } + return (score, matchedSection); } + /// + /// Scores how well the query matches the document slug. + /// Helps dedicated docs rank higher than docs with incidental mentions. + /// + private static float ScoreSlugMatch(string slugLower, string[] slugSegments, string[] queryTokens, string queryAsSlug) + { + if (slugLower.Length is 0 || queryTokens.Length is 0) + { + return 0; + } + + // queryAsSlug is pre-computed before the scoring loop to avoid repeated allocation + // e.g., ["service", "discovery"] -> "service-discovery" + + // Exact match: query "service-discovery" matches slug "service-discovery" + if (slugLower == queryAsSlug) + { + return ExactSlugMatchBonus; + } + + // Check if slug contains the full query phrase + // This handles both multi-word queries and hyphenated single-token queries + // e.g., slug "azure-service-discovery" contains "service-discovery" + // e.g., single token "service-bus" matches slug "azure-service-bus" + var isMultiWordQuery = queryTokens.Length > 1; + var hasHyphenatedToken = queryTokens.Any(static t => t.Contains('-')); + + if ((isMultiWordQuery || hasHyphenatedToken) && slugLower.Contains(queryAsSlug)) + { + return FullPhraseInSlugBonus; + } + + // Count how many query tokens appear as distinct slug segments + // This prevents "service discovery" from boosting "azure-service-bus" + // because "discovery" must be a segment, not just "service" + // Note: slugSegments is pre-computed to avoid allocation in hot path + var matchingSegments = 0; + + foreach (var token in queryTokens) + { + // For hyphenated tokens, check if all parts match consecutive segments in order + if (token.Contains('-')) + { + var tokenParts = token.Split('-'); + + // Look for a contiguous sequence of slug segments that matches all token parts + var foundContiguousMatch = false; + var maxStartIndex = slugSegments.Length - tokenParts.Length; + + for (var startIndex = 0; startIndex <= maxStartIndex; startIndex++) + { + var allPartsMatch = true; + + for (var partIndex = 0; partIndex < tokenParts.Length; partIndex++) + { + if (slugSegments[startIndex + partIndex] != tokenParts[partIndex]) + { + allPartsMatch = false; + break; + } + } + + if (allPartsMatch) + { + foundContiguousMatch = true; + break; + } + } + + if (foundContiguousMatch) + { + matchingSegments++; + } + } + else + { + foreach (var segment in slugSegments) + { + if (segment == token) + { + matchingSegments++; + break; + } + } + } + } + + // All tokens match as individual segments (but not necessarily as a contiguous phrase) + // e.g., query "azure cosmos" matches slug "azure-cosmos-db" segment-by-segment + // This gets PartialSlugMatchBonus because the full phrase isn't in the slug + if (matchingSegments == queryTokens.Length) + { + return PartialSlugMatchBonus; + } + + // Some tokens match slug segments - give proportional bonus + if (matchingSegments > 0) + { + // Give proportional bonus based on how many tokens matched + return PartialSlugMatchBonus * matchingSegments / (float)queryTokens.Length; + } + + return 0; + } + /// /// Tokenizes a query string, preserving symbols like --flag, AddRedis, aspire.json. /// @@ -430,18 +562,34 @@ private static int CountOccurrences(ReadOnlySpan text, string token) /// /// Pre-indexed document with lowercase text for faster searching. /// - private sealed class IndexedDocument(LlmsDocument source) + private sealed class IndexedDocument { - public LlmsDocument Source { get; } = source; + private readonly string _slugLower; + + public IndexedDocument(LlmsDocument source) + { + Source = source; + TitleLower = source.Title.ToLowerInvariant(); + _slugLower = source.Slug.ToLowerInvariant(); + SlugSegments = _slugLower.Split('-'); + SummaryLower = source.Summary?.ToLowerInvariant(); + Sections = [.. source.Sections.Select(static s => new IndexedSection(s))]; + } - public string TitleLower { get; } = source.Title.ToLowerInvariant(); + public LlmsDocument Source { get; } - public string? SummaryLower { get; } = source.Summary?.ToLowerInvariant(); + public string TitleLower { get; } - public IReadOnlyList Sections { get; } = - [ - .. source.Sections.Select(static s => new IndexedSection(s)) - ]; + public string SlugLower => _slugLower; + + /// + /// Pre-computed slug segments to avoid allocation in hot path during scoring. + /// + public string[] SlugSegments { get; } + + public string? SummaryLower { get; } + + public IReadOnlyList Sections { get; } } /// diff --git a/tests/Aspire.Cli.Tests/Mcp/Docs/DocsIndexServiceTests.cs b/tests/Aspire.Cli.Tests/Mcp/Docs/DocsIndexServiceTests.cs index 0d458b3fef9..022869a2c9d 100644 --- a/tests/Aspire.Cli.Tests/Mcp/Docs/DocsIndexServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/Docs/DocsIndexServiceTests.cs @@ -709,6 +709,271 @@ Redis content. Assert.Empty(results); } + [Fact] + public async Task SearchAsync_SlugExactMatch_RanksHigher() + { + // This tests the "service discovery" example from the issue + // Query "service-discovery" should match slug "service-discovery" and rank #1 + var content = """ + # Service Discovery + > Learn about service discovery in Aspire. + + Service discovery content. + + # Azure Service Bus + > Connect to Azure Service Bus. + + Azure Service Bus has a service name. + """; + + var fetcher = CreateMockFetcher(content); + var service = new DocsIndexService(fetcher, NullLogger.Instance); + + var results = await service.SearchAsync("service-discovery"); + + Assert.NotEmpty(results); + Assert.Equal("Service Discovery", results[0].Title); + } + + [Fact] + public async Task SearchAsync_SlugPhraseMatch_RanksHigher() + { + // Query "service discovery" should match slug "service-discovery" with high score + // and not "azure-service-bus" just because "service" appears in it + var content = """ + # Service Discovery + > Learn about service discovery in Aspire. + + Service discovery content. + + # Azure Service Bus + > Connect to Azure Service Bus for messaging. + + Azure Service Bus documentation with lots of service mentions. + Service is mentioned multiple times. Service again. And service. + """; + + var fetcher = CreateMockFetcher(content); + var service = new DocsIndexService(fetcher, NullLogger.Instance); + + var results = await service.SearchAsync("service discovery"); + + Assert.NotEmpty(results); + Assert.Equal("Service Discovery", results[0].Title); + } + + [Fact] + public async Task SearchAsync_WhatsNewPenalty_RanksLower() + { + // "What's New" pages mention many features and should rank lower than dedicated docs + var content = """ + # JavaScript Integration + > How to use JavaScript with Aspire. + + JavaScript integration details. + + # What's New in Aspire 1.3 + > Release notes for Aspire 1.3. + + JavaScript support was added. JavaScript is now fully supported. + JavaScript JavaScript JavaScript. We love JavaScript! + """; + + var fetcher = CreateMockFetcher(content); + var service = new DocsIndexService(fetcher, NullLogger.Instance); + + var results = await service.SearchAsync("javascript"); + + Assert.NotEmpty(results); + // The dedicated JavaScript doc should rank higher even though What's New mentions it more + Assert.Equal("JavaScript Integration", results[0].Title); + } + + [Fact] + public async Task SearchAsync_PartialSlugMatch_StillRanksReasonably() + { + // Query with partial slug match should still rank well + var content = """ + # Configure the MCP Server + > How to configure MCP. + + MCP configuration details. + + # Aspire Dashboard Configuration + > Dashboard configuration including MCP settings. + + The dashboard has MCP options in settings. + """; + + var fetcher = CreateMockFetcher(content); + var service = new DocsIndexService(fetcher, NullLogger.Instance); + + var results = await service.SearchAsync("mcp"); + + Assert.NotEmpty(results); + // The doc with "mcp" in the slug should rank higher + Assert.Equal("Configure the MCP Server", results[0].Title); + } + + [Fact] + public async Task SearchAsync_ChangelogPenalty_AppliesCorrectly() + { + // Similar to whats-new, changelog pages should be penalized + var content = """ + # Redis Integration + > How to use Redis with Aspire. + + Redis integration details. + + # Changelog + > Complete changelog for Aspire. + + Redis support was added. Redis improvements. More Redis features. + """; + + var fetcher = CreateMockFetcher(content); + var service = new DocsIndexService(fetcher, NullLogger.Instance); + + var results = await service.SearchAsync("redis"); + + Assert.NotEmpty(results); + // The dedicated Redis doc should rank higher than the changelog + Assert.Equal("Redis Integration", results[0].Title); + } + + [Fact] + public async Task SearchAsync_MultiWordQuery_MatchesSlugSegments() + { + // Query "azure cosmos" should match slug "azure-cosmos-db" well + var content = """ + # Azure Cosmos DB + > Connect to Azure Cosmos DB. + + Cosmos content. + + # Azure Overview + > General Azure services overview. + + Overview includes Cosmos DB mention. + """; + + var fetcher = CreateMockFetcher(content); + var service = new DocsIndexService(fetcher, NullLogger.Instance); + + var results = await service.SearchAsync("azure cosmos"); + + Assert.NotEmpty(results); + Assert.Equal("Azure Cosmos DB", results[0].Title); + } + + [Fact] + public async Task SearchAsync_SingleWordQuery_UsesSegmentMatching() + { + // Single-word query should use segment-based matching (10 points) + // not phrase matching (30 points). + // This ensures "service" is scored by segment matches so that docs with "service" + // in the title and slug outrank docs where it only appears in the body. + var content = """ + # Redis Integration + > How to use Redis with Aspire. + + Redis integration details. + + # Azure Service Bus + > Connect to Azure Service Bus. + + The service is for messaging. Redis is mentioned in the service docs. + """; + + var fetcher = CreateMockFetcher(content); + var service = new DocsIndexService(fetcher, NullLogger.Instance); + + var results = await service.SearchAsync("service"); + + Assert.NotEmpty(results); + // Both docs should return results, but Azure Service Bus should rank higher + // because "service" is in the title AND as a slug segment + Assert.Equal("Azure Service Bus", results[0].Title); + } + + [Fact] + public async Task SearchAsync_HyphenatedQuery_MatchesSlugWithExtraSegments() + { + // Query "service-bus" should match slug "azure-service-bus" + // even though it's a single token containing a hyphen + var content = """ + # Azure Service Bus + > Connect to Azure Service Bus. + + Service Bus content. + + # Azure Overview + > General Azure services overview. + + Overview of Azure services. + """; + + var fetcher = CreateMockFetcher(content); + var service = new DocsIndexService(fetcher, NullLogger.Instance); + + var results = await service.SearchAsync("service-bus"); + + Assert.NotEmpty(results); + Assert.Equal("Azure Service Bus", results[0].Title); + } + + [Fact] + public async Task SearchAsync_ChangelogQuery_DoesNotApplyPenalty() + { + // When user searches for "changelog", the changelog page should NOT be penalized + var content = """ + # Changelog + > Complete changelog for Aspire. + + Version 1.0 changes. Version 2.0 changes. + + # Some Other Page + > Random page. + + Changelog mentioned once. + """; + + var fetcher = CreateMockFetcher(content); + var service = new DocsIndexService(fetcher, NullLogger.Instance); + + var results = await service.SearchAsync("changelog"); + + Assert.NotEmpty(results); + // The dedicated Changelog page should rank highest when user searches for it + Assert.Equal("Changelog", results[0].Title); + } + + [Fact] + public async Task SearchAsync_WhatsNewQuery_DoesNotApplyPenalty() + { + // When user searches for "whats new", the whats-new page should NOT be penalized + var content = """ + # What's New in Aspire 1.3 + > Release notes for Aspire 1.3. + + New features and improvements. + + # Other Documentation + > Some other docs. + + Nothing new here. + """; + + var fetcher = CreateMockFetcher(content); + var service = new DocsIndexService(fetcher, NullLogger.Instance); + + var results = await service.SearchAsync("whats new"); + + Assert.NotEmpty(results); + // The What's New page should rank highest when user searches for it + Assert.Equal("What's New in Aspire 1.3", results[0].Title); + } + private sealed class MockDocsFetcher(string? content) : IDocsFetcher { public Task FetchDocsAsync(CancellationToken cancellationToken = default) From ad0160cd4acd5b193c5a26460d9a4fac2ab69d12 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 2 Feb 2026 13:55:59 +1100 Subject: [PATCH 009/256] Enable SSHD and Copilot CLI in devcontainer (#14185) * Enable sshd feature in devcontainer * Add Copilot CLI to devcontainer * Use copilot-cli devcontainer feature instead of gh extension * Add Dockerfile workaround for Yarn GPG key expiration issue * Fix misleading Dockerfile comment about Yarn keyring removal --------- Co-authored-by: Mitch Denny --- .devcontainer/Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index a02edba8452..471fe5a3621 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,7 +1,6 @@ FROM mcr.microsoft.com/devcontainers/dotnet:10.0-noble # Workaround for expired Yarn GPG key (https://github.com/yarnpkg/yarn/issues/9218) -# This updates the yarn keyring before apt-get update is called by devcontainer features RUN rm -f /etc/apt/sources.list.d/yarn.list 2>/dev/null || true \ && rm -f /usr/share/keyrings/yarn-archive-keyring.gpg 2>/dev/null || true \ && rm -f /usr/share/keyrings/yarn.gpg 2>/dev/null || true From 16e6c4b05a56cb2b420929d28f2fb30e2c10c79b Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 2 Feb 2026 00:33:33 -0500 Subject: [PATCH 010/256] Apply filters when set even if metrics collection is paused (#14277) * Apply filters when set even if metrics collection is paused * keep data window stable when paused, add tests --- .../Components/Controls/Chart/ChartBase.cs | 9 +- .../Controls/Chart/ChartContainer.razor | 4 +- .../Controls/Chart/ChartContainer.razor.cs | 4 +- .../Controls/Chart/ChartFilters.razor | 5 +- .../Controls/Chart/ChartFilters.razor.cs | 15 +++ .../Model/DimensionFilterViewModel.cs | 11 ++- .../Controls/ChartFiltersTests.cs | 92 +++++++++++++++++++ 7 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 tests/Aspire.Dashboard.Components.Tests/Controls/ChartFiltersTests.cs diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs index bccb2d4acc3..a407078d315 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs @@ -80,9 +80,14 @@ InstrumentViewModel.MatchedDimensions is null || var inProgressDataTime = PauseManager.AreMetricsPaused(out var pausedAt) ? pausedAt.Value : GetCurrentDataTime(); - while (_currentDataStartTime.Add(_tickDuration) < inProgressDataTime) + // Only advance the time window when not paused. When paused, keep the chart's + // time axis stable so filter changes don't cause the x-axis to jump. + if (pausedAt is null) { - _currentDataStartTime = _currentDataStartTime.Add(_tickDuration); + while (_currentDataStartTime.Add(_tickDuration) < inProgressDataTime) + { + _currentDataStartTime = _currentDataStartTime.Add(_tickDuration); + } } var dimensionAttributes = InstrumentViewModel.MatchedDimensions.Select(d => d.Attributes).ToList(); diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor index 27ad9367f82..7a594d2c639 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor @@ -38,7 +38,7 @@ else Icon="@(new Icons.Regular.Size24.DataArea())">
- +
- +
diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs index aaa3e38021c..d47014b4e4b 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs @@ -183,7 +183,9 @@ protected override async Task OnParametersSetAsync() private OtlpInstrumentData? GetInstrument() { - var endDate = DateTime.UtcNow; + // When paused, use the paused time to keep the data window stable. + // This ensures filter changes while paused still show the same data. + var endDate = PauseManager.AreMetricsPaused(out var pausedAt) ? pausedAt.Value : DateTime.UtcNow; // Get more data than is being displayed. Histogram graph uses some historical data to calculate bucket counts. // It's ok to get more data than is needed here. An additional date filter is applied when building chart values. var startDate = endDate.Subtract(Duration + TimeSpan.FromSeconds(30)); diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor index ea416138447..6efc7a7a044 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor @@ -63,7 +63,8 @@ ThreeState="true" ShowIndeterminate="false" ThreeStateOrderUncheckToIntermediate="true" - @bind-CheckState="context.AreAllValuesSelected"/> + @bind-CheckState:get="context.AreAllValuesSelected" + @bind-CheckState:set="@(v => OnAllValuesSelectionChangedAsync(context, v))"/> @foreach (var tag in context.Values.OrderBy(v => v.Text)) { var isChecked = context.SelectedValues.Contains(tag); @@ -71,7 +72,7 @@ title="@tag.Text" @key=tag @bind-Value:get="isChecked" - @bind-Value:set="c => context.OnTagSelectionChanged(tag, c)"/> + @bind-Value:set="c => OnTagSelectionChangedAsync(context, tag, c)"/> } diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs index 4024da5c954..316ee490820 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs @@ -19,6 +19,9 @@ public partial class ChartFilters [Parameter, EditorRequired] public required ImmutableList DimensionFilters { get; set; } + [Parameter] + public EventCallback OnDimensionValuesChanged { get; set; } + public bool ShowCounts { get; set; } protected override void OnInitialized() @@ -34,4 +37,16 @@ private void ShowCountChanged() { InstrumentViewModel.ShowCount = ShowCounts; } + + private async Task OnTagSelectionChangedAsync(DimensionFilterViewModel context, DimensionValueViewModel tag, bool isChecked) + { + context.OnTagSelectionChanged(tag, isChecked); + await OnDimensionValuesChanged.InvokeAsync(context); + } + + private async Task OnAllValuesSelectionChangedAsync(DimensionFilterViewModel context, bool? isChecked) + { + context.AreAllValuesSelected = isChecked; + await OnDimensionValuesChanged.InvokeAsync(context); + } } diff --git a/src/Aspire.Dashboard/Model/DimensionFilterViewModel.cs b/src/Aspire.Dashboard/Model/DimensionFilterViewModel.cs index 63edc659421..000c1bd95fa 100644 --- a/src/Aspire.Dashboard/Model/DimensionFilterViewModel.cs +++ b/src/Aspire.Dashboard/Model/DimensionFilterViewModel.cs @@ -34,8 +34,17 @@ public bool? AreAllValuesSelected } else if (value is false) { - SelectedValues.Clear(); + // Only clear if all values are currently selected. + // FluentCheckbox's three-state handling can spuriously fire the setter with false + // when the state transitions from true to null (intermediate) due to individual + // checkbox changes. In that case, AreAllValuesSelected is already null/false, + // and we should not clear the remaining selections. + if (AreAllValuesSelected is true) + { + SelectedValues.Clear(); + } } + // When value is null (intermediate state), do nothing. } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ChartFiltersTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ChartFiltersTests.cs new file mode 100644 index 00000000000..a4e13d5c0e5 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ChartFiltersTests.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Controls; + +public class ChartFiltersTests +{ + [Fact] + public void AreAllValuesSelected_SetFalse_ClearsOnlyWhenAllSelected() + { + // Arrange - all values selected + var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" }; + dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "GET", Value = "GET" }); + dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "POST", Value = "POST" }); + dimensionFilter.SelectedValues.Add(dimensionFilter.Values[0]); + dimensionFilter.SelectedValues.Add(dimensionFilter.Values[1]); + + Assert.True(dimensionFilter.AreAllValuesSelected); + + // Act - set false when all are selected + dimensionFilter.AreAllValuesSelected = false; + + // Assert - should clear + Assert.Empty(dimensionFilter.SelectedValues); + } + + [Fact] + public void AreAllValuesSelected_SetFalse_DoesNotClearWhenPartiallySelected() + { + // This test verifies the fix for the FluentCheckbox ThreeState race condition. + // FluentCheckbox with ThreeState=true can spuriously fire the setter with false + // when the bound CheckState changes from true to null (intermediate state). + // Our fix prevents clearing when AreAllValuesSelected is not true. + + // Arrange - only GET selected (partial selection, AreAllValuesSelected = null) + var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" }; + dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "GET", Value = "GET" }); + dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "POST", Value = "POST" }); + dimensionFilter.SelectedValues.Add(dimensionFilter.Values[0]); // Only GET + + Assert.Null(dimensionFilter.AreAllValuesSelected); // Partial selection = null + + // Act - simulate FluentCheckbox spuriously firing setter with false + dimensionFilter.AreAllValuesSelected = false; + + // Assert - GET should still be selected (not cleared) + Assert.Single(dimensionFilter.SelectedValues); + Assert.Equal("GET", dimensionFilter.SelectedValues.First().Value); + } + + [Fact] + public void AreAllValuesSelected_SetTrue_SelectsAllValues() + { + // Arrange - no values selected + var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" }; + dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "GET", Value = "GET" }); + dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "POST", Value = "POST" }); + + // Act + dimensionFilter.AreAllValuesSelected = true; + + // Assert + Assert.Equal(2, dimensionFilter.SelectedValues.Count); + Assert.True(dimensionFilter.AreAllValuesSelected); + } + + [Fact] + public void OnTagSelectionChanged_RemovesValue_LeavesOthersSelected() + { + // This tests the normal flow when user unchecks a single filter value. + + // Arrange - both selected + var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" }; + var getValue = new DimensionValueViewModel { Text = "GET", Value = "GET" }; + var postValue = new DimensionValueViewModel { Text = "POST", Value = "POST" }; + dimensionFilter.Values.Add(getValue); + dimensionFilter.Values.Add(postValue); + dimensionFilter.SelectedValues.Add(getValue); + dimensionFilter.SelectedValues.Add(postValue); + + // Act - uncheck GET + dimensionFilter.OnTagSelectionChanged(getValue, isChecked: false); + + // Assert - only POST remains + Assert.Single(dimensionFilter.SelectedValues); + Assert.Contains(postValue, dimensionFilter.SelectedValues); + Assert.DoesNotContain(getValue, dimensionFilter.SelectedValues); + } +} From 9c031a837813ac3c212aaed14609694a8f099743 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 2 Feb 2026 13:35:46 +0800 Subject: [PATCH 011/256] Refactor CLI MCP to get resources from app host (#14252) --- src/Aspire.Cli/Aspire.Cli.csproj | 4 + .../Backchannel/AppHostConnectionHelper.cs | 97 +++++++++ .../Backchannel/ResourceSnapshotMapper.cs | 188 ++++++++++++++++ src/Aspire.Cli/Commands/AgentMcpCommand.cs | 90 +------- src/Aspire.Cli/Commands/McpStartCommand.cs | 2 +- src/Aspire.Cli/Commands/ResourcesCommand.cs | 132 +++++------- src/Aspire.Cli/Mcp/KnownMcpTools.cs | 4 +- src/Aspire.Cli/Mcp/McpErrorMessages.cs | 26 +++ src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs | 105 +++++++-- src/Aspire.Cli/Properties/launchSettings.json | 8 + src/Aspire.Dashboard/Aspire.Dashboard.csproj | 2 + .../Controls/ResourceActions.razor.cs | 5 +- .../Controls/ResourceDetails.razor.cs | 2 +- .../Components/Pages/ConsoleLogs.razor.cs | 4 +- .../Components/Pages/Resources.razor | 1 - .../Components/Pages/Resources.razor.cs | 4 +- .../Assistant/Prompts/KnownChatMessages.cs | 2 +- src/Aspire.Dashboard/Model/ExportHelpers.cs | 14 +- .../Model/ResourceMenuBuilder.cs | 18 +- .../Model/ResourceSourceViewModel.cs | 107 +++++----- .../Model/ResourceViewModel.cs | 9 +- .../Model/ResourceViewModelExtensions.cs | 20 ++ .../ResourceJsonSerializerContext.cs | 1 + .../Model/TelemetryExportService.cs | 48 ++++- .../ApplicationModel/ResourceExtensions.cs | 23 +- .../AuxiliaryBackchannelRpcTarget.cs | 59 +++++- .../Backchannel/BackchannelDataTypes.cs | 118 ++++++++++- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 4 +- src/Aspire.Hosting/Dcp/DcpNameGenerator.cs | 2 +- src/Aspire.Hosting/DistributedApplication.cs | 13 +- .../Utils => Shared}/DashboardUrls.cs | 70 ++++-- src/Shared/Model/ResourceSourceViewModel.cs | 47 ++++ .../Model/Serialization/ResourceJson.cs | 31 +++ .../Model/ExportHelpersTests.cs | 12 +- .../Model/ResourceMenuBuilderTests.cs | 6 +- .../Model/ResourceSourceViewModelTests.cs | 104 +++++---- .../Model/TelemetryExportServiceTests.cs | 47 +++- .../AuxiliaryBackchannelRpcTargetTests.cs | 200 ++++++++++++++++++ .../Backchannel/BackchannelContractTests.cs | 4 +- .../ResourceExtensionsTests.cs | 70 ++++++ 40 files changed, 1308 insertions(+), 395 deletions(-) create mode 100644 src/Aspire.Cli/Backchannel/AppHostConnectionHelper.cs create mode 100644 src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs create mode 100644 src/Aspire.Cli/Mcp/McpErrorMessages.cs rename src/{Aspire.Dashboard/Utils => Shared}/DashboardUrls.cs (63%) create mode 100644 src/Shared/Model/ResourceSourceViewModel.cs create mode 100644 tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 1b72e8cd290..735aebb190a 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -67,6 +67,10 @@ + + + + diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionHelper.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionHelper.cs new file mode 100644 index 00000000000..a844a43b052 --- /dev/null +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionHelper.cs @@ -0,0 +1,97 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; +using ModelContextProtocol; + +namespace Aspire.Cli.Backchannel; + +/// +/// Provides helper methods for working with AppHost connections. +/// +internal static class AppHostConnectionHelper +{ + /// + /// Gets the appropriate AppHost connection based on the selection logic: + /// 1. If a specific AppHost is selected via select_apphost, use that + /// 2. Otherwise, look for in-scope connections (AppHosts within the working directory) + /// 3. If exactly one in-scope connection exists, use it + /// 4. If multiple in-scope connections exist, throw an error listing them + /// 5. If no in-scope connections exist, fall back to the first available connection + /// + /// The backchannel monitor to get connections from. + /// Logger for debug output. + /// Cancellation token. + /// The selected connection, or null if none available. + public static async Task GetSelectedConnectionAsync( + IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, + ILogger logger, + CancellationToken cancellationToken = default) + { + var connections = auxiliaryBackchannelMonitor.Connections.ToList(); + + if (connections.Count == 0) + { + await auxiliaryBackchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); + connections = auxiliaryBackchannelMonitor.Connections.ToList(); + if (connections.Count == 0) + { + return null; + } + } + + // Check if a specific AppHost was selected + var selectedPath = auxiliaryBackchannelMonitor.SelectedAppHostPath; + if (!string.IsNullOrEmpty(selectedPath)) + { + var selectedConnection = connections.FirstOrDefault(c => + c.AppHostInfo?.AppHostPath != null && + string.Equals(c.AppHostInfo.AppHostPath, selectedPath, StringComparison.OrdinalIgnoreCase)); + + if (selectedConnection != null) + { + logger.LogDebug("Using explicitly selected AppHost: {AppHostPath}", selectedPath); + return selectedConnection; + } + + logger.LogWarning("Selected AppHost at '{SelectedPath}' is no longer running, falling back to selection logic", selectedPath); + // Clear the selection since the AppHost is no longer available + auxiliaryBackchannelMonitor.SelectedAppHostPath = null; + } + + // Get in-scope connections + var inScopeConnections = connections.Where(c => c.IsInScope).ToList(); + + if (inScopeConnections.Count == 1) + { + logger.LogDebug("Using single in-scope AppHost: {AppHostPath}", inScopeConnections[0].AppHostInfo?.AppHostPath ?? "N/A"); + return inScopeConnections[0]; + } + + if (inScopeConnections.Count > 1) + { + var paths = inScopeConnections + .Where(c => c.AppHostInfo?.AppHostPath != null) + .Select(c => c.AppHostInfo!.AppHostPath) + .ToList(); + + var pathsList = string.Join("\n", paths.Select(p => $" - {p}")); + + throw new McpProtocolException( + $"Multiple Aspire AppHosts are running in the scope of the MCP server's working directory. " + + $"Use the 'select_apphost' tool to specify which AppHost to use.\n\nRunning AppHosts:\n{pathsList}", + McpErrorCode.InternalError); + } + + var fallback = connections + .OrderBy(c => c.AppHostInfo?.AppHostPath ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .ThenBy(c => c.AppHostInfo?.ProcessId ?? int.MaxValue) + .FirstOrDefault(); + + logger.LogDebug( + "No in-scope AppHosts found. Falling back to first available AppHost: {AppHostPath}", + fallback?.AppHostInfo?.AppHostPath ?? "N/A"); + + return fallback; + } +} diff --git a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs new file mode 100644 index 00000000000..c06c192717b --- /dev/null +++ b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs @@ -0,0 +1,188 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Utils; +using Aspire.Shared.Model; +using Aspire.Shared.Model.Serialization; + +namespace Aspire.Cli.Backchannel; + +/// +/// Maps to for serialization. +/// +internal static class ResourceSnapshotMapper +{ + /// + /// Maps a list of to a list of . + /// + /// The resource snapshots to map. + /// Optional base URL of the Aspire Dashboard for generating resource URLs. + public static List MapToResourceJsonList(IEnumerable snapshots, string? dashboardBaseUrl = null) + { + var snapshotList = snapshots.ToList(); + return snapshotList.Select(s => MapToResourceJson(s, snapshotList, dashboardBaseUrl)).ToList(); + } + + /// + /// Maps a to . + /// + /// The resource snapshot to map. + /// All resource snapshots for resolving relationships. + /// Optional base URL of the Aspire Dashboard for generating resource URLs. + public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnlyList allSnapshots, string? dashboardBaseUrl = null) + { + var urls = snapshot.Urls + .Select(u => new ResourceUrlJson + { + Name = u.Name, + DisplayName = u.DisplayProperties?.DisplayName, + Url = u.Url, + IsInternal = u.IsInternal + }) + .ToArray(); + + var volumes = snapshot.Volumes + .Select(v => new ResourceVolumeJson + { + Source = v.Source, + Target = v.Target, + MountType = v.MountType, + IsReadOnly = v.IsReadOnly + }) + .ToArray(); + + var healthReports = snapshot.HealthReports + .Select(h => new ResourceHealthReportJson + { + Name = h.Name, + Status = h.Status, + Description = h.Description, + ExceptionMessage = h.ExceptionText + }) + .ToArray(); + + var environment = snapshot.EnvironmentVariables + .Where(e => e.IsFromSpec) + .Select(e => new ResourceEnvironmentVariableJson + { + Name = e.Name, + Value = e.Value + }) + .ToArray(); + + var properties = snapshot.Properties + .Select(p => new ResourcePropertyJson + { + Name = p.Key, + Value = p.Value + }) + .ToArray(); + + // Build relationships by matching DisplayName + var relationships = new List(); + foreach (var relationship in snapshot.Relationships) + { + var matches = allSnapshots + .Where(r => string.Equals(r.DisplayName, relationship.ResourceName, StringComparisons.ResourceName)) + .ToList(); + + foreach (var match in matches) + { + relationships.Add(new ResourceRelationshipJson + { + Type = relationship.Type, + ResourceName = match.Name + }); + } + } + + // Only include enabled commands + var commands = snapshot.Commands + .Where(c => string.Equals(c.State, "Enabled", StringComparison.OrdinalIgnoreCase)) + .Select(c => new ResourceCommandJson + { + Name = c.Name, + Description = c.Description + }) + .ToArray(); + + // Get source information using the shared ResourceSourceViewModel + var sourceViewModel = ResourceSource.GetSourceModel(snapshot.ResourceType, snapshot.Properties); + + // Generate dashboard URL for this resource if a base URL is provided + string? dashboardUrl = null; + if (!string.IsNullOrEmpty(dashboardBaseUrl)) + { + var resourcePath = DashboardUrls.ResourcesUrl(snapshot.Name); + dashboardUrl = DashboardUrls.CombineUrl(dashboardBaseUrl, resourcePath); + } + + return new ResourceJson + { + Name = snapshot.Name, + DisplayName = snapshot.DisplayName, + ResourceType = snapshot.ResourceType, + State = snapshot.State, + StateStyle = snapshot.StateStyle, + HealthStatus = snapshot.HealthStatus, + Source = sourceViewModel?.Value, + ExitCode = snapshot.ExitCode, + CreationTimestamp = snapshot.CreatedAt, + StartTimestamp = snapshot.StartedAt, + StopTimestamp = snapshot.StoppedAt, + DashboardUrl = dashboardUrl, + Urls = urls, + Volumes = volumes, + Environment = environment, + HealthReports = healthReports, + Properties = properties, + Relationships = relationships.ToArray(), + Commands = commands + }; + } + + /// + /// Gets the display name for a resource, returning the unique name if there are multiple resources + /// with the same display name (replicas). + /// + /// The resource to get the name for. + /// All resources to check for duplicates. + /// The display name if unique, otherwise the unique resource name. + public static string GetResourceName(ResourceSnapshot resource, IDictionary allResources) + { + return GetResourceName(resource, allResources.Values); + } + + /// + /// Gets the display name for a resource, returning the unique name if there are multiple resources + /// with the same display name (replicas). + /// + /// The resource to get the name for. + /// All resources to check for duplicates. + /// The display name if unique, otherwise the unique resource name. + public static string GetResourceName(ResourceSnapshot resource, IEnumerable allResources) + { + var count = 0; + foreach (var item in allResources) + { + // Skip hidden resources + if (string.Equals(item.State, "Hidden", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.Equals(item.DisplayName, resource.DisplayName, StringComparisons.ResourceName)) + { + count++; + if (count >= 2) + { + // There are multiple resources with the same display name so they're part of a replica set. + // Need to use the name which has a unique ID to tell them apart. + return resource.Name; + } + } + } + + return resource.DisplayName ?? resource.Name; + } +} diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index e708247d33a..dae7a9eda62 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -63,7 +63,7 @@ public AgentMcpCommand( _docsIndexService = docsIndexService; _knownTools = new Dictionary { - [KnownMcpTools.ListResources] = new ListResourcesTool(), + [KnownMcpTools.ListResources] = new ListResourcesTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), [KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(), [KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(), [KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(), @@ -269,21 +269,13 @@ private async ValueTask CallDashboardToolAsync( if (connection is null) { _logger.LogWarning("No Aspire AppHost is currently running"); - throw new McpProtocolException( - "No Aspire AppHost is currently running. " + - "To use Aspire MCP tools, you must first start an Aspire application by running 'aspire run' in your AppHost project directory. " + - "Once the application is running, the MCP tools will be able to connect to the dashboard and execute commands.", - McpErrorCode.InternalError); + throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError); } if (connection.McpInfo is null) { _logger.LogWarning("Dashboard is not available in the running AppHost"); - throw new McpProtocolException( - "The Aspire Dashboard is not available in the running AppHost. " + - "The dashboard must be enabled to use MCP tools. " + - "Ensure your AppHost is configured with the dashboard enabled (this is the default configuration).", - McpErrorCode.InternalError); + throw new McpProtocolException(McpErrorMessages.DashboardNotAvailable, McpErrorCode.InternalError); } _logger.LogInformation( @@ -404,80 +396,10 @@ private async Task RefreshResourceToolMapAsync(CancellationToken cancellati } /// - /// Gets the appropriate AppHost connection based on the selection logic: - /// 1. If a specific AppHost is selected via select_apphost, use that - /// 2. Otherwise, look for in-scope connections (AppHosts within the working directory) - /// 3. If exactly one in-scope connection exists, use it - /// 4. If multiple in-scope connections exist, throw an error listing them - /// 5. If no in-scope connections exist, fall back to the first available connection + /// Gets the appropriate AppHost connection based on the selection logic. /// - private async Task GetSelectedConnectionAsync(CancellationToken cancellationToken) + private Task GetSelectedConnectionAsync(CancellationToken cancellationToken) { - var connections = _auxiliaryBackchannelMonitor.Connections.ToList(); - - if (connections.Count == 0) - { - await _auxiliaryBackchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); - connections = _auxiliaryBackchannelMonitor.Connections.ToList(); - if (connections.Count == 0) - { - return null; - } - } - - // Check if a specific AppHost was selected - var selectedPath = _auxiliaryBackchannelMonitor.SelectedAppHostPath; - if (!string.IsNullOrEmpty(selectedPath)) - { - var selectedConnection = connections.FirstOrDefault(c => - c.AppHostInfo?.AppHostPath != null && - string.Equals(c.AppHostInfo.AppHostPath, selectedPath, StringComparison.OrdinalIgnoreCase)); - - if (selectedConnection != null) - { - _logger.LogDebug("Using explicitly selected AppHost: {AppHostPath}", selectedPath); - return selectedConnection; - } - - _logger.LogWarning("Selected AppHost at '{SelectedPath}' is no longer running, falling back to selection logic", selectedPath); - // Clear the selection since the AppHost is no longer available - _auxiliaryBackchannelMonitor.SelectedAppHostPath = null; - } - - // Get in-scope connections - var inScopeConnections = connections.Where(c => c.IsInScope).ToList(); - - if (inScopeConnections.Count == 1) - { - _logger.LogDebug("Using single in-scope AppHost: {AppHostPath}", inScopeConnections[0].AppHostInfo?.AppHostPath ?? "N/A"); - return inScopeConnections[0]; - } - - if (inScopeConnections.Count > 1) - { - var paths = inScopeConnections - .Where(c => c.AppHostInfo?.AppHostPath != null) - .Select(c => c.AppHostInfo!.AppHostPath) - .ToList(); - - var pathsList = string.Join("\n", paths.Select(p => $" - {p}")); - - throw new McpProtocolException( - $"Multiple Aspire AppHosts are running in the scope of the MCP server's working directory. " + - $"Use the 'select_apphost' tool to specify which AppHost to use.\n\nRunning AppHosts:\n{pathsList}", - McpErrorCode.InternalError); - } - - var fallback = connections - .OrderBy(c => c.AppHostInfo?.AppHostPath ?? string.Empty, StringComparer.OrdinalIgnoreCase) - .ThenBy(c => c.AppHostInfo?.ProcessId ?? int.MaxValue) - .FirstOrDefault(); - - _logger.LogDebug( - "No in-scope AppHosts found for working directory {WorkingDirectory}. Falling back to first available AppHost: {AppHostPath}", - _executionContext.WorkingDirectory, - fallback?.AppHostInfo?.AppHostPath ?? "N/A"); - - return fallback; + return AppHostConnectionHelper.GetSelectedConnectionAsync(_auxiliaryBackchannelMonitor, _logger, cancellationToken); } } diff --git a/src/Aspire.Cli/Commands/McpStartCommand.cs b/src/Aspire.Cli/Commands/McpStartCommand.cs index 473af283d02..553c3e5ba1c 100644 --- a/src/Aspire.Cli/Commands/McpStartCommand.cs +++ b/src/Aspire.Cli/Commands/McpStartCommand.cs @@ -60,7 +60,7 @@ protected override Task ExecuteAsync(ParseResult parseResult, CancellationT { // Display deprecation warning to stderr (all MCP logging goes to stderr) InteractionService.DisplayMarkupLine($"[yellow]⚠ {McpCommandStrings.DeprecatedCommandWarning}[/]"); - + // Delegate to the new AgentMcpCommand return _agentMcpCommand.ExecuteCommandAsync(parseResult, cancellationToken); } diff --git a/src/Aspire.Cli/Commands/ResourcesCommand.cs b/src/Aspire.Cli/Commands/ResourcesCommand.cs index 9a8b93f7bb6..a203c5c748b 100644 --- a/src/Aspire.Cli/Commands/ResourcesCommand.cs +++ b/src/Aspire.Cli/Commands/ResourcesCommand.cs @@ -32,6 +32,7 @@ internal sealed class ResourcesOutput [JsonSerializable(typeof(ResourceHealthReportJson))] [JsonSerializable(typeof(ResourcePropertyJson))] [JsonSerializable(typeof(ResourceRelationshipJson))] +[JsonSerializable(typeof(ResourceCommandJson))] [JsonSourceGenerationOptions( WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, @@ -148,8 +149,14 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell private async Task ExecuteSnapshotAsync(AppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, CancellationToken cancellationToken) { - // Get current resource snapshots using the dedicated RPC method - var snapshots = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); + // Get dashboard URL and resource snapshots in parallel + var dashboardUrlsTask = connection.GetDashboardUrlsAsync(cancellationToken); + var snapshotsTask = connection.GetResourceSnapshotsAsync(cancellationToken); + + await Task.WhenAll(dashboardUrlsTask, snapshotsTask).ConfigureAwait(false); + + var dashboardUrls = await dashboardUrlsTask.ConfigureAwait(false); + var snapshots = await snapshotsTask.ConfigureAwait(false); // Filter by resource name if specified if (resourceName is not null) @@ -164,7 +171,9 @@ private async Task ExecuteSnapshotAsync(AppHostAuxiliaryBackchannel connect return ExitCodeConstants.FailedToFindProject; } - var resourceList = snapshots.Select(MapToResourceJson).ToList(); + // Use the dashboard base URL if available + var dashboardBaseUrl = dashboardUrls?.BaseUrlWithLoginToken; + var resourceList = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardBaseUrl); if (format == OutputFormat.Json) { @@ -174,7 +183,7 @@ private async Task ExecuteSnapshotAsync(AppHostAuxiliaryBackchannel connect } else { - DisplayResourcesTable(resourceList); + DisplayResourcesTable(snapshots); } return ExitCodeConstants.Success; @@ -182,16 +191,26 @@ private async Task ExecuteSnapshotAsync(AppHostAuxiliaryBackchannel connect private async Task ExecuteWatchAsync(AppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, CancellationToken cancellationToken) { + // Get dashboard URL first for generating resource links + var dashboardUrls = await connection.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false); + var dashboardBaseUrl = dashboardUrls?.BaseUrlWithLoginToken; + + // Maintain a dictionary of all resources seen so far for relationship resolution + var allResources = new Dictionary(StringComparer.OrdinalIgnoreCase); + // Stream resource snapshots await foreach (var snapshot in connection.WatchResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false)) { + // Update the dictionary with the latest snapshot for this resource + allResources[snapshot.Name] = snapshot; + // Filter by resource name if specified if (resourceName is not null && !string.Equals(snapshot.Name, resourceName, StringComparison.OrdinalIgnoreCase)) { continue; } - var resourceJson = MapToResourceJson(snapshot); + var resourceJson = ResourceSnapshotMapper.MapToResourceJson(snapshot, allResources.Values.ToList(), dashboardBaseUrl); if (format == OutputFormat.Json) { @@ -202,26 +221,31 @@ private async Task ExecuteWatchAsync(AppHostAuxiliaryBackchannel connection else { // Human-readable update - DisplayResourceUpdate(resourceJson); + DisplayResourceUpdate(snapshot, allResources); } } return ExitCodeConstants.Success; } - private void DisplayResourcesTable(List resources) + private void DisplayResourcesTable(IReadOnlyList snapshots) { - if (resources.Count == 0) + if (snapshots.Count == 0) { _interactionService.DisplayPlainText("No resources found."); return; } + // Get display names for all resources + var orderedItems = snapshots.Select(s => (Snapshot: s, DisplayName: ResourceSnapshotMapper.GetResourceName(s, snapshots))) + .OrderBy(x => x.DisplayName) + .ToList();; + // Calculate column widths based on data - var nameWidth = Math.Max("NAME".Length, resources.Max(r => r.Name?.Length ?? 0)); - var typeWidth = Math.Max("TYPE".Length, resources.Max(r => r.ResourceType?.Length ?? 0)); - var stateWidth = Math.Max("STATE".Length, resources.Max(r => r.State?.Length ?? "Unknown".Length)); - var healthWidth = Math.Max("HEALTH".Length, resources.Max(r => r.HealthStatus?.Length ?? 1)); + var nameWidth = Math.Max("NAME".Length, orderedItems.Max(i => i.DisplayName.Length)); + var typeWidth = Math.Max("TYPE".Length, orderedItems.Max(i => i.Snapshot.ResourceType?.Length ?? 0)); + var stateWidth = Math.Max("STATE".Length, orderedItems.Max(i => i.Snapshot.State?.Length ?? "Unknown".Length)); + var healthWidth = Math.Max("HEALTH".Length, orderedItems.Max(i => i.Snapshot.HealthStatus?.Length ?? 1)); var totalWidth = nameWidth + typeWidth + stateWidth + healthWidth + 12 + 20; // 12 for spacing, 20 for endpoints min @@ -230,89 +254,33 @@ private void DisplayResourcesTable(List resources) _interactionService.DisplayPlainText($"{"NAME".PadRight(nameWidth)} {"TYPE".PadRight(typeWidth)} {"STATE".PadRight(stateWidth)} {"HEALTH".PadRight(healthWidth)} {"ENDPOINTS"}"); _interactionService.DisplayPlainText(new string('-', totalWidth)); - foreach (var resource in resources.OrderBy(r => r.Name)) + foreach (var (snapshot, displayName) in orderedItems) { - var endpoints = resource.Urls?.Length > 0 - ? string.Join(", ", resource.Urls.Where(u => !u.IsInternal).Select(u => u.Url)) + var endpoints = snapshot.Urls.Length > 0 + ? string.Join(", ", snapshot.Urls.Where(e => !e.IsInternal).Select(e => e.Url)) : "-"; - var name = resource.Name ?? "-"; - var type = resource.ResourceType ?? "-"; - var state = resource.State ?? "Unknown"; - var health = resource.HealthStatus ?? "-"; + var type = snapshot.ResourceType ?? "-"; + var state = snapshot.State ?? "Unknown"; + var health = snapshot.HealthStatus ?? "-"; - _interactionService.DisplayPlainText($"{name.PadRight(nameWidth)} {type.PadRight(typeWidth)} {state.PadRight(stateWidth)} {health.PadRight(healthWidth)} {endpoints}"); + _interactionService.DisplayPlainText($"{displayName.PadRight(nameWidth)} {type.PadRight(typeWidth)} {state.PadRight(stateWidth)} {health.PadRight(healthWidth)} {endpoints}"); } _interactionService.DisplayPlainText(""); } - private void DisplayResourceUpdate(ResourceJson resource) + private void DisplayResourceUpdate(ResourceSnapshot snapshot, IDictionary allResources) { - var endpoints = resource.Urls?.Length > 0 - ? string.Join(", ", resource.Urls.Where(u => !u.IsInternal).Select(u => u.Url)) + var displayName = ResourceSnapshotMapper.GetResourceName(snapshot, allResources); + + var endpoints = snapshot.Urls.Length > 0 + ? string.Join(", ", snapshot.Urls.Where(e => !e.IsInternal).Select(e => e.Url)) : ""; - var health = !string.IsNullOrEmpty(resource.HealthStatus) ? $" ({resource.HealthStatus})" : ""; + var health = !string.IsNullOrEmpty(snapshot.HealthStatus) ? $" ({snapshot.HealthStatus})" : ""; var endpointsStr = !string.IsNullOrEmpty(endpoints) ? $" - {endpoints}" : ""; - _interactionService.DisplayPlainText($"[{resource.Name}] {resource.State ?? "Unknown"}{health}{endpointsStr}"); - } - - private static ResourceJson MapToResourceJson(ResourceSnapshot snapshot) - { - return new ResourceJson - { - Name = snapshot.Name, - DisplayName = snapshot.Name, // Use name as display name for now - ResourceType = snapshot.Type, - State = snapshot.State, - StateStyle = snapshot.StateStyle, - CreationTimestamp = snapshot.CreatedAt, - StartTimestamp = snapshot.StartedAt, - StopTimestamp = snapshot.StoppedAt, - ExitCode = snapshot.ExitCode, - HealthStatus = snapshot.HealthStatus, - Urls = snapshot.Endpoints is { Length: > 0 } - ? snapshot.Endpoints.Select(e => new ResourceUrlJson - { - Name = e.Name, - Url = e.Url, - IsInternal = e.IsInternal - }).ToArray() - : null, - Volumes = snapshot.Volumes is { Length: > 0 } - ? snapshot.Volumes.Select(v => new ResourceVolumeJson - { - Source = v.Source, - Target = v.Target, - MountType = v.MountType, - IsReadOnly = v.IsReadOnly - }).ToArray() - : null, - HealthReports = snapshot.HealthReports is { Length: > 0 } - ? snapshot.HealthReports.Select(h => new ResourceHealthReportJson - { - Name = h.Name, - Status = h.Status, - Description = h.Description, - ExceptionMessage = h.ExceptionText - }).ToArray() - : null, - Properties = snapshot.Properties is { Count: > 0 } - ? snapshot.Properties.Select(p => new ResourcePropertyJson - { - Name = p.Key, - Value = p.Value - }).ToArray() - : null, - Relationships = snapshot.Relationships is { Length: > 0 } - ? snapshot.Relationships.Select(r => new ResourceRelationshipJson - { - Type = r.Type, - ResourceName = r.ResourceName - }).ToArray() - : null - }; + _interactionService.DisplayPlainText($"[{displayName}] {snapshot.State ?? "Unknown"}{health}{endpointsStr}"); } } diff --git a/src/Aspire.Cli/Mcp/KnownMcpTools.cs b/src/Aspire.Cli/Mcp/KnownMcpTools.cs index f8fe6bf388b..3e90912de4d 100644 --- a/src/Aspire.Cli/Mcp/KnownMcpTools.cs +++ b/src/Aspire.Cli/Mcp/KnownMcpTools.cs @@ -32,10 +32,10 @@ KnownMcpTools.Doctor or KnownMcpTools.RefreshTools or KnownMcpTools.ListDocs or KnownMcpTools.SearchDocs or - KnownMcpTools.GetDoc; + KnownMcpTools.GetDoc or + KnownMcpTools.ListResources; public static bool IsDashboardTool(string toolName) => toolName is - KnownMcpTools.ListResources or KnownMcpTools.ListConsoleLogs or KnownMcpTools.ExecuteResourceCommand or KnownMcpTools.ListStructuredLogs or diff --git a/src/Aspire.Cli/Mcp/McpErrorMessages.cs b/src/Aspire.Cli/Mcp/McpErrorMessages.cs new file mode 100644 index 00000000000..3f722db771a --- /dev/null +++ b/src/Aspire.Cli/Mcp/McpErrorMessages.cs @@ -0,0 +1,26 @@ +// 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.Mcp; + +/// +/// Provides common error messages used by MCP tools. +/// +internal static class McpErrorMessages +{ + /// + /// Error message when no Aspire AppHost is currently running. + /// + public const string NoAppHostRunning = + "No Aspire AppHost is currently running. " + + "To use Aspire MCP tools, you must first start an Aspire application by running 'aspire run' in your AppHost project directory. " + + "Once the application is running, the MCP tools will be able to connect to the dashboard and execute commands."; + + /// + /// Error message when the dashboard is not available in the running AppHost. + /// + public const string DashboardNotAvailable = + "The Aspire Dashboard is not available in the running AppHost. " + + "The dashboard must be enabled to use MCP tools. " + + "Ensure your AppHost is configured with the dashboard enabled (this is the default configuration)."; +} diff --git a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs index 5c6df184196..508f5cce451 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs @@ -1,13 +1,50 @@ // 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.Serialization; +using Aspire.Cli.Backchannel; +using Aspire.Shared.Model.Serialization; +using Microsoft.Extensions.Logging; using ModelContextProtocol; using ModelContextProtocol.Protocol; namespace Aspire.Cli.Mcp.Tools; -internal sealed class ListResourcesTool : CliMcpTool +[JsonSerializable(typeof(ResourceJson[]))] +[JsonSerializable(typeof(ResourceUrlJson))] +[JsonSerializable(typeof(ResourceVolumeJson))] +[JsonSerializable(typeof(ResourceEnvironmentVariableJson))] +[JsonSerializable(typeof(ResourceHealthReportJson))] +[JsonSerializable(typeof(ResourcePropertyJson))] +[JsonSerializable(typeof(ResourceRelationshipJson))] +[JsonSerializable(typeof(ResourceCommandJson))] +[JsonSourceGenerationOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +internal sealed partial class ListResourcesToolJsonContext : JsonSerializerContext +{ + private static ListResourcesToolJsonContext? s_relaxedEscaping; + + /// + /// Gets a context with relaxed JSON escaping for non-ASCII character support (pretty-printed). + /// + public static ListResourcesToolJsonContext RelaxedEscaping => s_relaxedEscaping ??= new(new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); +} + +/// +/// MCP tool for listing application resources. +/// Gets resource data directly from the AppHost backchannel instead of forwarding to the dashboard. +/// +internal sealed class ListResourcesTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListResources; @@ -20,22 +57,62 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) { - // Convert JsonElement arguments to Dictionary - Dictionary? convertedArgs = null; - if (arguments != null) + // This tool does not use the MCP client as it operates via backchannel + _ = mcpClient; + _ = arguments; + + var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + if (connection is null) { - convertedArgs = new Dictionary(); - foreach (var kvp in arguments) + logger.LogWarning("No Aspire AppHost is currently running"); + throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError); + } + + try + { + // Get dashboard URL and resource snapshots in parallel + var dashboardUrlsTask = connection.GetDashboardUrlsAsync(cancellationToken); + var snapshotsTask = connection.GetResourceSnapshotsAsync(cancellationToken); + + await Task.WhenAll(dashboardUrlsTask, snapshotsTask).ConfigureAwait(false); + + var dashboardUrls = await dashboardUrlsTask.ConfigureAwait(false); + var snapshots = await snapshotsTask.ConfigureAwait(false); + + if (snapshots.Count == 0) { - convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; + return new CallToolResult + { + Content = [new TextContentBlock { Text = "No resources found." }] + }; } - } - // Forward the call to the dashboard's MCP server - return await mcpClient.CallToolAsync( - Name, - convertedArgs, - serializerOptions: McpJsonUtilities.DefaultOptions, - cancellationToken: cancellationToken); + // Use the dashboard base URL if available + var dashboardBaseUrl = dashboardUrls?.BaseUrlWithLoginToken; + var resources = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardBaseUrl); + var resourceGraphData = JsonSerializer.Serialize(resources.ToArray(), ListResourcesToolJsonContext.RelaxedEscaping.ResourceJsonArray); + + var response = $""" + resource_name is the identifier of resources. + environment_variables is a list of environment variables configured for the resource. Environment variable values aren't provided because they could contain sensitive information. + Console logs for a resource can provide more information about why a resource is not in a running state. + + # RESOURCE DATA + + {resourceGraphData} + """; + + return new CallToolResult + { + Content = [new TextContentBlock { Text = response }] + }; + } + catch + { + return new CallToolResult + { + Content = [new TextContentBlock { Text = "No resources found." }] + }; + } } } diff --git a/src/Aspire.Cli/Properties/launchSettings.json b/src/Aspire.Cli/Properties/launchSettings.json index 2edabdd16eb..238071565f1 100644 --- a/src/Aspire.Cli/Properties/launchSettings.json +++ b/src/Aspire.Cli/Properties/launchSettings.json @@ -62,6 +62,14 @@ "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" } }, + "get-resources": { + "commandName": "Project", + "dotnetRunMessages": true, + "commandLineArgs": "resources --project ../../../../../playground/TestShop/TestShop.AppHost/TestShop.AppHost.csproj", + "environmentVariables": { + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + }, "run-singlefileapphost": { "commandName": "Project", "dotnetRunMessages": true, diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 6fea19731b9..13a9dd237d5 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -288,6 +288,7 @@ + @@ -302,6 +303,7 @@ + diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs index 0ede50f0d25..606f25ef39a 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs @@ -44,9 +44,6 @@ public partial class ResourceActions : ComponentBase [Parameter] public required ResourceViewModel Resource { get; set; } - [Parameter] - public required Func GetResourceName { get; set; } - [Parameter] public required int MaxHighlightedCount { get; set; } @@ -67,7 +64,7 @@ protected override void OnParametersSet() ResourceMenuBuilder.AddMenuItems( _menuItems, Resource, - GetResourceName, + ResourceByName, EventCallback.Factory.Create(this, () => OnViewDetails.InvokeAsync(_menuButton?.MenuButtonId)), CommandSelected, IsCommandExecuting, diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs index 58ca6f26577..c858271d12b 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ResourceDetails.razor.cs @@ -268,7 +268,7 @@ private void UpdateResourceActionsMenu() ResourceMenuBuilder.AddMenuItems( _resourceActionsMenuItems, Resource, - FormatName, + ResourceByName, EventCallback.Empty, // View details not shown since we're already in the details view CommandSelected, IsCommandExecuting, diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index 69bb7a917d9..34f7e3b3f86 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -505,7 +505,7 @@ private void UpdateMenuButtons() ResourceMenuBuilder.AddMenuItems( _resourceMenuItems, selectedResource, - GetResourceName, + _resourceByName, EventCallback.Factory.Create(this, () => { NavigationManager.NavigateTo(DashboardUrls.ResourcesUrl(resource: selectedResource.Name)); @@ -778,7 +778,7 @@ private void LoadLogsForResource(ConsoleLogsSubscription subscription) } } - var resourcePrefix = ResourceViewModel.GetResourceName(subscription.Resource, _resourceByName, _showHiddenResources); + var resourcePrefix = ResourceViewModel.GetResourceName(subscription.Resource, _resourceByName); var logParser = new LogParser(ConsoleColor.Black); await foreach (var batch in logSubscription.ConfigureAwait(false)) diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor b/src/Aspire.Dashboard/Components/Pages/Resources.razor index 9697ce7d3a4..be0cd0c3569 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor @@ -205,7 +205,6 @@ IsCommandExecuting="@((resource, command) => DashboardCommandExecutor.IsExecuting(resource.Name, command.Name))" OnViewDetails="@((buttonId) => ShowResourceDetailsAsync(context.Resource, buttonId))" Resource="context.Resource" - GetResourceName="GetResourceName" MaxHighlightedCount="_maxHighlightedCount" ResourceByName="@_resourceByName" /> diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs index 72323735beb..e7341da6899 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs @@ -643,7 +643,7 @@ private async Task ShowContextMenuAsync(ResourceViewModel resource, int screenWi ResourceMenuBuilder.AddMenuItems( _contextMenuItems, resource, - GetResourceName, + _resourceByName, EventCallback.Factory.Create(this, () => ShowResourceDetailsAsync(resource, buttonId: null)), EventCallback.Factory.Create(this, (command) => ExecuteResourceCommandAsync(resource, command)), (resource, command) => DashboardCommandExecutor.IsExecuting(resource.Name, command.Name), @@ -734,7 +734,7 @@ private async Task ClearSelectedResourceAsync(bool causedByUserAction = false) _elementIdBeforeDetailsViewOpened = null; } - private string GetResourceName(ResourceViewModel resource) => ResourceViewModel.GetResourceName(resource, _resourceByName, _showHiddenResources); + private string GetResourceName(ResourceViewModel resource) => ResourceViewModel.GetResourceName(resource, _resourceByName); private bool HasMultipleReplicas(ResourceViewModel resource) { diff --git a/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs b/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs index dc1bd142f9a..5b14dc3aa5d 100644 --- a/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs +++ b/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs @@ -84,7 +84,7 @@ public static ChatMessage CreateRecentActivityMessage() Summarize recent traces and structured logs for all resources. Investigate the root cause of any errors in traces or structured logs. """; - + return new(ChatRole.User, prompt); } diff --git a/src/Aspire.Dashboard/Model/ExportHelpers.cs b/src/Aspire.Dashboard/Model/ExportHelpers.cs index 923beaa86c4..12d5e277cab 100644 --- a/src/Aspire.Dashboard/Model/ExportHelpers.cs +++ b/src/Aspire.Dashboard/Model/ExportHelpers.cs @@ -62,12 +62,12 @@ public static ExportResult GetTraceAsJson(OtlpTrace trace, TelemetryRepository t /// Gets a resource as a JSON export result. ///
/// The resource to convert. - /// A function to resolve the resource name for the file name. + /// All resources for resolving relationships and resource names. /// A result containing the JSON representation and suggested file name. - public static ExportResult GetResourceAsJson(ResourceViewModel resource, Func getResourceName) + public static ExportResult GetResourceAsJson(ResourceViewModel resource, IDictionary resourceByName) { - var json = TelemetryExportService.ConvertResourceToJson(resource); - var fileName = $"{getResourceName(resource)}.json"; + var json = TelemetryExportService.ConvertResourceToJson(resource, resourceByName.Values.ToList()); + var fileName = $"{ResourceViewModel.GetResourceName(resource, resourceByName)}.json"; return new ExportResult(json, fileName); } @@ -75,12 +75,12 @@ public static ExportResult GetResourceAsJson(ResourceViewModel resource, Func /// The resource containing environment variables. - /// A function to resolve the resource name for the file name. + /// All resources for resolving resource names. /// A result containing the .env file content and suggested file name. - public static ExportResult GetEnvironmentVariablesAsEnvFile(ResourceViewModel resource, Func getResourceName) + public static ExportResult GetEnvironmentVariablesAsEnvFile(ResourceViewModel resource, IDictionary resourceByName) { var envContent = EnvHelpers.ConvertToEnvFormat(resource.Environment.Select(e => new KeyValuePair(e.Name, e.Value))); - var fileName = $"{getResourceName(resource)}.env"; + var fileName = $"{ResourceViewModel.GetResourceName(resource, resourceByName)}.env"; return new ExportResult(envContent, fileName); } } diff --git a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs index 2c19194b7ed..325386ac05e 100644 --- a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs @@ -73,7 +73,7 @@ public ResourceMenuBuilder( public void AddMenuItems( List menuItems, ResourceViewModel resource, - Func getResourceName, + IDictionary resourceByName, EventCallback onViewDetails, EventCallback commandSelected, Func isCommandExecuting, @@ -99,7 +99,7 @@ public void AddMenuItems( Icon = s_consoleLogsIcon, OnClick = () => { - _navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: getResourceName(resource))); + _navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: ResourceViewModel.GetResourceName(resource, resourceByName))); return Task.CompletedTask; } }); @@ -111,7 +111,7 @@ public void AddMenuItems( Icon = s_bracesIcon, OnClick = async () => { - var result = ExportHelpers.GetResourceAsJson(resource, getResourceName); + var result = ExportHelpers.GetResourceAsJson(resource, resourceByName); await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { DialogService = _dialogService, @@ -132,7 +132,7 @@ await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions Icon = s_exportEnvIcon, OnClick = async () => { - var result = ExportHelpers.GetEnvironmentVariablesAsEnvFile(resource, getResourceName); + var result = ExportHelpers.GetEnvironmentVariablesAsEnvFile(resource, resourceByName); await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { DialogService = _dialogService, @@ -163,7 +163,7 @@ await _aiContextProvider.LaunchAssistantSidebarAsync( }); } - AddTelemetryMenuItems(menuItems, resource, getResourceName); + AddTelemetryMenuItems(menuItems, resource, resourceByName); AddCommandMenuItems(menuItems, resource, commandSelected, isCommandExecuting); @@ -230,7 +230,7 @@ private static MenuButtonItem CreateUrlMenuItem(DisplayedUrl url) }; } - private void AddTelemetryMenuItems(List menuItems, ResourceViewModel resource, Func getResourceName) + private void AddTelemetryMenuItems(List menuItems, ResourceViewModel resource, IDictionary resourceByName) { // Show telemetry menu items if there is telemetry for the resource. var telemetryResource = _telemetryRepository.GetResourceByCompositeName(resource.Name); @@ -247,7 +247,7 @@ private void AddTelemetryMenuItems(List menuItems, ResourceViewM Icon = s_structuredLogsIcon, OnClick = () => { - _navigationManager.NavigateTo(DashboardUrls.StructuredLogsUrl(resource: getResourceName(resource))); + _navigationManager.NavigateTo(DashboardUrls.StructuredLogsUrl(resource: ResourceViewModel.GetResourceName(resource, resourceByName))); return Task.CompletedTask; } }); @@ -260,7 +260,7 @@ private void AddTelemetryMenuItems(List menuItems, ResourceViewM Icon = s_tracesIcon, OnClick = () => { - _navigationManager.NavigateTo(DashboardUrls.TracesUrl(resource: getResourceName(resource))); + _navigationManager.NavigateTo(DashboardUrls.TracesUrl(resource: ResourceViewModel.GetResourceName(resource, resourceByName))); return Task.CompletedTask; } }); @@ -274,7 +274,7 @@ private void AddTelemetryMenuItems(List menuItems, ResourceViewM Icon = s_metricsIcon, OnClick = () => { - _navigationManager.NavigateTo(DashboardUrls.MetricsUrl(resource: getResourceName(resource))); + _navigationManager.NavigateTo(DashboardUrls.MetricsUrl(resource: ResourceViewModel.GetResourceName(resource, resourceByName))); return Task.CompletedTask; } }); diff --git a/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs index 624fc5b5d93..7abf69f5310 100644 --- a/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs @@ -2,10 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Utils; +using Aspire.Shared.Model; namespace Aspire.Dashboard.Model; -public class ResourceSourceViewModel(string value, List? contentAfterValue, string valueToVisualize, string tooltip) +public record LaunchArgument(string Value, bool IsShown); + +internal sealed record ResourceSourceViewModel(string value, List? contentAfterValue, string valueToVisualize, string tooltip) { public string Value { get; } = value; public List? ContentAfterValue { get; } = contentAfterValue; @@ -14,84 +17,70 @@ public class ResourceSourceViewModel(string value, List? content internal static ResourceSourceViewModel? GetSourceViewModel(ResourceViewModel resource) { - var commandLineInfo = GetCommandLineInfo(resource); - - // NOTE project and tools are also executables, so check for those first - if (resource.IsProject() && resource.TryGetProjectPath(out var projectPath)) - { - return CreateResourceSourceViewModel(Path.GetFileName(projectPath), projectPath, commandLineInfo); - } - if (resource.IsTool() && resource.TryGetToolPackage(out var toolPackage)) - { - return CreateResourceSourceViewModel(toolPackage, toolPackage, commandLineInfo); - } - - if (resource.TryGetExecutablePath(out var executablePath)) - { - return CreateResourceSourceViewModel(Path.GetFileName(executablePath), executablePath, commandLineInfo); - } + var properties = resource.GetPropertiesAsDictionary(); - if (resource.TryGetContainerImage(out var containerImage)) + var source = ResourceSource.GetSourceModel(resource.ResourceType, properties); + if (source is null) { - return CreateResourceSourceViewModel(containerImage, containerImage, commandLineInfo); + return null; } - if (resource.Properties.TryGetValue(KnownProperties.Resource.Source, out var property) && property.Value is { HasStringValue: true, StringValue: var value }) + var commandLineInfo = GetCommandLineInfo(resource); + if (commandLineInfo is null) { - return new ResourceSourceViewModel(value, contentAfterValue: null, valueToVisualize: value, tooltip: value); + return new ResourceSourceViewModel( + value: source.Value, + contentAfterValue: null, + valueToVisualize: source.OriginalValue, + tooltip: source.OriginalValue); } - return null; + return new ResourceSourceViewModel( + value: source.Value, + contentAfterValue: commandLineInfo.Arguments, + valueToVisualize: $"{source.OriginalValue} {commandLineInfo.ArgumentsString}", + tooltip: $"{source.OriginalValue} {commandLineInfo.TooltipString}"); + } - static CommandLineInfo? GetCommandLineInfo(ResourceViewModel resourceViewModel) + private static CommandLineInfo? GetCommandLineInfo(ResourceViewModel resourceViewModel) + { + // If the resource contains launch arguments, these project arguments should be shown in place of all executable arguments, + // which include args added by the app host + if (resourceViewModel.TryGetAppArgs(out var launchArguments)) { - // If the resource contains launch arguments, these project arguments should be shown in place of all executable arguments, - // which include args added by the app host - if (resourceViewModel.TryGetAppArgs(out var launchArguments)) + if (launchArguments.IsDefaultOrEmpty) { - if (launchArguments.IsDefaultOrEmpty) - { - return null; - } - - var argumentsString = string.Join(" ", launchArguments); - if (resourceViewModel.TryGetAppArgsSensitivity(out var areArgumentsSensitive)) - { - var arguments = launchArguments - .Select((arg, i) => new LaunchArgument(arg, IsShown: !areArgumentsSensitive[i])) - .ToList(); - - return new CommandLineInfo( - Arguments: arguments, - ArgumentsString: argumentsString, - TooltipString: string.Join(" ", arguments.Select(arg => arg.IsShown - ? arg.Value - : DashboardUIHelpers.GetMaskingText(6).Text))); - } - - return new CommandLineInfo(Arguments: launchArguments.Select(arg => new LaunchArgument(arg, true)).ToList(), ArgumentsString: argumentsString, TooltipString: argumentsString); + return null; } - if (resourceViewModel.TryGetExecutableArguments(out var executableArguments) && !resourceViewModel.IsProject()) + var argumentsString = string.Join(" ", launchArguments); + if (resourceViewModel.TryGetAppArgsSensitivity(out var areArgumentsSensitive)) { - var arguments = executableArguments.IsDefaultOrEmpty ? [] : executableArguments.Select(arg => new LaunchArgument(arg, true)).ToList(); - var argumentsString = string.Join(" ", executableArguments); - - return new CommandLineInfo(Arguments: arguments, ArgumentsString: argumentsString, TooltipString: argumentsString); + var arguments = launchArguments + .Select((arg, i) => new LaunchArgument(arg, IsShown: !areArgumentsSensitive[i])) + .ToList(); + + return new CommandLineInfo( + Arguments: arguments, + ArgumentsString: argumentsString, + TooltipString: string.Join(" ", arguments.Select(arg => arg.IsShown + ? arg.Value + : DashboardUIHelpers.GetMaskingText(6).Text))); } - return null; + return new CommandLineInfo(Arguments: launchArguments.Select(arg => new LaunchArgument(arg, true)).ToList(), ArgumentsString: argumentsString, TooltipString: argumentsString); } - static ResourceSourceViewModel CreateResourceSourceViewModel(string value, string path, CommandLineInfo? commandLineInfo) + if (resourceViewModel.TryGetExecutableArguments(out var executableArguments) && !resourceViewModel.IsProject()) { - return commandLineInfo is not null - ? new ResourceSourceViewModel(value: value, contentAfterValue: commandLineInfo.Arguments, valueToVisualize: $"{path} {commandLineInfo.ArgumentsString}", tooltip: $"{path} {commandLineInfo.TooltipString}") - : new ResourceSourceViewModel(value: value, contentAfterValue: null, valueToVisualize: path, tooltip: path); + var arguments = executableArguments.IsDefaultOrEmpty ? [] : executableArguments.Select(arg => new LaunchArgument(arg, true)).ToList(); + var argumentsString = string.Join(" ", executableArguments); + + return new CommandLineInfo(Arguments: arguments, ArgumentsString: argumentsString, TooltipString: argumentsString); } + + return null; } private record CommandLineInfo(List Arguments, string ArgumentsString, string TooltipString); } - -public record LaunchArgument(string Value, bool IsShown); diff --git a/src/Aspire.Dashboard/Model/ResourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceViewModel.cs index fbe671c39ad..c9c371ae6eb 100644 --- a/src/Aspire.Dashboard/Model/ResourceViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceViewModel.cs @@ -154,21 +154,16 @@ public bool IsResourceHidden(bool showHiddenResources) ?? Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy; } - public static string GetResourceName(ResourceViewModel resource, IDictionary allResources, bool showHiddenResources = false) + public static string GetResourceName(ResourceViewModel resource, IDictionary allResources) { return GetResourceName(resource, allResources.Values); } - public static string GetResourceName(ResourceViewModel resource, IEnumerable allResources, bool showHiddenResources = false) + public static string GetResourceName(ResourceViewModel resource, IEnumerable allResources) { var count = 0; foreach (var item in allResources) { - if (item.IsResourceHidden(showHiddenResources)) - { - continue; - } - if (string.Equals(item.DisplayName, resource.DisplayName, StringComparisons.ResourceName)) { count++; diff --git a/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs b/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs index 5e7b62992b5..6684e71f2dd 100644 --- a/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs +++ b/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs @@ -9,6 +9,26 @@ namespace Aspire.Dashboard.Model; internal static class ResourceViewModelExtensions { + /// + /// Converts the resource properties to a dictionary of string values. + /// This is used to provide a consistent interface for code that works with both + /// ResourceViewModel (Dashboard) and ResourceSnapshot (CLI). + /// + public static IReadOnlyDictionary GetPropertiesAsDictionary(this ResourceViewModel resource) + { + var result = new Dictionary(StringComparer.Ordinal); + + foreach (var (key, property) in resource.Properties) + { + if (property.Value.TryConvertToString(out var stringValue)) + { + result[key] = stringValue; + } + } + + return result; + } + public static bool IsContainer(this ResourceViewModel resource) { return StringComparers.ResourceType.Equals(resource.ResourceType, KnownResourceTypes.Container); diff --git a/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs b/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs index 0239624a88c..ee006d8c3bf 100644 --- a/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs +++ b/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs @@ -23,6 +23,7 @@ namespace Aspire.Dashboard.Model.Serialization; [JsonSerializable(typeof(ResourceHealthReportJson))] [JsonSerializable(typeof(ResourcePropertyJson))] [JsonSerializable(typeof(ResourceRelationshipJson))] +[JsonSerializable(typeof(ResourceCommandJson))] internal sealed partial class ResourceJsonSerializerContext : JsonSerializerContext { /// diff --git a/src/Aspire.Dashboard/Model/TelemetryExportService.cs b/src/Aspire.Dashboard/Model/TelemetryExportService.cs index dc52d23c3fc..f8f3e396a0e 100644 --- a/src/Aspire.Dashboard/Model/TelemetryExportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryExportService.cs @@ -143,7 +143,7 @@ private static void ExportResources(ZipArchive archive, List foreach (var resource in resources) { var resourceName = ResourceViewModel.GetResourceName(resource, resources); - var resourceJson = ConvertResourceToJson(resource); + var resourceJson = ConvertResourceToJson(resource, resources); var entry = archive.CreateEntry($"resources/{SanitizeFileName(resourceName)}.json"); using var entryStream = entry.Open(); using var writer = new StreamWriter(entryStream, Encoding.UTF8); @@ -676,8 +676,32 @@ private static string SanitizeFileName(string name) return sanitized.ToString(); } - internal static string ConvertResourceToJson(ResourceViewModel resource) + internal static string ConvertResourceToJson(ResourceViewModel resource, IReadOnlyList allResources) { + // Build relationships by matching DisplayName and filtering out hidden resources + ResourceRelationshipJson[]? relationshipsJson = null; + if (resource.Relationships.Length > 0) + { + var relationships = new List(); + foreach (var relationship in resource.Relationships) + { + var matches = allResources + .Where(r => string.Equals(r.DisplayName, relationship.ResourceName, StringComparisons.ResourceName)) + .Where(r => r.KnownState != KnownResourceState.Hidden) + .ToList(); + + foreach (var match in matches) + { + relationships.Add(new ResourceRelationshipJson + { + Type = relationship.Type, + ResourceName = ResourceViewModel.GetResourceName(match, allResources) + }); + } + } + relationshipsJson = relationships.ToArray(); + } + var resourceJson = new ResourceJson { Name = resource.Name, @@ -707,7 +731,7 @@ internal static string ConvertResourceToJson(ResourceViewModel resource) }).ToArray() : null, Environment = resource.Environment.Length > 0 - ? resource.Environment.Select(e => new ResourceEnvironmentVariableJson + ? resource.Environment.Where(e => e.FromSpec).Select(e => new ResourceEnvironmentVariableJson { Name = e.Name, Value = e.Value @@ -729,13 +753,17 @@ internal static string ConvertResourceToJson(ResourceViewModel resource) Value = p.Value.Value.TryConvertToString(out var value) ? value : null }).ToArray() : null, - Relationships = resource.Relationships.Length > 0 - ? resource.Relationships.Select(r => new ResourceRelationshipJson - { - Type = r.Type, - ResourceName = r.ResourceName - }).ToArray() - : null + Relationships = relationshipsJson, + Commands = resource.Commands.Length > 0 + ? resource.Commands + .Where(c => c.State == CommandViewModelState.Enabled) + .Select(c => new ResourceCommandJson + { + Name = c.Name, + Description = c.GetDisplayDescription() + }).ToArray() + : null, + Source = ResourceSourceViewModel.GetSourceViewModel(resource)?.Value }; return JsonSerializer.Serialize(resourceJson, ResourceJsonSerializerContext.IndentedOptions); diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs index 30b49118b3a..4836d473b9a 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.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.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using Aspire.Dashboard.Model; using Microsoft.Extensions.DependencyInjection; @@ -1061,6 +1062,24 @@ internal static string GetResolvedResourceName(this IResource resource) return names[0]; } + /// + /// Attempts to get the DCP instances for the specified resource. + /// + /// The resource to get the DCP instances from. + /// When this method returns, contains the DCP instances if found and not empty; otherwise, an empty array. + /// if the resource has a non-empty DCP instances annotation; otherwise, . + internal static bool TryGetInstances(this IResource resource, out ImmutableArray instances) + { + if (resource.TryGetLastAnnotation(out var annotation) && !annotation.Instances.IsEmpty) + { + instances = annotation.Instances; + return true; + } + + instances = []; + return false; + } + /// /// Gets resolved names for the specified resource. /// DCP resources are given a unique suffix as part of the complete name. We want to use that value. @@ -1068,9 +1087,9 @@ internal static string GetResolvedResourceName(this IResource resource) /// internal static string[] GetResolvedResourceNames(this IResource resource) { - if (resource.TryGetLastAnnotation(out var replicaAnnotation) && !replicaAnnotation.Instances.IsEmpty) + if (resource.TryGetInstances(out var instances)) { - return replicaAnnotation.Instances.Select(i => i.Name).ToArray(); + return instances.Select(i => i.Name).ToArray(); } else { diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index c52cd8741a0..267731d0777 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -339,7 +339,17 @@ public async Task> GetResourceSnapshotsAsync(Cancellation continue; } - if (notificationService.TryGetCurrentState(resource.Name, out var resourceEvent)) + foreach (var instanceName in resource.GetResolvedResourceNames()) + { + await AddResult(instanceName).ConfigureAwait(false); + } + } + + return results; + + async Task AddResult(string resourceName) + { + if (notificationService.TryGetCurrentState(resourceName, out var resourceEvent)) { var snapshot = await CreateResourceSnapshotFromEventAsync(resourceEvent, cancellationToken).ConfigureAwait(false); if (snapshot is not null) @@ -348,8 +358,6 @@ public async Task> GetResourceSnapshotsAsync(Cancellation } } } - - return results; } /// @@ -406,14 +414,19 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu } } - // Build endpoints from URLs - var endpoints = snapshot.Urls + // Build URLs + var urls = snapshot.Urls .Where(u => !u.IsInactive && !string.IsNullOrEmpty(u.Url)) - .Select(u => new ResourceSnapshotEndpoint + .Select(u => new ResourceSnapshotUrl { Name = u.Name ?? "default", Url = u.Url, - IsInternal = u.IsInternal + IsInternal = u.IsInternal, + DisplayProperties = new ResourceSnapshotUrlDisplayProperties + { + DisplayName = string.IsNullOrEmpty(u.DisplayProperties.DisplayName) ? null : u.DisplayProperties.DisplayName, + SortOrder = u.DisplayProperties.SortOrder + } }) .ToArray(); @@ -448,6 +461,16 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu }) .ToArray(); + // Build environment variables + var environmentVariables = snapshot.EnvironmentVariables + .Select(e => new ResourceSnapshotEnvironmentVariable + { + Name = e.Name, + Value = e.Value, + IsFromSpec = e.IsFromSpec + }) + .ToArray(); + // Build properties dictionary from ResourcePropertySnapshot // Redact sensitive property values to avoid leaking secrets var properties = new Dictionary(); @@ -472,10 +495,22 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu properties[prop.Name] = stringValue; } + // Build commands + var commands = snapshot.Commands + .Select(c => new ResourceSnapshotCommand + { + Name = c.Name, + DisplayName = c.DisplayName, + Description = c.DisplayDescription, + State = c.State.ToString() + }) + .ToArray(); + return new ResourceSnapshot { - Name = resource.Name, - Type = snapshot.ResourceType, + Name = resourceEvent.ResourceId, + DisplayName = resource.Name, + ResourceType = snapshot.ResourceType, State = snapshot.State?.Text, StateStyle = snapshot.State?.Style, HealthStatus = snapshot.HealthStatus?.ToString(), @@ -483,12 +518,14 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu CreatedAt = snapshot.CreationTimeStamp, StartedAt = snapshot.StartTimeStamp, StoppedAt = snapshot.StopTimeStamp, - Endpoints = endpoints, + Urls = urls, Relationships = relationships, HealthReports = healthReports, Volumes = volumes, + EnvironmentVariables = environmentVariables, Properties = properties, - McpServer = mcpServer + McpServer = mcpServer, + Commands = commands }; } diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index ddef85facb2..bf89a5ee320 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -9,6 +9,7 @@ namespace Aspire.Cli.Backchannel; namespace Aspire.Hosting.Backchannel; #endif +using System.Diagnostics; using System.Text.Json; using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; @@ -516,6 +517,7 @@ internal sealed class DashboardMcpConnectionInfo /// Represents a snapshot of a resource in the application model, suitable for RPC communication. /// Designed to be extensible - new fields can be added without breaking existing consumers. /// +[DebuggerDisplay("Name = {Name}, ResourceType = {ResourceType}, State = {State}, Properties = {Properties.Count}")] internal sealed class ResourceSnapshot { /// @@ -523,10 +525,26 @@ internal sealed class ResourceSnapshot /// public required string Name { get; init; } + /// + /// Gets the display name of the resource. + /// + public string? DisplayName { get; init; } + + // ResourceType can't be required because older versions of the backchannel may not set it. /// /// Gets the type of the resource (e.g., "Project", "Container", "Executable"). /// - public required string Type { get; init; } + public string? ResourceType { get; init; } + + /// + /// Gets the type of the resource (e.g., "Project", "Container", "Executable"). + /// + [Obsolete("Use ResourceType property instead.")] + public string? Type + { + get => ResourceType; + init => ResourceType = value; + } /// /// Gets the current state of the resource (e.g., "Running", "Stopped", "Starting"). @@ -564,9 +582,9 @@ internal sealed class ResourceSnapshot public DateTimeOffset? StoppedAt { get; init; } /// - /// Gets the endpoints exposed by this resource. + /// Gets the URLs exposed by this resource. /// - public ResourceSnapshotEndpoint[] Endpoints { get; init; } = []; + public ResourceSnapshotUrl[] Urls { get; init; } = []; /// /// Gets the relationships to other resources. @@ -583,6 +601,11 @@ internal sealed class ResourceSnapshot /// public ResourceSnapshotVolume[] Volumes { get; init; } = []; + /// + /// Gets the environment variables for this resource. + /// + public ResourceSnapshotEnvironmentVariable[] EnvironmentVariables { get; init; } = []; + /// /// Gets additional properties as key-value pairs. /// This allows for extensibility without changing the schema. @@ -593,15 +616,48 @@ internal sealed class ResourceSnapshot /// Gets the MCP server information if the resource exposes an MCP endpoint. /// public ResourceSnapshotMcpServer? McpServer { get; init; } + + /// + /// Gets the commands available for this resource. + /// + public ResourceSnapshotCommand[] Commands { get; init; } = []; } /// -/// Represents an endpoint exposed by a resource. +/// Represents a command available for a resource. /// -internal sealed class ResourceSnapshotEndpoint +[DebuggerDisplay("Name = {Name}, State = {State}")] +internal sealed class ResourceSnapshotCommand { /// - /// Gets the endpoint name (e.g., "http", "https", "tcp"). + /// Gets the command name (e.g., "resource-start", "resource-stop", "resource-restart"). + /// + public required string Name { get; init; } + + /// + /// Gets the display name of the command. + /// + public string? DisplayName { get; init; } + + /// + /// Gets the description of the command. + /// + public string? Description { get; init; } + + /// + /// Gets the state of the command (e.g., "Enabled", "Disabled", "Hidden"). + /// + public required string State { get; init; } +} + +/// +/// Represents a URL exposed by a resource. +/// +[DebuggerDisplay("Name = {Name}, Url = {Url}")] +internal sealed class ResourceSnapshotUrl +{ + /// + /// Gets the URL name (e.g., "http", "https", "tcp"). /// public required string Name { get; init; } @@ -611,14 +667,37 @@ internal sealed class ResourceSnapshotEndpoint public required string Url { get; init; } /// - /// Gets whether this is an internal endpoint. + /// Gets whether this is an internal URL. /// public bool IsInternal { get; init; } + + /// + /// Gets the display properties for the URL. + /// + public ResourceSnapshotUrlDisplayProperties? DisplayProperties { get; init; } +} + +/// +/// Represents display properties for a URL. +/// +[DebuggerDisplay("DisplayName = {DisplayName}, SortOrder = {SortOrder}")] +internal sealed class ResourceSnapshotUrlDisplayProperties +{ + /// + /// Gets the display name of the URL. + /// + public string? DisplayName { get; init; } + + /// + /// Gets the sort order for display. Higher numbers are displayed first. + /// + public int SortOrder { get; init; } } /// /// Represents a relationship to another resource. /// +[DebuggerDisplay("ResourceName = {ResourceName}, Type = {Type}")] internal sealed class ResourceSnapshotRelationship { /// @@ -635,6 +714,7 @@ internal sealed class ResourceSnapshotRelationship /// /// Represents a health report for a resource. /// +[DebuggerDisplay("Name = {Name}, Status = {Status}")] internal sealed class ResourceSnapshotHealthReport { /// @@ -661,6 +741,7 @@ internal sealed class ResourceSnapshotHealthReport /// /// Represents a volume mounted to a resource. /// +[DebuggerDisplay("Source = {Source}, Target = {Target}")] internal sealed class ResourceSnapshotVolume { /// @@ -684,9 +765,32 @@ internal sealed class ResourceSnapshotVolume public bool IsReadOnly { get; init; } } +/// +/// Represents an environment variable for a resource. +/// +[DebuggerDisplay("Name = {Name}, Value = {Value}")] +internal sealed class ResourceSnapshotEnvironmentVariable +{ + /// + /// Gets the name of the environment variable. + /// + public required string Name { get; init; } + + /// + /// Gets the value of the environment variable. + /// + public string? Value { get; init; } + + /// + /// Gets whether this environment variable is from the resource specification. + /// + public bool IsFromSpec { get; init; } +} + /// /// Represents MCP server information for a resource. /// +[DebuggerDisplay("EndpointUrl = {EndpointUrl}")] internal sealed class ResourceSnapshotMcpServer { /// diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 88a90c892c2..1d6b253359c 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -1867,12 +1867,12 @@ private void PrepareContainers() /// private static DcpInstance GetDcpInstance(IResource resource, int instanceIndex) { - if (!resource.TryGetLastAnnotation(out var replicaAnnotation)) + if (!resource.TryGetInstances(out var instances)) { throw new DistributedApplicationException($"Couldn't find required {nameof(DcpInstancesAnnotation)} annotation on resource {resource.Name}."); } - foreach (var instance in replicaAnnotation.Instances) + foreach (var instance in instances) { if (instance.Index == instanceIndex) { diff --git a/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs b/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs index d810e9e662e..bf24f738865 100644 --- a/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs +++ b/src/Aspire.Hosting/Dcp/DcpNameGenerator.cs @@ -27,7 +27,7 @@ public DcpNameGenerator(IConfiguration configuration, IOptions optio public void EnsureDcpInstancesPopulated(IResource resource) { - if (resource.TryGetLastAnnotation(out _)) + if (resource.TryGetInstances(out _)) { return; } diff --git a/src/Aspire.Hosting/DistributedApplication.cs b/src/Aspire.Hosting/DistributedApplication.cs index 03333885f8d..af4597eb1bc 100644 --- a/src/Aspire.Hosting/DistributedApplication.cs +++ b/src/Aspire.Hosting/DistributedApplication.cs @@ -609,18 +609,9 @@ public List Resources var results = new List(app._model.Resources.Count); foreach (var resource in app._model.Resources) { - resource.TryGetLastAnnotation(out var dcpInstancesAnnotation); - if (dcpInstancesAnnotation is not null) + foreach (var instanceName in resource.GetResolvedResourceNames()) { - foreach (var instance in dcpInstancesAnnotation.Instances) - { - app.ResourceNotifications.TryGetCurrentState(instance.Name, out var resourceEvent); - results.Add(new() { Resource = resource, Snapshot = resourceEvent?.Snapshot }); - } - } - else - { - app.ResourceNotifications.TryGetCurrentState(resource.Name, out var resourceEvent); + app.ResourceNotifications.TryGetCurrentState(instanceName, out var resourceEvent); results.Add(new() { Resource = resource, Snapshot = resourceEvent?.Snapshot }); } } diff --git a/src/Aspire.Dashboard/Utils/DashboardUrls.cs b/src/Shared/DashboardUrls.cs similarity index 63% rename from src/Aspire.Dashboard/Utils/DashboardUrls.cs rename to src/Shared/DashboardUrls.cs index 73f530a2dee..91c02259f14 100644 --- a/src/Aspire.Dashboard/Utils/DashboardUrls.cs +++ b/src/Shared/DashboardUrls.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; -using Microsoft.AspNetCore.WebUtilities; +using System.Text.Encodings.Web; namespace Aspire.Dashboard.Utils; @@ -21,23 +21,23 @@ public static string ResourcesUrl(string? resource = null, string? view = null, var url = $"/{ResourcesBasePath}"; if (resource != null) { - url = QueryHelpers.AddQueryString(url, "resource", resource); + url = AddQueryString(url, "resource", resource); } if (view != null) { - url = QueryHelpers.AddQueryString(url, "view", view); + url = AddQueryString(url, "view", view); } if (hiddenTypes != null) { - url = QueryHelpers.AddQueryString(url, "hiddenTypes", hiddenTypes); + url = AddQueryString(url, "hiddenTypes", hiddenTypes); } if (hiddenStates != null) { - url = QueryHelpers.AddQueryString(url, "hiddenStates", hiddenStates); + url = AddQueryString(url, "hiddenStates", hiddenStates); } if (hiddenHealthStates != null) { - url = QueryHelpers.AddQueryString(url, "hiddenHealthStates", hiddenHealthStates); + url = AddQueryString(url, "hiddenHealthStates", hiddenHealthStates); } return url; @@ -64,19 +64,19 @@ public static string MetricsUrl(string? resource = null, string? meter = null, s if (meter is not null) { // Meter and instrument must be querystring parameters because it's valid for the name to contain forward slashes. - url = QueryHelpers.AddQueryString(url, "meter", meter); + url = AddQueryString(url, "meter", meter); if (instrument is not null) { - url = QueryHelpers.AddQueryString(url, "instrument", instrument); + url = AddQueryString(url, "instrument", instrument); } } if (duration != null) { - url = QueryHelpers.AddQueryString(url, "duration", duration.Value.ToString(CultureInfo.InvariantCulture)); + url = AddQueryString(url, "duration", duration.Value.ToString(CultureInfo.InvariantCulture)); } if (view != null) { - url = QueryHelpers.AddQueryString(url, "view", view); + url = AddQueryString(url, "view", view); } return url; @@ -91,26 +91,26 @@ public static string StructuredLogsUrl(string? resource = null, string? logLevel } if (logLevel != null) { - url = QueryHelpers.AddQueryString(url, "logLevel", logLevel); + url = AddQueryString(url, "logLevel", logLevel); } if (filters != null) { // Filters contains : and + characters. These are escaped when they're not needed to, // which makes the URL harder to read. Consider having a custom method for appending // query string here that uses an encoder that doesn't encode those characters. - url = QueryHelpers.AddQueryString(url, "filters", filters); + url = AddQueryString(url, "filters", filters); } if (traceId != null) { - url = QueryHelpers.AddQueryString(url, "traceId", traceId); + url = AddQueryString(url, "traceId", traceId); } if (spanId != null) { - url = QueryHelpers.AddQueryString(url, "spanId", spanId); + url = AddQueryString(url, "spanId", spanId); } if (logEntryId != null) { - url = QueryHelpers.AddQueryString(url, "logEntryId", logEntryId.Value.ToString(CultureInfo.InvariantCulture)); + url = AddQueryString(url, "logEntryId", logEntryId.Value.ToString(CultureInfo.InvariantCulture)); } return url; @@ -125,14 +125,14 @@ public static string TracesUrl(string? resource = null, string? type = null, str } if (type != null) { - url = QueryHelpers.AddQueryString(url, "type", type); + url = AddQueryString(url, "type", type); } if (filters != null) { // Filters contains : and + characters. These are escaped when they're not needed to, // which makes the URL harder to read. Consider having a custom method for appending // query string here that uses an encoder that doesn't encode those characters. - url = QueryHelpers.AddQueryString(url, "filters", filters); + url = AddQueryString(url, "filters", filters); } return url; @@ -143,7 +143,7 @@ public static string TraceDetailUrl(string traceId, string? spanId = null) var url = $"/{TracesBasePath}/detail/{Uri.EscapeDataString(traceId)}"; if (spanId != null) { - url = QueryHelpers.AddQueryString(url, "spanId", spanId); + url = AddQueryString(url, "spanId", spanId); } return url; @@ -154,11 +154,11 @@ public static string LoginUrl(string? returnUrl = null, string? token = null) var url = $"/{LoginBasePath}"; if (returnUrl != null) { - url = QueryHelpers.AddQueryString(url, "returnUrl", returnUrl); + url = AddQueryString(url, "returnUrl", returnUrl); } if (token != null) { - url = QueryHelpers.AddQueryString(url, "t", token); + url = AddQueryString(url, "t", token); } return url; @@ -167,9 +167,35 @@ public static string LoginUrl(string? returnUrl = null, string? token = null) public static string SetLanguageUrl(string language, string redirectUrl) { var url = "/api/set-language"; - url = QueryHelpers.AddQueryString(url, "language", language); - url = QueryHelpers.AddQueryString(url, "redirectUrl", redirectUrl); + url = AddQueryString(url, "language", language); + url = AddQueryString(url, "redirectUrl", redirectUrl); return url; } + + /// + /// Combines a base URL with a path. + /// + /// The base URL (e.g., "https://localhost:5000"). + /// The path (e.g., "/?resource=myapp"). + /// The combined URL. + public static string CombineUrl(string baseUrl, string path) + { + // Remove trailing slash from base URL and leading slash from path to avoid double slashes + var trimmedBase = baseUrl.TrimEnd('/'); + var trimmedPath = path.TrimStart('/'); + + return $"{trimmedBase}/{trimmedPath}"; + } + + /// + /// Adds a query string parameter to a URL. + /// This implementation matches the behavior of QueryHelpers.AddQueryString from ASP.NET Core, + /// which uses UrlEncoder.Default that doesn't encode certain characters like ! and @. + /// + private static string AddQueryString(string url, string name, string value) + { + var separator = url.Contains('?') ? '&' : '?'; + return $"{url}{separator}{UrlEncoder.Default.Encode(name)}={UrlEncoder.Default.Encode(value)}"; + } } diff --git a/src/Shared/Model/ResourceSourceViewModel.cs b/src/Shared/Model/ResourceSourceViewModel.cs new file mode 100644 index 00000000000..7b572a8e99b --- /dev/null +++ b/src/Shared/Model/ResourceSourceViewModel.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Model; + +namespace Aspire.Shared.Model; + +internal record ResourceSource(string Value, string OriginalValue) +{ + public static ResourceSource? GetSourceModel(string? resourceType, IReadOnlyDictionary properties) + { + // NOTE project and tools are also executables, so check for those first + if (StringComparers.ResourceType.Equals(resourceType, KnownResourceTypes.Project) && + properties.TryGetValue(KnownProperties.Project.Path, out var projectPath) && + !string.IsNullOrEmpty(projectPath)) + { + return new ResourceSource(Path.GetFileName(projectPath), projectPath); + } + + if (StringComparers.ResourceType.Equals(resourceType, KnownResourceTypes.Tool) && + properties.TryGetValue(KnownProperties.Tool.Package, out var toolPackage) && + !string.IsNullOrEmpty(toolPackage)) + { + return new ResourceSource(toolPackage, toolPackage); + } + + if (properties.TryGetValue(KnownProperties.Executable.Path, out var executablePath) && + !string.IsNullOrEmpty(executablePath)) + { + return new ResourceSource(Path.GetFileName(executablePath), executablePath); + } + + if (properties.TryGetValue(KnownProperties.Container.Image, out var containerImage) && + !string.IsNullOrEmpty(containerImage)) + { + return new ResourceSource(containerImage, containerImage); + } + + if (properties.TryGetValue(KnownProperties.Resource.Source, out var source) && + !string.IsNullOrEmpty(source)) + { + return new ResourceSource(source, source); + } + + return null; + } +} diff --git a/src/Shared/Model/Serialization/ResourceJson.cs b/src/Shared/Model/Serialization/ResourceJson.cs index 89247f103f0..fc59ee520fa 100644 --- a/src/Shared/Model/Serialization/ResourceJson.cs +++ b/src/Shared/Model/Serialization/ResourceJson.cs @@ -56,6 +56,11 @@ internal sealed class ResourceJson /// public DateTimeOffset? StopTimestamp { get; set; } + /// + /// The source of the resource (e.g., project path, container image, executable path). + /// + public string? Source { get; set; } + /// /// The exit code if the resource has exited. /// @@ -66,6 +71,11 @@ internal sealed class ResourceJson /// public string? HealthStatus { get; set; } + /// + /// The URL to the resource in the Aspire Dashboard. + /// + public string? DashboardUrl { get; set; } + /// /// The URLs/endpoints associated with the resource. /// @@ -95,6 +105,11 @@ internal sealed class ResourceJson /// The relationships of the resource. /// public ResourceRelationshipJson[]? Relationships { get; set; } + + /// + /// The commands available for the resource. + /// + public ResourceCommandJson[]? Commands { get; set; } } /// @@ -232,3 +247,19 @@ internal sealed class ResourceRelationshipJson /// public string? ResourceName { get; set; } } + +/// +/// Represents a command in JSON format. +/// +internal sealed class ResourceCommandJson +{ + /// + /// The name of the command. + /// + public string? Name { get; set; } + + /// + /// The description of the command. + /// + public string? Description { get; set; } +} diff --git a/tests/Aspire.Dashboard.Tests/Model/ExportHelpersTests.cs b/tests/Aspire.Dashboard.Tests/Model/ExportHelpersTests.cs index 8f3b3455115..500242982aa 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ExportHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ExportHelpersTests.cs @@ -22,11 +22,13 @@ public void GetResourceAsJson_ReturnsExpectedJson() environment: [new EnvironmentVariableViewModel("MY_VAR", "my-value", fromSpec: false)], relationships: [new RelationshipViewModel("dependency", "Reference")]); + var resourceByName = new Dictionary(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource }; + // Act - var result = ExportHelpers.GetResourceAsJson(resource, r => r.Name); + var result = ExportHelpers.GetResourceAsJson(resource, resourceByName); // Assert - Assert.Equal("test-resource.json", result.FileName); + Assert.Equal("Test Resource.json", result.FileName); Assert.NotNull(result.Content); } @@ -43,11 +45,13 @@ public void GetEnvironmentVariablesAsEnvFile_ReturnsExpectedResult() new EnvironmentVariableViewModel("MY_VAR", "my-value", fromSpec: false) ]); + var resourceByName = new Dictionary(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource }; + // Act - var result = ExportHelpers.GetEnvironmentVariablesAsEnvFile(resource, r => r.Name); + var result = ExportHelpers.GetEnvironmentVariablesAsEnvFile(resource, resourceByName); // Assert - Assert.Equal("test-resource.env", result.FileName); + Assert.Equal("Test Resource.env", result.FileName); Assert.Contains("MY_VAR=my-value", result.Content); } } diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs index 65b7b78de7e..3decb32518c 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs @@ -62,7 +62,7 @@ public void AddMenuItems_NoTelemetry_NoTelemetryItems() resourceMenuBuilder.AddMenuItems( menuItems, resource, - r => r.Name, + new Dictionary(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource }, EventCallback.Empty, EventCallback.Empty, (_, _) => false, @@ -113,7 +113,7 @@ public void AddMenuItems_UninstrumentedPeer_TraceItem() resourceMenuBuilder.AddMenuItems( menuItems, resource, - r => r.Name, + new Dictionary(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource }, EventCallback.Empty, EventCallback.Empty, (_, _) => false, @@ -164,7 +164,7 @@ public void AddMenuItems_HasTelemetry_TelemetryItems() resourceMenuBuilder.AddMenuItems( menuItems, resource, - r => r.Name, + new Dictionary(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource }, EventCallback.Empty, EventCallback.Empty, (_, _) => false, diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs index 93d5ee40955..e99812b80d2 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceSourceViewModelTests.cs @@ -9,11 +9,11 @@ namespace Aspire.Dashboard.Tests.Model; -public class ResourceSourceViewModelTests +public sealed class ResourceSourceViewModelTests { [Theory] [MemberData(nameof(ResourceSourceViewModel_ReturnsCorrectValue_TestData))] - public void ResourceSourceViewModel_ReturnsCorrectValue(TestData testData, ResourceSourceViewModel? expected) + public void ResourceSourceViewModel_ReturnsCorrectValue(TestData testData, ExpectedData? expected) { var properties = new Dictionary(); AddStringProperty(KnownProperties.Executable.Path, testData.ExecutablePath); @@ -49,9 +49,23 @@ public void ResourceSourceViewModel_ReturnsCorrectValue(TestData testData, Resou { Assert.NotNull(actual); Assert.Equal(expected.Value, actual.Value); - Assert.Equal(expected.ContentAfterValue, actual.ContentAfterValue); Assert.Equal(expected.ValueToVisualize, actual.ValueToVisualize); Assert.Equal(expected.Tooltip, actual.Tooltip); + + if (expected.ContentAfterValue is null) + { + Assert.Null(actual.ContentAfterValue); + } + else + { + Assert.NotNull(actual.ContentAfterValue); + Assert.Equal(expected.ContentAfterValue.Count, actual.ContentAfterValue.Count); + for (var i = 0; i < expected.ContentAfterValue.Count; i++) + { + Assert.Equal(expected.ContentAfterValue[i].Value, actual.ContentAfterValue[i].Value); + Assert.Equal(expected.ContentAfterValue[i].IsShown, actual.ContentAfterValue[i].IsShown); + } + } } void AddStringProperty(string propertyName, string? propertyValue) @@ -60,9 +74,9 @@ void AddStringProperty(string propertyName, string? propertyValue) } } - public static TheoryData ResourceSourceViewModel_ReturnsCorrectValue_TestData() + public static TheoryData ResourceSourceViewModel_ReturnsCorrectValue_TestData() { - var data = new TheoryData(); + var data = new TheoryData(); // Project with app arguments data.Add(new TestData( @@ -74,11 +88,11 @@ void AddStringProperty(string propertyName, string? propertyValue) ProjectPath: "path/to/project", ContainerImage: null, SourceProperty: null), - new ResourceSourceViewModel( - value: "project", - contentAfterValue: [new LaunchArgument("arg2", true)], - valueToVisualize: "path/to/project arg2", - tooltip: "path/to/project arg2")); + new ExpectedData( + Value: "project", + ContentAfterValue: [new ExpectedLaunchArgument("arg2", true)], + ValueToVisualize: "path/to/project arg2", + Tooltip: "path/to/project arg2")); var maskingText = DashboardUIHelpers.GetMaskingText(6).Text; // Project with app arguments, as well as a secret (format argument) @@ -91,11 +105,11 @@ void AddStringProperty(string propertyName, string? propertyValue) ProjectPath: "path/to/project", ContainerImage: null, SourceProperty: null), - new ResourceSourceViewModel( - value: "project", - contentAfterValue: [new LaunchArgument("arg2", true), new LaunchArgument("--key", true), new LaunchArgument("secret", false), new LaunchArgument("secret2", false), new LaunchArgument("notsecret", true)], - valueToVisualize: "path/to/project arg2 --key secret secret2 notsecret", - tooltip: $"path/to/project arg2 --key {maskingText} {maskingText} notsecret")); + new ExpectedData( + Value: "project", + ContentAfterValue: [new ExpectedLaunchArgument("arg2", true), new ExpectedLaunchArgument("--key", true), new ExpectedLaunchArgument("secret", false), new ExpectedLaunchArgument("secret2", false), new ExpectedLaunchArgument("notsecret", true)], + ValueToVisualize: "path/to/project arg2 --key secret secret2 notsecret", + Tooltip: $"path/to/project arg2 --key {maskingText} {maskingText} notsecret")); // Project without executable arguments data.Add(new TestData( @@ -107,11 +121,11 @@ void AddStringProperty(string propertyName, string? propertyValue) ProjectPath: "path/to/project", ContainerImage: null, SourceProperty: null), - new ResourceSourceViewModel( - value: "project", - contentAfterValue: null, - valueToVisualize: "path/to/project", - tooltip: "path/to/project")); + new ExpectedData( + Value: "project", + ContentAfterValue: null, + ValueToVisualize: "path/to/project", + Tooltip: "path/to/project")); // Executable with arguments data.Add(new TestData( @@ -123,11 +137,11 @@ void AddStringProperty(string propertyName, string? propertyValue) ProjectPath: null, ContainerImage: null, SourceProperty: null), - new ResourceSourceViewModel( - value: "executable", - contentAfterValue: [new LaunchArgument("arg1", true), new LaunchArgument("arg2", true)], - valueToVisualize: "path/to/executable arg1 arg2", - tooltip: "path/to/executable arg1 arg2")); + new ExpectedData( + Value: "executable", + ContentAfterValue: [new ExpectedLaunchArgument("arg1", true), new ExpectedLaunchArgument("arg2", true)], + ValueToVisualize: "path/to/executable arg1 arg2", + Tooltip: "path/to/executable arg1 arg2")); // Container image data.Add(new TestData( @@ -139,11 +153,11 @@ void AddStringProperty(string propertyName, string? propertyValue) ProjectPath: null, ContainerImage: "my-container-image", SourceProperty: null), - new ResourceSourceViewModel( - value: "my-container-image", - contentAfterValue: null, - valueToVisualize: "my-container-image", - tooltip: "my-container-image")); + new ExpectedData( + Value: "my-container-image", + ContentAfterValue: null, + ValueToVisualize: "my-container-image", + Tooltip: "my-container-image")); // Resource source property data.Add(new TestData( @@ -155,11 +169,11 @@ void AddStringProperty(string propertyName, string? propertyValue) ProjectPath: null, ContainerImage: null, SourceProperty: "source-value"), - new ResourceSourceViewModel( - value: "source-value", - contentAfterValue: null, - valueToVisualize: "source-value", - tooltip: "source-value")); + new ExpectedData( + Value: "source-value", + ContentAfterValue: null, + ValueToVisualize: "source-value", + Tooltip: "source-value")); // Executable path without arguments data.Add(new TestData( @@ -171,16 +185,16 @@ void AddStringProperty(string propertyName, string? propertyValue) ProjectPath: null, ContainerImage: null, SourceProperty: null), - new ResourceSourceViewModel( - value: "executable", - contentAfterValue: null, - valueToVisualize: "path/to/executable", - tooltip: "path/to/executable")); + new ExpectedData( + Value: "executable", + ContentAfterValue: null, + ValueToVisualize: "path/to/executable", + Tooltip: "path/to/executable")); return data; } - public record TestData( + public sealed record TestData( string ResourceType, string? ExecutablePath, string[]? ExecutableArguments, @@ -189,4 +203,12 @@ public record TestData( string? ProjectPath, string? ContainerImage, string? SourceProperty); + + public sealed record ExpectedLaunchArgument(string Value, bool IsShown); + + public sealed record ExpectedData( + string Value, + List? ContentAfterValue, + string ValueToVisualize, + string Tooltip); } diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs index ee2d8906dac..e3f7c282a81 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs @@ -1039,17 +1039,25 @@ private static void AddTestData(TelemetryRepository repository, string resourceN public void ConvertResourceToJson_ReturnsExpectedJson() { // Arrange + var dependencyResource = ModelTestHelpers.CreateResource( + resourceName: "dependency-resource", + displayName: "dependency", + resourceType: "Container", + state: KnownResourceState.Running); + var resource = ModelTestHelpers.CreateResource( resourceName: "test-resource", displayName: "Test Resource", resourceType: "Container", state: KnownResourceState.Running, urls: [new UrlViewModel("http", new Uri("http://localhost:5000"), isInternal: false, isInactive: false, UrlDisplayPropertiesViewModel.Empty)], - environment: [new EnvironmentVariableViewModel("MY_VAR", "my-value", fromSpec: false)], + environment: [new EnvironmentVariableViewModel("MY_VAR", "my-value", fromSpec: true)], relationships: [new RelationshipViewModel("dependency", "Reference")]); + var allResources = new[] { resource, dependencyResource }; + // Act - var json = TelemetryExportService.ConvertResourceToJson(resource); + var json = TelemetryExportService.ConvertResourceToJson(resource, allResources); // Assert var deserialized = JsonSerializer.Deserialize(json, ResourceJsonSerializerContext.Default.ResourceJson); @@ -1067,12 +1075,43 @@ public void ConvertResourceToJson_ReturnsExpectedJson() Assert.Single(deserialized.Environment); Assert.Equal("MY_VAR", deserialized.Environment[0].Name); + // Relationships are resolved by matching DisplayName. Since there's only one resource + // with that display name (not a replica), the display name is used as the resource name. Assert.NotNull(deserialized.Relationships); Assert.Single(deserialized.Relationships); Assert.Equal("dependency", deserialized.Relationships[0].ResourceName); Assert.Equal("Reference", deserialized.Relationships[0].Type); } + [Fact] + public void ConvertResourceToJson_OnlyIncludesFromSpecEnvironmentVariables() + { + // Arrange + var resource = ModelTestHelpers.CreateResource( + resourceName: "test-resource", + displayName: "Test Resource", + resourceType: "Container", + state: KnownResourceState.Running, + environment: + [ + new EnvironmentVariableViewModel("FROM_SPEC_VAR", "spec-value", fromSpec: true), + new EnvironmentVariableViewModel("NOT_FROM_SPEC_VAR", "other-value", fromSpec: false), + new EnvironmentVariableViewModel("ANOTHER_SPEC_VAR", "another-spec-value", fromSpec: true) + ]); + + // Act + var json = TelemetryExportService.ConvertResourceToJson(resource, [resource]); + + // Assert + var deserialized = JsonSerializer.Deserialize(json, ResourceJsonSerializerContext.Default.ResourceJson); + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.Environment); + Assert.Equal(2, deserialized.Environment.Length); + Assert.Contains(deserialized.Environment, e => e.Name == "FROM_SPEC_VAR" && e.Value == "spec-value"); + Assert.Contains(deserialized.Environment, e => e.Name == "ANOTHER_SPEC_VAR" && e.Value == "another-spec-value"); + Assert.DoesNotContain(deserialized.Environment, e => e.Name == "NOT_FROM_SPEC_VAR"); + } + [Fact] public void ConvertResourceToJson_NonAsciiContent_IsNotEscaped() { @@ -1086,10 +1125,10 @@ public void ConvertResourceToJson_NonAsciiContent_IsNotEscaped() displayName: japaneseDisplayName, resourceType: "Container", state: KnownResourceState.Running, - environment: [new EnvironmentVariableViewModel("JAPANESE_VAR", japaneseEnvValue, fromSpec: false)]); + environment: [new EnvironmentVariableViewModel("JAPANESE_VAR", japaneseEnvValue, fromSpec: true)]); // Act - var json = TelemetryExportService.ConvertResourceToJson(resource); + var json = TelemetryExportService.ConvertResourceToJson(resource, [resource]); // Assert - Verify Japanese characters appear directly in JSON (not Unicode-escaped) Assert.Contains(japaneseName, json); diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs new file mode 100644 index 00000000000..ac59779d551 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs @@ -0,0 +1,200 @@ +// 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.Tests.Utils; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.Backchannel; + +public class AuxiliaryBackchannelRpcTargetTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task GetResourceSnapshotsAsync_ReturnsEmptyList_WhenAppModelIsNull() + { + var services = new ServiceCollection(); + services.AddSingleton(ResourceNotificationServiceTestHelpers.Create()); + var serviceProvider = services.BuildServiceProvider(); + + var target = new AuxiliaryBackchannelRpcTarget( + NullLogger.Instance, + serviceProvider); + + var result = await target.GetResourceSnapshotsAsync(); + + Assert.Empty(result); + } + + [Fact] + public async Task GetResourceSnapshotsAsync_EnumeratesResources() + { + using var builder = TestDistributedApplicationBuilder.Create(outputHelper); + + builder.AddParameter("myparam"); + builder.AddResource(new CustomResource(KnownResourceNames.AspireDashboard)); + + var resourceWithReplicas = builder.AddResource(new CustomResource("myresource")); + resourceWithReplicas.WithAnnotation(new DcpInstancesAnnotation([ + new DcpInstance("myresource-abc123", "abc123", 0), + new DcpInstance("myresource-def456", "def456", 1) + ])); + + using var app = builder.Build(); + await app.StartAsync(); + + var notificationService = app.Services.GetRequiredService(); + await notificationService.PublishUpdateAsync(resourceWithReplicas.Resource, "myresource-abc123", s => s with + { + State = new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success) + }); + await notificationService.PublishUpdateAsync(resourceWithReplicas.Resource, "myresource-def456", s => s with + { + State = new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success) + }); + + var target = new AuxiliaryBackchannelRpcTarget( + NullLogger.Instance, + app.Services); + + var result = await target.GetResourceSnapshotsAsync(); + + // Dashboard resource should be skipped + Assert.DoesNotContain(result, r => r.Name == KnownResourceNames.AspireDashboard); + + // Parameter resource (no replicas) should be returned with matching Name/DisplayName + var paramSnapshot = Assert.Single(result, r => r.Name == "myparam"); + Assert.Equal("myparam", paramSnapshot.DisplayName); + Assert.Equal("Parameter", paramSnapshot.ResourceType); + + // Resource with DcpInstancesAnnotation should return multiple instances + Assert.Contains(result, r => r.Name == "myresource-abc123"); + Assert.Contains(result, r => r.Name == "myresource-def456"); + Assert.All(result.Where(r => r.Name.StartsWith("myresource-")), r => Assert.Equal("myresource", r.DisplayName)); + + await app.StopAsync(); + } + + [Fact] + public async Task GetResourceSnapshotsAsync_MapsSnapshotData() + { + using var builder = TestDistributedApplicationBuilder.Create(outputHelper); + + var custom = builder.AddResource(new CustomResource("myresource")); + + using var app = builder.Build(); + await app.StartAsync(); + + var createdAt = DateTime.UtcNow.AddMinutes(-5); + var startedAt = DateTime.UtcNow.AddMinutes(-4); + + var notificationService = app.Services.GetRequiredService(); + await notificationService.PublishUpdateAsync(custom.Resource, s => s with + { + State = new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success), + CreationTimeStamp = createdAt, + StartTimeStamp = startedAt, + Urls = [ + new UrlSnapshot("http", "http://localhost:5000", false) { DisplayProperties = new UrlDisplayPropertiesSnapshot("HTTP Endpoint", 1) }, + new UrlSnapshot("https", "https://localhost:5001", true) { DisplayProperties = new UrlDisplayPropertiesSnapshot("HTTPS Endpoint", 2) }, + new UrlSnapshot("inactive", "http://localhost:5002", false) { IsInactive = true } + ], + Relationships = [ + new RelationshipSnapshot("dependency1", "Reference"), + new RelationshipSnapshot("dependency2", "WaitFor") + ], + HealthReports = [ + new HealthReportSnapshot("check1", Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy, "All good", null), + new HealthReportSnapshot("check2", Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy, "Failed", "Exception occurred") + ], + Volumes = [ + new VolumeSnapshot("/host/path", "/container/path", "bind", false), + new VolumeSnapshot("myvolume", "/data", "volume", true) + ], + EnvironmentVariables = [ + new EnvironmentVariableSnapshot("MY_VAR", "my-value", false), + new EnvironmentVariableSnapshot("ANOTHER_VAR", "another-value", true) + ], + Commands = [ + new ResourceCommandSnapshot("resource-start", ResourceCommandState.Enabled, "Start", "Start the resource", null, null, null, null, false), + new ResourceCommandSnapshot("resource-stop", ResourceCommandState.Disabled, "Stop", "Stop the resource", null, null, null, null, false), + new ResourceCommandSnapshot("resource-restart", ResourceCommandState.Hidden, "Restart", null, null, null, null, null, true) + ], + Properties = [ + new ResourcePropertySnapshot(CustomResourceKnownProperties.Source, "normal-value"), + new ResourcePropertySnapshot("ConnectionString", "secret-value") { IsSensitive = true } + ] + }); + + var target = new AuxiliaryBackchannelRpcTarget( + NullLogger.Instance, + app.Services); + + var result = await target.GetResourceSnapshotsAsync(); + + var snapshot = Assert.Single(result); + + // State + Assert.Equal("Running", snapshot.State); + Assert.Equal(KnownResourceStateStyles.Success, snapshot.StateStyle); + + // Timestamps + Assert.Equal(createdAt, snapshot.CreatedAt); + Assert.Equal(startedAt, snapshot.StartedAt); + + // URLs (inactive URLs should be excluded) + Assert.Equal(2, snapshot.Urls.Length); + Assert.Contains(snapshot.Urls, u => u.Name == "http" && u.Url == "http://localhost:5000" && !u.IsInternal); + Assert.Contains(snapshot.Urls, u => u.Name == "https" && u.Url == "https://localhost:5001" && u.IsInternal); + Assert.DoesNotContain(snapshot.Urls, u => u.Name == "inactive"); + + // URL display properties + var httpUrl = snapshot.Urls.Single(u => u.Name == "http"); + Assert.NotNull(httpUrl.DisplayProperties); + Assert.Equal("HTTP Endpoint", httpUrl.DisplayProperties.DisplayName); + Assert.Equal(1, httpUrl.DisplayProperties.SortOrder); + + var httpsUrl = snapshot.Urls.Single(u => u.Name == "https"); + Assert.NotNull(httpsUrl.DisplayProperties); + Assert.Equal("HTTPS Endpoint", httpsUrl.DisplayProperties.DisplayName); + Assert.Equal(2, httpsUrl.DisplayProperties.SortOrder); + + // Relationships + Assert.Equal(2, snapshot.Relationships.Length); + Assert.Contains(snapshot.Relationships, r => r.ResourceName == "dependency1" && r.Type == "Reference"); + Assert.Contains(snapshot.Relationships, r => r.ResourceName == "dependency2" && r.Type == "WaitFor"); + + // Health reports + Assert.Equal(2, snapshot.HealthReports.Length); + Assert.Contains(snapshot.HealthReports, h => h.Name == "check1" && h.Status == "Healthy"); + Assert.Contains(snapshot.HealthReports, h => h.Name == "check2" && h.Status == "Unhealthy" && h.ExceptionText == "Exception occurred"); + + // Volumes + Assert.Equal(2, snapshot.Volumes.Length); + Assert.Contains(snapshot.Volumes, v => v.Source == "/host/path" && v.Target == "/container/path" && !v.IsReadOnly); + Assert.Contains(snapshot.Volumes, v => v.Source == "myvolume" && v.Target == "/data" && v.IsReadOnly); + + // Environment variables + Assert.Equal(2, snapshot.EnvironmentVariables.Length); + Assert.Contains(snapshot.EnvironmentVariables, e => e.Name == "MY_VAR" && e.Value == "my-value" && !e.IsFromSpec); + Assert.Contains(snapshot.EnvironmentVariables, e => e.Name == "ANOTHER_VAR" && e.Value == "another-value" && e.IsFromSpec); + + // Commands + Assert.Equal(3, snapshot.Commands.Length); + Assert.Contains(snapshot.Commands, c => c.Name == "resource-start" && c.DisplayName == "Start" && c.Description == "Start the resource" && c.State == "Enabled"); + Assert.Contains(snapshot.Commands, c => c.Name == "resource-stop" && c.DisplayName == "Stop" && c.Description == "Stop the resource" && c.State == "Disabled"); + Assert.Contains(snapshot.Commands, c => c.Name == "resource-restart" && c.DisplayName == "Restart" && c.Description == null && c.State == "Hidden"); + + // Properties (sensitive values should be redacted) + Assert.True(snapshot.Properties.TryGetValue(CustomResourceKnownProperties.Source, out var normalValue)); + Assert.Equal("normal-value", normalValue); + Assert.True(snapshot.Properties.TryGetValue("ConnectionString", out var sensitiveValue)); + Assert.Null(sensitiveValue); + + await app.StopAsync(); + } + + private sealed class CustomResource(string name) : Resource(name) + { + } +} diff --git a/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs index 6a57d919326..4c33cd5861b 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs @@ -30,10 +30,12 @@ public class BackchannelContractTests typeof(StopAppHostRequest), typeof(StopAppHostResponse), typeof(ResourceSnapshot), - typeof(ResourceSnapshotEndpoint), + typeof(ResourceSnapshotUrl), + typeof(ResourceSnapshotUrlDisplayProperties), typeof(ResourceSnapshotRelationship), typeof(ResourceSnapshotHealthReport), typeof(ResourceSnapshotVolume), + typeof(ResourceSnapshotEnvironmentVariable), typeof(ResourceSnapshotMcpServer), typeof(ResourceLogLine), ]; diff --git a/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs b/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs index 2a612a4bdd3..1f027781538 100644 --- a/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs @@ -509,4 +509,74 @@ private sealed class AnotherDummyAnnotation : IResourceAnnotation private sealed class TestContainerFilesResource(string name) : ContainerResource(name), IResourceWithContainerFiles { } + + [Theory] + [InlineData(false)] // No annotation + [InlineData(true)] // Empty annotation + public void TryGetInstances_ReturnsFalse_WhenNoInstances(bool addEmptyAnnotation) + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddResource(new ParentResource("test")); + + if (addEmptyAnnotation) + { + resource.WithAnnotation(new DcpInstancesAnnotation([])); + } + + var result = resource.Resource.TryGetInstances(out var instances); + + Assert.False(result); + Assert.Empty(instances); + } + + [Fact] + public void TryGetInstances_ReturnsTrue_WhenAnnotationHasInstances() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddResource(new ParentResource("test")) + .WithAnnotation(new DcpInstancesAnnotation([ + new DcpInstance("test-abc123", "abc123", 0), + new DcpInstance("test-def456", "def456", 1) + ])); + + var result = resource.Resource.TryGetInstances(out var instances); + + Assert.True(result); + Assert.Equal(2, instances.Length); + Assert.Equal("test-abc123", instances[0].Name); + Assert.Equal("test-def456", instances[1].Name); + } + + [Theory] + [InlineData(false)] // No annotation + [InlineData(true)] // Empty annotation + public void GetResolvedResourceNames_ReturnsResourceName_WhenNoInstances(bool addEmptyAnnotation) + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddResource(new ParentResource("test")); + + if (addEmptyAnnotation) + { + resource.WithAnnotation(new DcpInstancesAnnotation([])); + } + + var result = resource.Resource.GetResolvedResourceNames(); + + Assert.Equal(["test"], result); + } + + [Fact] + public void GetResolvedResourceNames_ReturnsInstanceNames_WhenAnnotationHasInstances() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddResource(new ParentResource("test")) + .WithAnnotation(new DcpInstancesAnnotation([ + new DcpInstance("test-abc123", "abc123", 0), + new DcpInstance("test-def456", "def456", 1) + ])); + + var result = resource.Resource.GetResolvedResourceNames(); + + Assert.Equal(["test-abc123", "test-def456"], result); + } } From c8ed53a827faafaadd031df58fb61376d857d710 Mon Sep 17 00:00:00 2001 From: dotnet bot Date: Sun, 1 Feb 2026 22:35:56 -0800 Subject: [PATCH 012/256] Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2893138 (#14279) * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2893080 * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2893080 * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2893080 * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2893080 * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2893080 * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2893080 * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2893080 --- .../Resources/xlf/AgentCommandStrings.cs.xlf | 14 +++++----- .../Resources/xlf/AgentCommandStrings.de.xlf | 14 +++++----- .../Resources/xlf/AgentCommandStrings.es.xlf | 14 +++++----- .../Resources/xlf/AgentCommandStrings.fr.xlf | 14 +++++----- .../Resources/xlf/AgentCommandStrings.it.xlf | 14 +++++----- .../Resources/xlf/AgentCommandStrings.ja.xlf | 14 +++++----- .../Resources/xlf/AgentCommandStrings.ko.xlf | 14 +++++----- .../Resources/xlf/AgentCommandStrings.pl.xlf | 14 +++++----- .../xlf/AgentCommandStrings.pt-BR.xlf | 14 +++++----- .../Resources/xlf/AgentCommandStrings.ru.xlf | 14 +++++----- .../Resources/xlf/AgentCommandStrings.tr.xlf | 14 +++++----- .../xlf/AgentCommandStrings.zh-Hans.xlf | 14 +++++----- .../xlf/AgentCommandStrings.zh-Hant.xlf | 14 +++++----- .../Resources/xlf/ConfigCommandStrings.cs.xlf | 12 ++++---- .../Resources/xlf/ConfigCommandStrings.de.xlf | 12 ++++---- .../Resources/xlf/ConfigCommandStrings.es.xlf | 12 ++++---- .../Resources/xlf/ConfigCommandStrings.fr.xlf | 12 ++++---- .../Resources/xlf/ConfigCommandStrings.it.xlf | 12 ++++---- .../Resources/xlf/ConfigCommandStrings.ja.xlf | 12 ++++---- .../Resources/xlf/ConfigCommandStrings.ko.xlf | 12 ++++---- .../Resources/xlf/ConfigCommandStrings.pl.xlf | 12 ++++---- .../xlf/ConfigCommandStrings.pt-BR.xlf | 12 ++++---- .../Resources/xlf/ConfigCommandStrings.ru.xlf | 12 ++++---- .../Resources/xlf/ConfigCommandStrings.tr.xlf | 12 ++++---- .../xlf/ConfigCommandStrings.zh-Hans.xlf | 12 ++++---- .../xlf/ConfigCommandStrings.zh-Hant.xlf | 12 ++++---- .../Resources/xlf/LogsCommandStrings.cs.xlf | 28 +++++++++---------- .../Resources/xlf/LogsCommandStrings.de.xlf | 28 +++++++++---------- .../Resources/xlf/LogsCommandStrings.es.xlf | 28 +++++++++---------- .../Resources/xlf/LogsCommandStrings.fr.xlf | 28 +++++++++---------- .../Resources/xlf/LogsCommandStrings.it.xlf | 28 +++++++++---------- .../Resources/xlf/LogsCommandStrings.ja.xlf | 28 +++++++++---------- .../Resources/xlf/LogsCommandStrings.ko.xlf | 28 +++++++++---------- .../Resources/xlf/LogsCommandStrings.pl.xlf | 28 +++++++++---------- .../xlf/LogsCommandStrings.pt-BR.xlf | 28 +++++++++---------- .../Resources/xlf/LogsCommandStrings.ru.xlf | 28 +++++++++---------- .../Resources/xlf/LogsCommandStrings.tr.xlf | 28 +++++++++---------- .../xlf/LogsCommandStrings.zh-Hans.xlf | 28 +++++++++---------- .../xlf/LogsCommandStrings.zh-Hant.xlf | 28 +++++++++---------- .../Resources/xlf/McpCommandStrings.cs.xlf | 8 +++--- .../Resources/xlf/McpCommandStrings.de.xlf | 8 +++--- .../Resources/xlf/McpCommandStrings.es.xlf | 8 +++--- .../Resources/xlf/McpCommandStrings.fr.xlf | 8 +++--- .../Resources/xlf/McpCommandStrings.it.xlf | 8 +++--- .../Resources/xlf/McpCommandStrings.ja.xlf | 8 +++--- .../Resources/xlf/McpCommandStrings.ko.xlf | 8 +++--- .../Resources/xlf/McpCommandStrings.pl.xlf | 8 +++--- .../Resources/xlf/McpCommandStrings.pt-BR.xlf | 8 +++--- .../Resources/xlf/McpCommandStrings.ru.xlf | 8 +++--- .../Resources/xlf/McpCommandStrings.tr.xlf | 8 +++--- .../xlf/McpCommandStrings.zh-Hans.xlf | 8 +++--- .../xlf/McpCommandStrings.zh-Hant.xlf | 8 +++--- .../xlf/ResourcesCommandStrings.cs.xlf | 22 +++++++-------- .../xlf/ResourcesCommandStrings.de.xlf | 22 +++++++-------- .../xlf/ResourcesCommandStrings.es.xlf | 22 +++++++-------- .../xlf/ResourcesCommandStrings.fr.xlf | 22 +++++++-------- .../xlf/ResourcesCommandStrings.it.xlf | 22 +++++++-------- .../xlf/ResourcesCommandStrings.ja.xlf | 22 +++++++-------- .../xlf/ResourcesCommandStrings.ko.xlf | 22 +++++++-------- .../xlf/ResourcesCommandStrings.pl.xlf | 22 +++++++-------- .../xlf/ResourcesCommandStrings.pt-BR.xlf | 22 +++++++-------- .../xlf/ResourcesCommandStrings.ru.xlf | 22 +++++++-------- .../xlf/ResourcesCommandStrings.tr.xlf | 22 +++++++-------- .../xlf/ResourcesCommandStrings.zh-Hans.xlf | 22 +++++++-------- .../xlf/ResourcesCommandStrings.zh-Hant.xlf | 22 +++++++-------- .../Resources/xlf/RootCommandStrings.cs.xlf | 10 +++---- .../Resources/xlf/RootCommandStrings.de.xlf | 10 +++---- .../Resources/xlf/RootCommandStrings.es.xlf | 10 +++---- .../Resources/xlf/RootCommandStrings.fr.xlf | 10 +++---- .../Resources/xlf/RootCommandStrings.it.xlf | 10 +++---- .../Resources/xlf/RootCommandStrings.ja.xlf | 10 +++---- .../Resources/xlf/RootCommandStrings.ko.xlf | 10 +++---- .../Resources/xlf/RootCommandStrings.pl.xlf | 10 +++---- .../xlf/RootCommandStrings.pt-BR.xlf | 10 +++---- .../Resources/xlf/RootCommandStrings.ru.xlf | 10 +++---- .../Resources/xlf/RootCommandStrings.tr.xlf | 12 ++++---- .../xlf/RootCommandStrings.zh-Hans.xlf | 10 +++---- .../xlf/RootCommandStrings.zh-Hant.xlf | 10 +++---- .../Resources/xlf/RunCommandStrings.cs.xlf | 10 +++---- .../Resources/xlf/RunCommandStrings.de.xlf | 10 +++---- .../Resources/xlf/RunCommandStrings.es.xlf | 10 +++---- .../Resources/xlf/RunCommandStrings.fr.xlf | 10 +++---- .../Resources/xlf/RunCommandStrings.it.xlf | 10 +++---- .../Resources/xlf/RunCommandStrings.ja.xlf | 10 +++---- .../Resources/xlf/RunCommandStrings.ko.xlf | 10 +++---- .../Resources/xlf/RunCommandStrings.pl.xlf | 10 +++---- .../Resources/xlf/RunCommandStrings.pt-BR.xlf | 10 +++---- .../Resources/xlf/RunCommandStrings.ru.xlf | 10 +++---- .../Resources/xlf/RunCommandStrings.tr.xlf | 10 +++---- .../xlf/RunCommandStrings.zh-Hans.xlf | 10 +++---- .../xlf/RunCommandStrings.zh-Hant.xlf | 10 +++---- .../Resources/xlf/AIAssistant.cs.xlf | 4 +-- .../Resources/xlf/Dialogs.cs.xlf | 2 +- .../Resources/xlf/Layout.cs.xlf | 2 +- .../Resources/xlf/Layout.de.xlf | 2 +- .../Resources/xlf/Layout.es.xlf | 2 +- .../Resources/xlf/Layout.fr.xlf | 2 +- .../Resources/xlf/Layout.it.xlf | 2 +- .../Resources/xlf/Layout.ja.xlf | 2 +- .../Resources/xlf/Layout.ko.xlf | 2 +- .../Resources/xlf/Layout.pl.xlf | 2 +- .../Resources/xlf/Layout.pt-BR.xlf | 2 +- .../Resources/xlf/Layout.ru.xlf | 2 +- .../Resources/xlf/Layout.tr.xlf | 2 +- .../Resources/xlf/Layout.zh-Hans.xlf | 2 +- .../Resources/xlf/Layout.zh-Hant.xlf | 2 +- .../Resources/xlf/CommandStrings.cs.xlf | 2 +- .../Resources/xlf/CommandStrings.de.xlf | 2 +- .../Resources/xlf/CommandStrings.es.xlf | 2 +- .../Resources/xlf/CommandStrings.fr.xlf | 2 +- .../Resources/xlf/CommandStrings.it.xlf | 2 +- .../Resources/xlf/CommandStrings.ja.xlf | 2 +- .../Resources/xlf/CommandStrings.ko.xlf | 2 +- .../Resources/xlf/CommandStrings.pl.xlf | 2 +- .../Resources/xlf/CommandStrings.pt-BR.xlf | 2 +- .../Resources/xlf/CommandStrings.ru.xlf | 2 +- .../Resources/xlf/CommandStrings.tr.xlf | 2 +- .../Resources/xlf/CommandStrings.zh-Hans.xlf | 2 +- .../Resources/xlf/CommandStrings.zh-Hant.xlf | 2 +- 119 files changed, 706 insertions(+), 706 deletions(-) diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf index 9ffef5b3979..d0d933897a3 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf @@ -4,37 +4,37 @@ The following agent configurations use the deprecated 'mcp start' command. Update them? - The following agent configurations use the deprecated 'mcp start' command. Update them? + Následující konfigurace agenta používají zastaralý příkaz „mcp start“. Chcete je aktualizovat? Run 'aspire agent init' to update configuration - Run 'aspire agent init' to update configuration + Spuštěním příkazu „aspire agent init“ aktualizujte konfiguraci Update {0} config to use new 'agent mcp' command - Update {0} config to use new 'agent mcp' command + Aktualizujte konfiguraci {0} tak, aby používala nový příkaz „agent mcp“ {0} config uses deprecated 'mcp start' command - {0} config uses deprecated 'mcp start' command + Konfigurace {0} používá zastaralý příkaz „mcp start“ Manage AI agent integrations. - Manage AI agent integrations. + Spravujte integrace agentů AI. Initialize agent environment configuration for detected agents. - Initialize agent environment configuration for detected agents. + Inicializujte konfiguraci prostředí agentů pro zjištěné agenty. Start the MCP (Model Context Protocol) server. - Start the MCP (Model Context Protocol) server. + Spusťte server MCP (Model Context Protocol). diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf index 24e98a2b3ba..1c43bbbed0a 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf @@ -4,37 +4,37 @@ The following agent configurations use the deprecated 'mcp start' command. Update them? - The following agent configurations use the deprecated 'mcp start' command. Update them? + In den folgenden Agentkonfigurationen wird der veraltete Befehl „mcp start“ verwendet. Aktualisieren? Run 'aspire agent init' to update configuration - Run 'aspire agent init' to update configuration + Führen Sie „aspire agent init“ aus, um die Konfiguration zu aktualisieren Update {0} config to use new 'agent mcp' command - Update {0} config to use new 'agent mcp' command + Aktualisieren der {0} Konfiguration für die Verwendung des neuen Befehls „agent mcp“ {0} config uses deprecated 'mcp start' command - {0} config uses deprecated 'mcp start' command + Die {0} Konfiguration verwendet den veralteten Befehl „mcp start“ Manage AI agent integrations. - Manage AI agent integrations. + Verwalten von KI-Agent-Integrationen. Initialize agent environment configuration for detected agents. - Initialize agent environment configuration for detected agents. + Initialisieren Sie die Agent-Umgebungskonfiguration für erkannte Agenten. Start the MCP (Model Context Protocol) server. - Start the MCP (Model Context Protocol) server. + Starten Sie den MCP-Server (Model Context Protocol). diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf index 2bdbd936801..18efd92b16b 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf @@ -4,37 +4,37 @@ The following agent configurations use the deprecated 'mcp start' command. Update them? - The following agent configurations use the deprecated 'mcp start' command. Update them? + Las siguientes configuraciones de agente usan el comando "mcp start" en desuso. ¿Actualizarlos? Run 'aspire agent init' to update configuration - Run 'aspire agent init' to update configuration + Ejecución de "agent init" para actualizar la configuración Update {0} config to use new 'agent mcp' command - Update {0} config to use new 'agent mcp' command + Actualización {0} de la configuración para usar el nuevo comando "agent mcp" {0} config uses deprecated 'mcp start' command - {0} config uses deprecated 'mcp start' command + {0} config usa el comando "mcp start" en desuso Manage AI agent integrations. - Manage AI agent integrations. + Administrar integraciones de agentes de IA. Initialize agent environment configuration for detected agents. - Initialize agent environment configuration for detected agents. + Inicialice la configuración del entorno del agente para los agentes detectados. Start the MCP (Model Context Protocol) server. - Start the MCP (Model Context Protocol) server. + Inicie el servidor MCP (protocolo de contexto de modelo). diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf index 0247dda1e05..2bdc3ba028d 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf @@ -4,37 +4,37 @@ The following agent configurations use the deprecated 'mcp start' command. Update them? - The following agent configurations use the deprecated 'mcp start' command. Update them? + Les configurations d’agent suivantes utilisent la commande « mcp start » obsolète. Voulez-vous les mettre à jour ? Run 'aspire agent init' to update configuration - Run 'aspire agent init' to update configuration + Exécutez « aspire agent init » pour mettre à jour la configuration Update {0} config to use new 'agent mcp' command - Update {0} config to use new 'agent mcp' command + Mettre à jour {0} la configuration pour utiliser la nouvelle commande « agent mcp » {0} config uses deprecated 'mcp start' command - {0} config uses deprecated 'mcp start' command + {0} configuration utilise la commande « mcp start » dépréciée Manage AI agent integrations. - Manage AI agent integrations. + Gérer les intégrations des agents IA. Initialize agent environment configuration for detected agents. - Initialize agent environment configuration for detected agents. + Initialiser la configuration de l’environnement des agents détectés. Start the MCP (Model Context Protocol) server. - Start the MCP (Model Context Protocol) server. + Démarrez le serveur MCP (Model Context Protocol). diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf index 35807a6ec7f..3c670bea806 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf @@ -4,37 +4,37 @@ The following agent configurations use the deprecated 'mcp start' command. Update them? - The following agent configurations use the deprecated 'mcp start' command. Update them? + Le configurazioni agente seguenti usano il comando 'mcp start' deprecato. Aggiornare ora? Run 'aspire agent init' to update configuration - Run 'aspire agent init' to update configuration + Eseguire 'aspire agent init' per aggiornare la configurazione Update {0} config to use new 'agent mcp' command - Update {0} config to use new 'agent mcp' command + Aggiornare la configurazione {0} in modo che usi il nuovo comando 'agent mcp' {0} config uses deprecated 'mcp start' command - {0} config uses deprecated 'mcp start' command + La configurazione {0} usa il comando 'mcp start' deprecato Manage AI agent integrations. - Manage AI agent integrations. + Gestire le integrazioni dell'agente AI. Initialize agent environment configuration for detected agents. - Initialize agent environment configuration for detected agents. + Consente di inizializzare la configurazione dell'ambiente agente per gli agenti rilevati. Start the MCP (Model Context Protocol) server. - Start the MCP (Model Context Protocol) server. + Avviare il server MCP (Model Context Protocol). diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf index 618ac5949b0..25d9e8d20f8 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf @@ -4,37 +4,37 @@ The following agent configurations use the deprecated 'mcp start' command. Update them? - The following agent configurations use the deprecated 'mcp start' command. Update them? + 次のエージェント構成で非推奨の 'mcp start' コマンドが使用されています。これらを更新しますか? Run 'aspire agent init' to update configuration - Run 'aspire agent init' to update configuration + 'aspire agent init' を実行して構成を更新してください Update {0} config to use new 'agent mcp' command - Update {0} config to use new 'agent mcp' command + 構成 {0} を更新して新しい 'agent mcp' コマンドを使用します {0} config uses deprecated 'mcp start' command - {0} config uses deprecated 'mcp start' command + {0} 構成で非推奨の 'mcp start' コマンドが使用されています Manage AI agent integrations. - Manage AI agent integrations. + AI エージェントの統合を管理します。 Initialize agent environment configuration for detected agents. - Initialize agent environment configuration for detected agents. + 検出されたエージェントのエージェント環境構成を初期化します。 Start the MCP (Model Context Protocol) server. - Start the MCP (Model Context Protocol) server. + MCP (モデル コンテキスト プロトコル) サーバーを起動します。 diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf index d50e7ab82b4..c312b98f5ee 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf @@ -4,37 +4,37 @@ The following agent configurations use the deprecated 'mcp start' command. Update them? - The following agent configurations use the deprecated 'mcp start' command. Update them? + 다음 에이전트 구성에서 더 이상 사용되지 않는 'mcp start' 명령을 사용하고 있습니다. 업데이트하시겠습니까? Run 'aspire agent init' to update configuration - Run 'aspire agent init' to update configuration + 구성을 업데이트하도록 'agent init' 명령 실행 Update {0} config to use new 'agent mcp' command - Update {0} config to use new 'agent mcp' command + 새 'agent mcp' 명령을 사용하도록 {0} 구성 업데이트 {0} config uses deprecated 'mcp start' command - {0} config uses deprecated 'mcp start' command + {0} config에서 더 이상 사용되지 않는 'mcp start' 명령을 사용하고 있습니다. Manage AI agent integrations. - Manage AI agent integrations. + AI 에이전트 통합을 관리합니다. Initialize agent environment configuration for detected agents. - Initialize agent environment configuration for detected agents. + 감지된 에이전트에 대한 에이전트 환경 구성을 초기화합니다. Start the MCP (Model Context Protocol) server. - Start the MCP (Model Context Protocol) server. + MCP(모델 컨텍스트 프로토콜) 서버를 시작합니다. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf index 1101d6acc13..b42532db057 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf @@ -4,37 +4,37 @@ The following agent configurations use the deprecated 'mcp start' command. Update them? - The following agent configurations use the deprecated 'mcp start' command. Update them? + Następujące konfiguracje agentów używają przestarzałego polecenia „mcp start”. Zaktualizować je? Run 'aspire agent init' to update configuration - Run 'aspire agent init' to update configuration + Uruchom polecenie „aspire agent init”, aby zaktualizować konfigurację Update {0} config to use new 'agent mcp' command - Update {0} config to use new 'agent mcp' command + Zaktualizuj konfigurację {0}, aby użyć nowego polecenia „agent mcp” {0} config uses deprecated 'mcp start' command - {0} config uses deprecated 'mcp start' command + Konfiguracja {0} używa przestarzałego polecenia „mcp start” Manage AI agent integrations. - Manage AI agent integrations. + Zarządzaj integracjami agentów sztucznej inteligencji. Initialize agent environment configuration for detected agents. - Initialize agent environment configuration for detected agents. + Zainicjuj konfigurację środowiska agenta dla wykrytych agentów. Start the MCP (Model Context Protocol) server. - Start the MCP (Model Context Protocol) server. + Uruchom serwer MCP (Model Context Protocol). diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf index cc9d216d4bd..4294efb9752 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf @@ -4,37 +4,37 @@ The following agent configurations use the deprecated 'mcp start' command. Update them? - The following agent configurations use the deprecated 'mcp start' command. Update them? + As configurações de agente a seguir usam o comando "mcp start" preterido. Deseja atualizá-las? Run 'aspire agent init' to update configuration - Run 'aspire agent init' to update configuration + Execute "aspire agent init" para atualizar a configuração Update {0} config to use new 'agent mcp' command - Update {0} config to use new 'agent mcp' command + Atualize a config {0} para usar o novo comando "agent mcp" {0} config uses deprecated 'mcp start' command - {0} config uses deprecated 'mcp start' command + A config {0} usa o comando "mcp start" preterido Manage AI agent integrations. - Manage AI agent integrations. + Gerencie integrações de agente de IA. Initialize agent environment configuration for detected agents. - Initialize agent environment configuration for detected agents. + Inicialize a configuração de ambiente do agente para agentes detectados. Start the MCP (Model Context Protocol) server. - Start the MCP (Model Context Protocol) server. + Inicie o servidor MCP (Protocolo de Contexto de Modelo). diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf index 102b4ec4d94..db959a6d4d5 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf @@ -4,37 +4,37 @@ The following agent configurations use the deprecated 'mcp start' command. Update them? - The following agent configurations use the deprecated 'mcp start' command. Update them? + Следующие конфигурации агента используют устаревшую команду "mcp start". Обновить их? Run 'aspire agent init' to update configuration - Run 'aspire agent init' to update configuration + Выполните команду "aspire agent init" для обновления конфигурации Update {0} config to use new 'agent mcp' command - Update {0} config to use new 'agent mcp' command + Обновите {0} конфигурацию для использования новой команды "agent mcp" {0} config uses deprecated 'mcp start' command - {0} config uses deprecated 'mcp start' command + Конфигурация {0} использует устаревшую команду "mcp start" Manage AI agent integrations. - Manage AI agent integrations. + Управляйте интеграциями агентов ИИ. Initialize agent environment configuration for detected agents. - Initialize agent environment configuration for detected agents. + Инициализировать конфигурацию среды агента для обнаруженных агентов. Start the MCP (Model Context Protocol) server. - Start the MCP (Model Context Protocol) server. + Запуск сервера MCP (протокол контекста модели). diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf index 05e64267ca6..b4f7dbdc41b 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf @@ -4,37 +4,37 @@ The following agent configurations use the deprecated 'mcp start' command. Update them? - The following agent configurations use the deprecated 'mcp start' command. Update them? + Aşağıdaki aracı yapılandırmaları, kullanımdan kaldırılmış olan 'mcp start' komutunu kullanmaktadır. Güncellensin mi? Run 'aspire agent init' to update configuration - Run 'aspire agent init' to update configuration + Yapılandırmayı güncellemek için 'aspire agent init' komutunu çalıştırın. Update {0} config to use new 'agent mcp' command - Update {0} config to use new 'agent mcp' command + {0} yapılandırmasını yeni 'agent mcp' komutunu kullanacak şekilde güncelleştirin {0} config uses deprecated 'mcp start' command - {0} config uses deprecated 'mcp start' command + {0} yapılandırması kullanım dışı kalan 'mcp start' komutunu kullanıyor. Manage AI agent integrations. - Manage AI agent integrations. + AI detekli aracı entegrasyonlarınızı yönetin. Initialize agent environment configuration for detected agents. - Initialize agent environment configuration for detected agents. + Tespit edilen aracılar için aracı ortam yapılandırmasını başlatın. Start the MCP (Model Context Protocol) server. - Start the MCP (Model Context Protocol) server. + MCP (Model Bağlam Protokolü) sunucusunu başlat. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf index 2f313ce49f5..49c47e7220f 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf @@ -4,37 +4,37 @@ The following agent configurations use the deprecated 'mcp start' command. Update them? - The following agent configurations use the deprecated 'mcp start' command. Update them? + 以下智能体配置使用已弃用的 "mcp start" 命令。是否更新它们? Run 'aspire agent init' to update configuration - Run 'aspire agent init' to update configuration + 运行 "aspire agent init" 以更新配置 Update {0} config to use new 'agent mcp' command - Update {0} config to use new 'agent mcp' command + 更新 {0} 配置以使用新的 "agent mcp" 命令 {0} config uses deprecated 'mcp start' command - {0} config uses deprecated 'mcp start' command + {0} 配置使用弃用的 "mcp start" 命令 Manage AI agent integrations. - Manage AI agent integrations. + 管理 AI 智能体集成。 Initialize agent environment configuration for detected agents. - Initialize agent environment configuration for detected agents. + 初始化检测到的智能体的智能体环境配置。 Start the MCP (Model Context Protocol) server. - Start the MCP (Model Context Protocol) server. + 启动 MCP (模型上下文协议)服务器。 diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf index 64ad67f6d44..45422b98fde 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf @@ -4,37 +4,37 @@ The following agent configurations use the deprecated 'mcp start' command. Update them? - The following agent configurations use the deprecated 'mcp start' command. Update them? + 以下代理程式設定使用已棄用的 'mcp start' 命令。更新它們? Run 'aspire agent init' to update configuration - Run 'aspire agent init' to update configuration + 執行 'aspire agent init' 以更新設定 Update {0} config to use new 'agent mcp' command - Update {0} config to use new 'agent mcp' command + 更新 {0} 設定以使用新的 'agent mcp' 命令 {0} config uses deprecated 'mcp start' command - {0} config uses deprecated 'mcp start' command + {0} 設定使用已淘汰的 'mcp start' 命令 Manage AI agent integrations. - Manage AI agent integrations. + 管理 AI Agent 整合。 Initialize agent environment configuration for detected agents. - Initialize agent environment configuration for detected agents. + 為偵測到的代理程式初始化代理程式環境設定。 Start the MCP (Model Context Protocol) server. - Start the MCP (Model Context Protocol) server. + 啟動 MCP (模型內容通訊協定) 伺服器。 diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.cs.xlf index 523be5670f5..b6c1287b682 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.cs.xlf @@ -84,32 +84,32 @@ Available features - Available features + Dostupné funkce Display configuration file paths and available features. - Display configuration file paths and available features. + Umožňuje zobrazit cesty ke konfiguračním souborům a dostupné funkce. Global settings path - Global settings path + Cesta ke globálnímu nastavení Output information in JSON format. - Output information in JSON format. + Výstup informací ve formátu JSON. Local settings path - Local settings path + Cestu k místnímu nastavení Settings file properties - Settings file properties + Vlastnosti souboru nastavení diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.de.xlf index 5d9b08fff03..fe5960df195 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.de.xlf @@ -84,32 +84,32 @@ Available features - Available features + Verfügbare Features Display configuration file paths and available features. - Display configuration file paths and available features. + Konfigurationsdateipfade und verfügbare Funktionen anzeigen. Global settings path - Global settings path + Pfad für globale Einstellungen Output information in JSON format. - Output information in JSON format. + Informationen im JSON-Format ausgeben. Local settings path - Local settings path + Pfad für lokale Einstellungen Settings file properties - Settings file properties + Eigenschaften der Einstellungsdatei diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.es.xlf index 1641255b110..0bb0dedb7ff 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.es.xlf @@ -84,32 +84,32 @@ Available features - Available features + Características disponibles Display configuration file paths and available features. - Display configuration file paths and available features. + Muestra las rutas de acceso del archivo de configuración y las características disponibles. Global settings path - Global settings path + Ruta de acceso de configuración global Output information in JSON format. - Output information in JSON format. + Información de salida en formato JSON. Local settings path - Local settings path + Ruta de acceso de configuración local Settings file properties - Settings file properties + Propiedades del archivo de configuración diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.fr.xlf index 18449133459..ba35f3d7e8c 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.fr.xlf @@ -84,32 +84,32 @@ Available features - Available features + Fonctionnalités disponibles Display configuration file paths and available features. - Display configuration file paths and available features. + Afficher les chemins des fichiers de configuration et les fonctionnalités disponibles. Global settings path - Global settings path + Chemin des paramètres globaux Output information in JSON format. - Output information in JSON format. + Afficher les informations au format JSON. Local settings path - Local settings path + Chemin d’accès aux paramètres locaux Settings file properties - Settings file properties + Propriétés du fichier de paramètres diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.it.xlf index 0af345d59ba..60e8e5b73bf 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.it.xlf @@ -84,32 +84,32 @@ Available features - Available features + Funzionalità disponibili Display configuration file paths and available features. - Display configuration file paths and available features. + Visualizza i percorsi dei file di configurazione e le funzionalità disponibili. Global settings path - Global settings path + Percorso impostazioni globali Output information in JSON format. - Output information in JSON format. + Restituisce le informazioni in formato JSON. Local settings path - Local settings path + Percorso impostazioni locali Settings file properties - Settings file properties + Proprietà del file di impostazioni diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ja.xlf index 912af4f97af..0c9d7aa0a5e 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ja.xlf @@ -84,32 +84,32 @@ Available features - Available features + 使用可能な機能 Display configuration file paths and available features. - Display configuration file paths and available features. + 構成ファイルのパスと使用可能な機能を表示します。 Global settings path - Global settings path + グローバル設定のパス Output information in JSON format. - Output information in JSON format. + 情報を JSON 形式で出力します。 Local settings path - Local settings path + ローカル設定のパス Settings file properties - Settings file properties + 設定ファイルのプロパティ diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ko.xlf index ceaa40d242a..b1f392431b4 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ko.xlf @@ -84,32 +84,32 @@ Available features - Available features + 사용 가능한 기능 Display configuration file paths and available features. - Display configuration file paths and available features. + 구성 파일 경로 및 사용 가능한 기능을 표시합니다. Global settings path - Global settings path + 전역 설정 경로 Output information in JSON format. - Output information in JSON format. + JSON 형식으로 정보를 출력합니다. Local settings path - Local settings path + 로컬 설정 경로 Settings file properties - Settings file properties + 설정 파일 속성 diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pl.xlf index b25382b13ce..0011ce03bf2 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pl.xlf @@ -84,32 +84,32 @@ Available features - Available features + Dostępne funkcje Display configuration file paths and available features. - Display configuration file paths and available features. + Pokaż ścieżki plików konfiguracyjnych i dostępne funkcje. Global settings path - Global settings path + Ścieżka ustawień globalnych Output information in JSON format. - Output information in JSON format. + Wyświetl informacje w formacie JSON. Local settings path - Local settings path + Ścieżka ustawień lokalnych Settings file properties - Settings file properties + Właściwości pliku ustawień diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pt-BR.xlf index b2d05751389..429142d6b16 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pt-BR.xlf @@ -84,32 +84,32 @@ Available features - Available features + Recursos disponíveis Display configuration file paths and available features. - Display configuration file paths and available features. + Exiba os caminhos de arquivo de configuração e recursos disponíveis. Global settings path - Global settings path + Caminho de configurações globais Output information in JSON format. - Output information in JSON format. + Informações de saída no formato JSON. Local settings path - Local settings path + Caminho de configurações locais Settings file properties - Settings file properties + Propriedades do arquivo de configurações diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ru.xlf index d8822c0fcec..4dfe010e1b7 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ru.xlf @@ -84,32 +84,32 @@ Available features - Available features + Доступные функции Display configuration file paths and available features. - Display configuration file paths and available features. + Отобразить пути к файлам конфигурации и доступные функции. Global settings path - Global settings path + Путь к глобальным параметрам Output information in JSON format. - Output information in JSON format. + Вывод информации в формате JSON. Local settings path - Local settings path + Путь к локальным параметрам Settings file properties - Settings file properties + Свойства файла параметров diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.tr.xlf index c1648cfe77e..369dcbdc755 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.tr.xlf @@ -84,32 +84,32 @@ Available features - Available features + Kullanılabilir özellikler Display configuration file paths and available features. - Display configuration file paths and available features. + Yapılandırma dosyası yollarını ve mevcut özellikleri görüntüle. Global settings path - Global settings path + Genel ayarların yolu Output information in JSON format. - Output information in JSON format. + Bilgileri JSON biçiminde çıkar. Local settings path - Local settings path + Yerel ayarlar yolu Settings file properties - Settings file properties + Ayar dosyası özellikleri diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hans.xlf index 4124bfce47b..cf2988efbdc 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hans.xlf @@ -84,32 +84,32 @@ Available features - Available features + 可用的功能 Display configuration file paths and available features. - Display configuration file paths and available features. + 显示配置文件路径和可用功能。 Global settings path - Global settings path + 全局设置路径 Output information in JSON format. - Output information in JSON format. + 以 JSON 格式输出信息。 Local settings path - Local settings path + 本地设置路径 Settings file properties - Settings file properties + 设置文件属性 diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hant.xlf index 33a0032acd0..4a3ccdccb66 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hant.xlf @@ -84,32 +84,32 @@ Available features - Available features + 可用功能 Display configuration file paths and available features. - Display configuration file paths and available features. + 顯示設定檔路徑和可用功能。 Global settings path - Global settings path + 全域設定路徑 Output information in JSON format. - Output information in JSON format. + 以 JSON 格式輸出資訊。 Local settings path - Local settings path + 本機設定路徑 Settings file properties - Settings file properties + 設定檔案屬性 diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf index 7be1451d6f2..64f8d410dc1 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf @@ -4,72 +4,72 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Nenašel se žádný spuštěný hostitel aplikací. Nejprve spusťte spuštění pomocí příkazu „aspire run“. Display logs from resources in a running Aspire apphost. - Display logs from resources in a running Aspire apphost. + Umožňuje zobrazit protokoly z prostředků v běžícím hostiteli aplikací Aspire. Stream logs in real-time as they are written. - Stream logs in real-time as they are written. + Streamujte protokoly v reálném čase při jejich zápisu. Output logs in JSON format (NDJSON). - Output logs in JSON format (NDJSON). + Výstupní protokoly ve formátu JSON (NDJSON). No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + V aktuálním adresáři se nenašli žádní hostitelé aplikací. Zobrazují se všichni spuštění hostitelé aplikací. No resources found. - No resources found. + Nenašly se žádné prostředky. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Cesta k souboru projektu Aspire AppHost. The name of the resource to get logs for. If not specified, logs from all resources are shown. - The name of the resource to get logs for. If not specified, logs from all resources are shown. + Název prostředku, pro který se mají načíst protokoly. Pokud se nezadá, zobrazí se protokoly ze všech prostředků. A resource name is required when not using --follow. Use --follow to stream logs from all resources. - A resource name is required when not using --follow. Use --follow to stream logs from all resources. + Pokud nepoužíváte --follow, vyžaduje se název prostředku. Pokud chcete streamovat protokoly ze všech prostředků, použijte --follow. Scanning for running AppHosts... - Scanning for running AppHosts... + Vyhledávání spuštěných hostitelů aplikací... Select an AppHost: - Select an AppHost: + Vyberte hostitele aplikací: The --tail value must be a positive number. - The --tail value must be a positive number. + Hodnota --tail musí být kladné číslo. Number of lines to show from the end of logs (default: all). - Number of lines to show from the end of logs (default: all). + Počet řádků, které se mají zobrazit od konce protokolů (výchozí: vše). The --tail option requires a resource name to be specified. - The --tail option requires a resource name to be specified. + Možnost --tail vyžaduje zadání názvu prostředku. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf index a2299a774c3..ec47e495971 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf @@ -4,72 +4,72 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Es wurde kein aktiver AppHost gefunden. Verwenden Sie zuerst „aspire run“, um einen zu starten. Display logs from resources in a running Aspire apphost. - Display logs from resources in a running Aspire apphost. + Protokolle von Ressourcen in einem laufenden Aspire-AppHost anzeigen. Stream logs in real-time as they are written. - Stream logs in real-time as they are written. + Streamen Sie Protokolle in Echtzeit, während sie geschrieben werden. Output logs in JSON format (NDJSON). - Output logs in JSON format (NDJSON). + Protokolle im JSON-Format (NDJSON) ausgeben. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Im aktuellen Verzeichnis wurden keine AppHosts gefunden. Es werden alle aktiven AppHosts angezeigt. No resources found. - No resources found. + Keine Ressourcen gefunden. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Der Pfad zur Aspire AppHost-Projektdatei. The name of the resource to get logs for. If not specified, logs from all resources are shown. - The name of the resource to get logs for. If not specified, logs from all resources are shown. + Name der Ressource, für die Protokolle abgerufen werden sollen. Wenn keine Angabe erfolgt, werden Protokolle von allen Ressourcen angezeigt. A resource name is required when not using --follow. Use --follow to stream logs from all resources. - A resource name is required when not using --follow. Use --follow to stream logs from all resources. + Ein Ressourcenname ist erforderlich, wenn --follow nicht verwendet wird. Verwenden Sie --follow, um Protokolle aller Ressourcen zu streamen. Scanning for running AppHosts... - Scanning for running AppHosts... + Suche nach aktiven AppHosts … Select an AppHost: - Select an AppHost: + AppHost auswählen: The --tail value must be a positive number. - The --tail value must be a positive number. + Der Wert für --tail muss eine positive Zahl sein. Number of lines to show from the end of logs (default: all). - Number of lines to show from the end of logs (default: all). + Anzahl der Zeilen, die vom Ende der Protokolle angezeigt werden sollen (Standard: alle). The --tail option requires a resource name to be specified. - The --tail option requires a resource name to be specified. + Für die Option --tail muss ein Ressourcenname angegeben werden. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf index b87430ef3d0..e21f94b8ca5 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf @@ -4,72 +4,72 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + No se encontró ningún AppHost en ejecución. Use "ejecutar aspire" para iniciar uno primero. Display logs from resources in a running Aspire apphost. - Display logs from resources in a running Aspire apphost. + Muestra los registros de los recursos en un host de aplicaciones Aspire en ejecución. Stream logs in real-time as they are written. - Stream logs in real-time as they are written. + Transmita los registros en tiempo real a medida que se escriben. Output logs in JSON format (NDJSON). - Output logs in JSON format (NDJSON). + Registros de salida en formato JSON (NDJSON). No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + No se encontró ningún AppHosts en el directorio actual. Mostrando todos los AppHosts en ejecución. No resources found. - No resources found. + No se encontraron recursos. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + La ruta de acceso al archivo del proyecto host de la AppHost Aspire. The name of the resource to get logs for. If not specified, logs from all resources are shown. - The name of the resource to get logs for. If not specified, logs from all resources are shown. + Nombre del recurso para el que se van a obtener registros. Si no se especifica, se muestran los registros de todos los recursos. A resource name is required when not using --follow. Use --follow to stream logs from all resources. - A resource name is required when not using --follow. Use --follow to stream logs from all resources. + Se requiere un nombre de recurso cuando no se usa --follow. Use --follow para transmitir registros de todos los recursos. Scanning for running AppHosts... - Scanning for running AppHosts... + Buscando AppHosts en ejecución... Select an AppHost: - Select an AppHost: + Seleccione un AppHost: The --tail value must be a positive number. - The --tail value must be a positive number. + El valor --tail debe ser un número positivo. Number of lines to show from the end of logs (default: all). - Number of lines to show from the end of logs (default: all). + Número de líneas que se mostrarán desde el final de los registros (valor predeterminado: todos). The --tail option requires a resource name to be specified. - The --tail option requires a resource name to be specified. + La opción --tail requiere que se especifique un nombre de recurso. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf index 9f01f174329..6344e92d62c 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf @@ -4,72 +4,72 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Désolé, aucun AppHost en cours d’exécution n’a été trouvé. Utilisez « aspire run » pour en démarrer un. Display logs from resources in a running Aspire apphost. - Display logs from resources in a running Aspire apphost. + Afficher les journaux des ressources dans un Apphost Aspire en cours d’exécution. Stream logs in real-time as they are written. - Stream logs in real-time as they are written. + Diffuser les journaux en temps réel au fur et à mesure de leur écriture. Output logs in JSON format (NDJSON). - Output logs in JSON format (NDJSON). + Générer les journaux au format JSON (NDJSON). No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Désolé, aucun AppHosts n’a été trouvé dans le répertoire actif. Affichage de tous les AppHosts en cours d’exécution. No resources found. - No resources found. + Ressources introuvables. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Chemin d’accès au fichier projet AppHost Aspire. The name of the resource to get logs for. If not specified, logs from all resources are shown. - The name of the resource to get logs for. If not specified, logs from all resources are shown. + Le nom de la ressource pour laquelle obtenir les journaux. Si aucun nom n’est spécifié, les journaux d’activité de toutes les ressources sont affichées. A resource name is required when not using --follow. Use --follow to stream logs from all resources. - A resource name is required when not using --follow. Use --follow to stream logs from all resources. + Un nom de ressource est requis si vous n’utilisez pas --follow. Utilisez --follow pour diffuser en continu les journaux de toutes les ressources. Scanning for running AppHosts... - Scanning for running AppHosts... + Recherche des AppHosts en cours d’exécution... Select an AppHost: - Select an AppHost: + Sélectionner un AppHost : The --tail value must be a positive number. - The --tail value must be a positive number. + La valeur --tail doit être un nombre positif. Number of lines to show from the end of logs (default: all). - Number of lines to show from the end of logs (default: all). + Nombre de lignes à afficher à partir de la fin des journaux (par défaut : toutes). The --tail option requires a resource name to be specified. - The --tail option requires a resource name to be specified. + L’option --tail requiert la spécification d’un nom de ressource. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf index 6ce572519ec..e24d5114a96 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf @@ -4,72 +4,72 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Non è stato trovato alcun AppHost in esecuzione. Usare prima di tutto "aspire run" per avviarne uno. Display logs from resources in a running Aspire apphost. - Display logs from resources in a running Aspire apphost. + Consente di visualizzare i log delle risorse in un apphost Aspire in esecuzione. Stream logs in real-time as they are written. - Stream logs in real-time as they are written. + Consente di trasmettere i log in tempo reale man mano che vengono scritti. Output logs in JSON format (NDJSON). - Output logs in JSON format (NDJSON). + Log di output in formato JSON (NDJSON). No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Nessun AppHost trovato nella directory corrente. Visualizzazione di tutti gli AppHost in esecuzione. No resources found. - No resources found. + Non sono state trovate risorse. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Percorso del file di un progetto AppHost di Aspire. The name of the resource to get logs for. If not specified, logs from all resources are shown. - The name of the resource to get logs for. If not specified, logs from all resources are shown. + Nome della risorsa per cui ottenere i log. Se non specificato, vengono visualizzati i log di tutte le risorse. A resource name is required when not using --follow. Use --follow to stream logs from all resources. - A resource name is required when not using --follow. Use --follow to stream logs from all resources. + Quando non si usa --follow, è necessario specificare un nome di risorsa. Usare --follow per trasmettere i log da tutte le risorse. Scanning for running AppHosts... - Scanning for running AppHosts... + Analisi per l'esecuzione di AppHosts in corso... Select an AppHost: - Select an AppHost: + Selezionare un AppHost: The --tail value must be a positive number. - The --tail value must be a positive number. + Il valore --tail deve essere un numero positivo. Number of lines to show from the end of logs (default: all). - Number of lines to show from the end of logs (default: all). + Numero di righe da visualizzare dalla fine dei log (impostazione predefinita: all). The --tail option requires a resource name to be specified. - The --tail option requires a resource name to be specified. + L'opzione --tail richiede che venga specificato un nome di risorsa. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf index 922274bce5a..7dc0960d043 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf @@ -4,72 +4,72 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + 実行中の AppHost は見つかりません。最初に 'aspire run' を使って起動してください。 Display logs from resources in a running Aspire apphost. - Display logs from resources in a running Aspire apphost. + 実行中の Aspire apphost のリソースからログを表示します。 Stream logs in real-time as they are written. - Stream logs in real-time as they are written. + ログが書き込まれると同時にリアルタイムでストリームします。 Output logs in JSON format (NDJSON). - Output logs in JSON format (NDJSON). + ログを JSON 形式 (NDJSON) で出力します。 No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + 現在のディレクトリ内に AppHost が見つかりません。実行中のすべての AppHost を表示しています。 No resources found. - No resources found. + リソースが見つかりませんでした。 The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost プロジェクト ファイルへのパス。 The name of the resource to get logs for. If not specified, logs from all resources are shown. - The name of the resource to get logs for. If not specified, logs from all resources are shown. + ログ取得の対象とするリソースの名前。指定しない場合は、すべてのリソースのログが表示されます。 A resource name is required when not using --follow. Use --follow to stream logs from all resources. - A resource name is required when not using --follow. Use --follow to stream logs from all resources. + --follow を使わない場合はリソース名の指定が必須です。--follow を使う場合、すべてのリソースのログがストリーミングされます。 Scanning for running AppHosts... - Scanning for running AppHosts... + 実行中の AppHost をスキャンしています... Select an AppHost: - Select an AppHost: + AppHost を選択: The --tail value must be a positive number. - The --tail value must be a positive number. + --tail の値は正の数値である必要があります。 Number of lines to show from the end of logs (default: all). - Number of lines to show from the end of logs (default: all). + 表示するログの行数を、ログの末尾から数えた行数で指定します (既定値: すべて)。 The --tail option requires a resource name to be specified. - The --tail option requires a resource name to be specified. + --tail オプションにはリソース名の指定が必須です。 diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf index 04383e605b9..ab73b881532 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf @@ -4,72 +4,72 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + 실행 중인 AppHost를 찾을 수 없습니다. 'aspire run'을 사용하여 먼저 하나를 시작합니다. Display logs from resources in a running Aspire apphost. - Display logs from resources in a running Aspire apphost. + 실행 중인 Aspire AppHost에서 리소스의 로그를 표시합니다. Stream logs in real-time as they are written. - Stream logs in real-time as they are written. + 로그가 기록되는 대로 실시간으로 스트리밍합니다. Output logs in JSON format (NDJSON). - Output logs in JSON format (NDJSON). + JSON 형식(NDJSON)으로 로그를 출력합니다. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + 현재 디렉터리에 AppHost가 없습니다. 실행 중인 모든 AppHost를 표시하는 중입니다. No resources found. - No resources found. + 리소스를 찾을 수 없습니다. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost 프로젝트 파일의 경로입니다. The name of the resource to get logs for. If not specified, logs from all resources are shown. - The name of the resource to get logs for. If not specified, logs from all resources are shown. + 로그를 가져올 리소스의 이름입니다. 지정하지 않으면 모든 리소스의 로그가 표시됩니다. A resource name is required when not using --follow. Use --follow to stream logs from all resources. - A resource name is required when not using --follow. Use --follow to stream logs from all resources. + --follow를 사용하지 않을 경우 리소스 이름은 필수 항목입니다. --follow를 사용하여 모든 리소스의 로그를 스트리밍합니다. Scanning for running AppHosts... - Scanning for running AppHosts... + 실행 중인 AppHost를 검색하는 중... Select an AppHost: - Select an AppHost: + AppHost 선택: The --tail value must be a positive number. - The --tail value must be a positive number. + --tail 값은 양수여야 합니다. Number of lines to show from the end of logs (default: all). - Number of lines to show from the end of logs (default: all). + 로그 끝에서 표시할 줄 수입니다(기본값: 모두). The --tail option requires a resource name to be specified. - The --tail option requires a resource name to be specified. + --tail 옵션을 사용하려면 리소스 이름을 지정해야 합니다. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf index 0ac9498bcbe..69afa3bdba0 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf @@ -4,72 +4,72 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Nie znaleziono uruchomionego hosta aplikacji. Najpierw uruchom go poleceniem „aspire run”. Display logs from resources in a running Aspire apphost. - Display logs from resources in a running Aspire apphost. + Wyświetl dzienniki z zasobów w działającym hoście usługi Aspire. Stream logs in real-time as they are written. - Stream logs in real-time as they are written. + Przesyłaj dzienniki w czasie rzeczywistym podczas ich zapisywania. Output logs in JSON format (NDJSON). - Output logs in JSON format (NDJSON). + Dzienniki wyjściowe w formacie JSON (NDJSON). No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Nie znaleziono hostów aplikacji w bieżącym katalogu. Wyświetlanie wszystkich uruchomionych hostów aplikacji. No resources found. - No resources found. + Nie znaleziono żadnych zasobów. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Ścieżka do pliku projektu hosta AppHost platformy Aspire. The name of the resource to get logs for. If not specified, logs from all resources are shown. - The name of the resource to get logs for. If not specified, logs from all resources are shown. + Nazwa zasobu, którego dzienniki mają zostać pobrane. Jeśli nie podasz nazwy, pokażą się dzienniki ze wszystkich zasobów. A resource name is required when not using --follow. Use --follow to stream logs from all resources. - A resource name is required when not using --follow. Use --follow to stream logs from all resources. + Nazwa zasobu jest wymagana, jeśli nie używasz opcji --follow. Użyj opcji --follow, aby przesyłać strumieniowo dzienniki ze wszystkich zasobów. Scanning for running AppHosts... - Scanning for running AppHosts... + Skanowanie w poszukiwaniu uruchomionych hostów aplikacji... Select an AppHost: - Select an AppHost: + Wybierz hosta aplikacji: The --tail value must be a positive number. - The --tail value must be a positive number. + Wartość --tail musi być liczbą dodatnią. Number of lines to show from the end of logs (default: all). - Number of lines to show from the end of logs (default: all). + Liczba linii wyświetlanych od końca dzienników (domyślnie: wszystkie). The --tail option requires a resource name to be specified. - The --tail option requires a resource name to be specified. + Opcja --tail wymaga podania nazwy zasobu. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf index 41be5b75c83..7428a6df57f 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf @@ -4,72 +4,72 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Nenhum AppHost em execução encontrado. Use "aspire run" para iniciar um primeiro. Display logs from resources in a running Aspire apphost. - Display logs from resources in a running Aspire apphost. + Exiba logs de recursos em um apphost do Aspire em execução. Stream logs in real-time as they are written. - Stream logs in real-time as they are written. + Transmita logs em tempo real conforme eles são gravados. Output logs in JSON format (NDJSON). - Output logs in JSON format (NDJSON). + Logs de saída no formato JSON (NDJSON). No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Nenhum AppHosts encontrado no diretório atual. Mostrando todos os AppHosts em execução. No resources found. - No resources found. + Nenhum recurso encontrado. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + O caminho para o arquivo de projeto do Aspire AppHost. The name of the resource to get logs for. If not specified, logs from all resources are shown. - The name of the resource to get logs for. If not specified, logs from all resources are shown. + O nome do recurso para o qual obter logs. Se não for especificado, os logs de todos os recursos serão mostrados. A resource name is required when not using --follow. Use --follow to stream logs from all resources. - A resource name is required when not using --follow. Use --follow to stream logs from all resources. + Um nome de recurso é necessário ao não usar --follow. Use --follow para transmitir logs de todos os recursos. Scanning for running AppHosts... - Scanning for running AppHosts... + Verificando se há AppHosts em execução... Select an AppHost: - Select an AppHost: + Selecione um AppHost: The --tail value must be a positive number. - The --tail value must be a positive number. + O valor --tail deve ser um número positivo. Number of lines to show from the end of logs (default: all). - Number of lines to show from the end of logs (default: all). + Número de linhas a serem mostradas a partir do final dos logs (padrão: todos). The --tail option requires a resource name to be specified. - The --tail option requires a resource name to be specified. + A opção --tail requer que um nome de recurso seja especificado. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf index 2fdc5f0bb16..712fda63d81 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf @@ -4,72 +4,72 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Запущенные хосты приложений не найдены. Сначала запустите один из них с помощью команды "aspire run". Display logs from resources in a running Aspire apphost. - Display logs from resources in a running Aspire apphost. + Отображать журналы ресурсов в запущенном хосте приложений Aspire. Stream logs in real-time as they are written. - Stream logs in real-time as they are written. + Потоковая передача журналов в реальном времени по мере их записи. Output logs in JSON format (NDJSON). - Output logs in JSON format (NDJSON). + Вывод журналов в формате JSON (NDJSON). No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Хосты приложений не найдены в текущем каталоге. Отображаются все запущенные хосты приложений. No resources found. - No resources found. + Ресурсы не найдены. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Путь к файлу проекта Aspire AppHost. The name of the resource to get logs for. If not specified, logs from all resources are shown. - The name of the resource to get logs for. If not specified, logs from all resources are shown. + Имя ресурса, для которого нужно получить журналы. Если не указано, отображаются журналы из всех ресурсов. A resource name is required when not using --follow. Use --follow to stream logs from all resources. - A resource name is required when not using --follow. Use --follow to stream logs from all resources. + При отсутствии параметра --follow необходимо указать имя ресурса. Используйте --follow для потоковой передачи журналов из всех ресурсов. Scanning for running AppHosts... - Scanning for running AppHosts... + Выполняется сканирование на наличие запущенных хостов приложений... Select an AppHost: - Select an AppHost: + Выберите хост приложения: The --tail value must be a positive number. - The --tail value must be a positive number. + Значение --tail должно быть положительным числом. Number of lines to show from the end of logs (default: all). - Number of lines to show from the end of logs (default: all). + Количество строк, отображаемых с конца журналов (по умолчанию: все). The --tail option requires a resource name to be specified. - The --tail option requires a resource name to be specified. + Параметр --tail требует указания имени ресурса. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf index b92cbfdf0ee..69c26c885f7 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf @@ -4,72 +4,72 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Çalışan AppHost bulunamadı. Önce birini başlatmak için 'aspire run' komutunu kullanın. Display logs from resources in a running Aspire apphost. - Display logs from resources in a running Aspire apphost. + Çalışan bir Aspire apphost'taki kaynakların günlüklerini görüntüle. Stream logs in real-time as they are written. - Stream logs in real-time as they are written. + Günlükleri yazıldıkça gerçek zamanlı olarak akışla aktar. Output logs in JSON format (NDJSON). - Output logs in JSON format (NDJSON). + Günlükleri JSON biçiminde (NDJSON) çıkar. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Geçerli dizinde AppHost bulunamadı. Tüm çalışan AppHost'lar gösteriliyor. No resources found. - No resources found. + Kaynak bulunamadı. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost proje dosyasının yolu. The name of the resource to get logs for. If not specified, logs from all resources are shown. - The name of the resource to get logs for. If not specified, logs from all resources are shown. + Günlükleri alınacak kaynağın adı. Belirtilmezse tüm kaynakların günlükleri gösterilir. A resource name is required when not using --follow. Use --follow to stream logs from all resources. - A resource name is required when not using --follow. Use --follow to stream logs from all resources. + --follow kullanılmadığında kaynak adı gereklidir. Tüm kaynaklardan günlük akışı yapmak için --follow kullanın. Scanning for running AppHosts... - Scanning for running AppHosts... + Çalışan AppHost'lar taranıyor... Select an AppHost: - Select an AppHost: + AppHost seçin: The --tail value must be a positive number. - The --tail value must be a positive number. + --tail değeri, pozitif bir sayı olmalıdır. Number of lines to show from the end of logs (default: all). - Number of lines to show from the end of logs (default: all). + Günlüklerin sonundan gösterilecek satır sayısı (varsayılan: tümü). The --tail option requires a resource name to be specified. - The --tail option requires a resource name to be specified. + --tail seçeneği için bir kaynak adı belirtilmelidir. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf index fe8910aa88b..5865e822bb0 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf @@ -4,72 +4,72 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + 找不到正在运行的 AppHost。请先使用 "aspire run" 启动一个。 Display logs from resources in a running Aspire apphost. - Display logs from resources in a running Aspire apphost. + 显示正在运行的 Aspire 应用主机中资源的日志。 Stream logs in real-time as they are written. - Stream logs in real-time as they are written. + 在写入日志时实时流式传输这些日志。 Output logs in JSON format (NDJSON). - Output logs in JSON format (NDJSON). + 以 JSON 格式输出日志(NDJSON)。 No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + 当前目录中未找到 AppHost。显示所有正在运行的 AppHost。 No resources found. - No resources found. + 未找到资源。 The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost 项目文件的路径。 The name of the resource to get logs for. If not specified, logs from all resources are shown. - The name of the resource to get logs for. If not specified, logs from all resources are shown. + 要获取其日志的资源的名称。如果未指定,则显示所有资源的日志。 A resource name is required when not using --follow. Use --follow to stream logs from all resources. - A resource name is required when not using --follow. Use --follow to stream logs from all resources. + 不使用 --follow 时必须指定资源名称。使用 --follow 流式传输所有资源的日志。 Scanning for running AppHosts... - Scanning for running AppHosts... + 正在扫描处于运行状态的 AppHost... Select an AppHost: - Select an AppHost: + 选择 AppHost: The --tail value must be a positive number. - The --tail value must be a positive number. + --tail 值必须为正数。 Number of lines to show from the end of logs (default: all). - Number of lines to show from the end of logs (default: all). + 要在日志末尾显示的行数(默认: 全部)。 The --tail option requires a resource name to be specified. - The --tail option requires a resource name to be specified. + --tail 选项需要指定资源名称。 diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf index 38cca5b5ce7..d20fc20dca7 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf @@ -4,72 +4,72 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + 找不到正在執行的 AppHost。請先使用 'aspire run' 啟動一個。 Display logs from resources in a running Aspire apphost. - Display logs from resources in a running Aspire apphost. + 顯示正在執行 Aspire Apphost 中的資源記錄。 Stream logs in real-time as they are written. - Stream logs in real-time as they are written. + 在寫入記錄時即時串流這些記錄。 Output logs in JSON format (NDJSON). - Output logs in JSON format (NDJSON). + 輸出記錄採用 JSON 格式 (NDJSON)。 No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + 在目前的目錄中找不到 AppHost。顯示所有正在執行的 AppHost。 No resources found. - No resources found. + 找不到任何資源。 The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost 專案檔案的路徑。 The name of the resource to get logs for. If not specified, logs from all resources are shown. - The name of the resource to get logs for. If not specified, logs from all resources are shown. + 要取得其記錄的資源名稱。如果未指定,則顯示所有資源的記錄。 A resource name is required when not using --follow. Use --follow to stream logs from all resources. - A resource name is required when not using --follow. Use --follow to stream logs from all resources. + 當未使用 --follow 時,必須指定資源名稱。使用 --follow 從所有資源串流記錄。 Scanning for running AppHosts... - Scanning for running AppHosts... + 正在掃描執行中的 AppHost... Select an AppHost: - Select an AppHost: + 選取 AppHost: The --tail value must be a positive number. - The --tail value must be a positive number. + --tail 值必須是正數。 Number of lines to show from the end of logs (default: all). - Number of lines to show from the end of logs (default: all). + 從記錄末端顯示的行數 (預設: 全部)。 The --tail option requires a resource name to be specified. - The --tail option requires a resource name to be specified. + --tail 選項需要指定資源名稱。 diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.cs.xlf index 42acd18730b..8971878c481 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.cs.xlf @@ -4,12 +4,12 @@ Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. - Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. + Upozornění: Příkazy „aspire mcp“ jsou zastaralé a v budoucí verzi se odeberou. Použijte místo toho „aspire agent“. Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') - Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') + Spravujte server MCP (Model Context Protocol). (zastaralé, použijte „agent“) @@ -39,7 +39,7 @@ Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') - Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') + Inicializujte konfiguraci serveru MCP pro detekovaná prostředí agentů. (zastaralé, použijte „agent init“) @@ -69,7 +69,7 @@ Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') - Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') + Spusťte server MCP (Model Context Protocol). (zastaralé, použijte „agent mcp“) diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.de.xlf index 52947b1e1c0..520d43c5744 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.de.xlf @@ -4,12 +4,12 @@ Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. - Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. + Warnung: Die Befehle „aspire mcp“ sind veraltet und werden in einer zukünftigen Version entfernt. Bitte verwenden Sie stattdessen „aspire agent“. Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') - Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') + Verwalten Sie den MCP-Server (Model Context Protocol). (Veraltet, stattdessen „agent“ verwenden) @@ -39,7 +39,7 @@ Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') - Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') + Initialisieren Sie die MCP-Serverkonfiguration für erkannte agentische Umgebungen. (veraltet, stattdessen „agent init“ verwenden) @@ -69,7 +69,7 @@ Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') - Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') + Starten Sie den MCP-Server (Model Context Protocol). (veraltet, stattdessen „agent mcp“ verwenden) diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.es.xlf index 64d38a20d90..ce9e8371562 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.es.xlf @@ -4,12 +4,12 @@ Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. - Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. + Advertencia: los comandos "mcp" están en desuso y se quitarán en una versión futura. En su lugar, use "agente aspire" en su lugar. Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') - Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') + Administrar el servidor MCP (protocolo de contexto de modelo). (en desuso, use ''agent'') @@ -39,7 +39,7 @@ Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') - Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') + Inicialice la configuración del servidor MCP para los entornos de agentes detectados. (en desuso, use ''agent init'') @@ -69,7 +69,7 @@ Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') - Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') + Inicie el servidor MCP (protocolo de contexto de modelo). (en desuso, use "agent mcp") diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.fr.xlf index 1891d421075..3538476e647 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.fr.xlf @@ -4,12 +4,12 @@ Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. - Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. + Avertissement : les commandes « aspire mcp » sont obsolètes et seront supprimées dans une prochaine version. Veuillez utiliser « aspire agent » à la place. Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') - Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') + Gérez le serveur MCP (Model Context Protocol). (obsolète, utilisez « agent ») @@ -39,7 +39,7 @@ Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') - Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') + Initialiser la configuration du serveur MCP pour les environnements d’agent détectés. (obsolète, utilisez « agent init ») @@ -69,7 +69,7 @@ Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') - Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') + Démarrer le serveur MCP (Model Context Protocol). (obsolète, utilisez « agent mcp ») diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.it.xlf index cdf88dd2f0e..4d0349bcd93 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.it.xlf @@ -4,12 +4,12 @@ Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. - Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. + Avviso: i comandi "aspire mcp" sono deprecati e verranno rimossi in una versione futura. In alternativa, usare "aspire agent". Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') - Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') + Consente di gestire il server MCP (Model Context Protocol). (deprecato, usare "agent") @@ -39,7 +39,7 @@ Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') - Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') + Consente di inizializzare la configurazione del server MCP per gli ambienti agente rilevati. (deprecato, usare "agent init") @@ -69,7 +69,7 @@ Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') - Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') + Consente di avviare il server MCP (Model Context Protocol). (deprecato, usare "agent mcp") diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ja.xlf index 8554550f970..fc8afa58fbe 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ja.xlf @@ -4,12 +4,12 @@ Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. - Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. + 警告: 'aspire mcp' コマンドは非推奨扱いになっており、将来のリリースで削除される予定です。代わりに 'aspire agent' を使用してください。 Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') - Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') + MCP (モデル コンテキスト プロトコル) サーバーを管理します。(非推奨です。'agent' を使用してください) @@ -39,7 +39,7 @@ Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') - Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') + 検出されたエージェント環境の MCP サーバー構成を初期化します。(非推奨です。'agent init' を使用してください) @@ -69,7 +69,7 @@ Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') - Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') + MCP (モデル コンテキスト プロトコル) サーバーを起動します。(非推奨です。'agent mcp' を使用してください) diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ko.xlf index 4e7a02a8cde..ff56c690c0c 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ko.xlf @@ -4,12 +4,12 @@ Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. - Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. + 경고: 'aspire mcp' 명령은 더 이상 사용되지 않으며 향후 릴리스에서 제거될 예정입니다. 대신 'aspire agent'를 사용하세요. Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') - Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') + MCP(모델 컨텍스트 프로토콜) 서버를 관리합니다. (더 이상 사용되지 않음, 'agent' 사용) @@ -39,7 +39,7 @@ Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') - Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') + 검색된 에이전트 환경에 대한 MCP 서버 구성을 초기화합니다. (더 이상 사용되지 않음, 'agent init' 사용) @@ -69,7 +69,7 @@ Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') - Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') + MCP(모델 컨텍스트 프로토콜) 서버를 시작합니다. (더 이상 사용되지 않음, 'agent mcp' 사용) diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pl.xlf index 281c754e70f..44e5c0d32b0 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pl.xlf @@ -4,12 +4,12 @@ Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. - Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. + Ostrzeżenie: polecenia „aspire mcp” są przestarzałe i zostaną usunięte w przyszłym wydaniu. Użyj zamiast nich polecenia „aspire agent”. Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') - Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') + Zarządzaj serwerem MCP (Model Context Protocol). (przestarzałe, użyj polecenia „agent”) @@ -39,7 +39,7 @@ Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') - Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') + Zainicjuj konfigurację serwera MCP dla wykrytych środowisk agentów. (przestarzałe, użyj polecenia „agent init”) @@ -69,7 +69,7 @@ Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') - Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') + Uruchom serwer MCP (Model Context Protocol). (przestarzałe, użyj polecenia „agent mcp”) diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pt-BR.xlf index 8a182a409a5..b6a7a8f1bca 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.pt-BR.xlf @@ -4,12 +4,12 @@ Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. - Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. + Aviso: os comandos "aspire mcp" estão obsoletos e serão removidos em uma versão futura. Use "aspire agent" em vez disso. Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') - Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') + Gerencie o servidor MCP (Protocolo de Contexto de Modelo). (obsoleto, usar "agent") @@ -39,7 +39,7 @@ Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') - Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') + Inicialize a configuração do servidor MCP para ambientes de agente detectados. (obsoleto, usar "agent init") @@ -69,7 +69,7 @@ Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') - Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') + Inicie o servidor MCP (Protocolo de Contexto de Modelo). (obsoleto, usar "agent mcp") diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ru.xlf index ff48ca8e9a3..961b13be6e8 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.ru.xlf @@ -4,12 +4,12 @@ Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. - Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. + Внимание: команды "aspire mcp" являются нерекомендуемыми и будут удалены в будущем выпуске. Используйте вместо них "aspire agent". Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') - Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') + Управление сервером MCP. (не рекомендуется, используйте "agent"") @@ -39,7 +39,7 @@ Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') - Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') + Инициализация конфигурации сервера MCP для обнаруженных сред агентов. (не рекомендуется, используйте "agent init") @@ -69,7 +69,7 @@ Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') - Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') + Запуск сервера MCP. (не рекомендуется, используйте "agent mcp") diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.tr.xlf index 6184a7bf001..c5fe900d32b 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.tr.xlf @@ -4,12 +4,12 @@ Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. - Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. + Uyarı: 'aspire mcp' komutları kullanım dışıdır ve gelecekteki bir sürümde kaldırılacaktır. Lütfen bunun yerine 'aspire agent' kullanın. Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') - Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') + MCP (Model Bağlam Protokolü) sunucusunu yönetin. (kullanım dışı, 'agent' kullanın) @@ -39,7 +39,7 @@ Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') - Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') + Algılanan aracı ortamları için MCP sunucu yapılandırmasını başlatın. (kullanım dışı, 'agent init' kullanın) @@ -69,7 +69,7 @@ Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') - Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') + MCP (Model Bağlam Protokolü) sunucusunu başlat. (kullanım dışı, 'agent mcp' kullanın) diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hans.xlf index 9c9eb80e0fc..b037114d647 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hans.xlf @@ -4,12 +4,12 @@ Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. - Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. + 警告: "aspire mcp" 命令已弃用,将在未来版本中移除。请改用 "aspire agent"。 Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') - Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') + 管理 MCP (模型上下文协议)服务器。(已弃用,请使用 "agent") @@ -39,7 +39,7 @@ Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') - Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') + 初始化检测到的智能体环境的 MCP 服务器配置。(已弃用,请使用 "agent init") @@ -69,7 +69,7 @@ Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') - Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') + 启动 MCP (模型上下文协议)服务器。(已弃用,请使用 "agent mcp") diff --git a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hant.xlf index f57fbba26ff..77d090531fe 100644 --- a/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/McpCommandStrings.zh-Hant.xlf @@ -4,12 +4,12 @@ Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. - Warning: 'aspire mcp' commands are deprecated and will be removed in a future release. Please use 'aspire agent' instead. + 警告: 'aspire mcp' 命令已棄用,並將於未來版本中移除。請改用 'aspire agent'。 Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') - Manage MCP (Model Context Protocol) server. (deprecated, use 'agent') + 管理 MCP (模型內容通訊協定) 伺服器。(已棄用,請使用 'agent') @@ -39,7 +39,7 @@ Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') - Initialize MCP server configuration for detected agent environments. (deprecated, use 'agent init') + 為偵測到的代理程式環境初始化 MCP 伺服器設定。(已棄用,請使用 'agent init') @@ -69,7 +69,7 @@ Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') - Start the MCP (Model Context Protocol) server. (deprecated, use 'agent mcp') + 啟動 MCP (模型內容通訊協定) 伺服器。(已棄用,請使用 'agent mcp') diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.cs.xlf index 684ef362561..c7b342fb973 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.cs.xlf @@ -4,57 +4,57 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Nenašel se žádný spuštěný hostitel aplikací. Nejprve spusťte spuštění pomocí příkazu „aspire run“. Display resource snapshots from a running Aspire apphost. - Display resource snapshots from a running Aspire apphost. + Umožňuje zobrazit snímky prostředků ze spuštěného hostitele aplikací Aspire. Output in JSON format for machine consumption. - Output in JSON format for machine consumption. + Výstup ve formátu JSON pro spotřebu počítače. No AppHost project found. - No AppHost project found. + Nenašel se žádný projekt hostitele aplikací. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + V aktuálním adresáři se nenašli žádní hostitelé aplikací. Zobrazují se všichni spuštění hostitelé aplikací. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Cesta k souboru projektu Aspire AppHost. The name of the resource to display. If not specified, all resources are shown. - The name of the resource to display. If not specified, all resources are shown. + Název prostředku, který se má zobrazit. Pokud se nezadá, zobrazí se všechny prostředky. Resource '{0}' not found. - Resource '{0}' not found. + Prostředek {0} nebyl nalezen. Scanning for running AppHosts... - Scanning for running AppHosts... + Vyhledávání spuštěných hostitelů aplikací... Select an AppHost: - Select an AppHost: + Vyberte hostitele aplikací: Stream resource snapshots as they change (NDJSON format when used with --json). - Stream resource snapshots as they change (NDJSON format when used with --json). + Streamování snímků prostředků při změně (formát NDJSON při použití s parametrem --json). diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.de.xlf index 58b2c952c34..3407fa794fb 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.de.xlf @@ -4,57 +4,57 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Es wurde kein ausgeführter AppHost gefunden. Verwenden Sie zuerst „aspire run“, um einen zu starten. Display resource snapshots from a running Aspire apphost. - Display resource snapshots from a running Aspire apphost. + Zeigen Sie Ressourcenmomentaufnahmen von einem laufenden Aspire-AppHost an. Output in JSON format for machine consumption. - Output in JSON format for machine consumption. + Ausgabe im JSON-Format zur maschinellen Verarbeitung. No AppHost project found. - No AppHost project found. + Kein AppHost-Projekt gefunden. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Im aktuellen Verzeichnis wurden keine AppHosts gefunden. Es werden alle aktiven AppHosts angezeigt. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Der Pfad zur Aspire AppHost-Projektdatei. The name of the resource to display. If not specified, all resources are shown. - The name of the resource to display. If not specified, all resources are shown. + Der Name der anzuzeigenden Ressource. Wenn keine Angabe erfolgt, werden alle Ressourcen angezeigt. Resource '{0}' not found. - Resource '{0}' not found. + Die Ressource "{0}" wurde nicht gefunden. Scanning for running AppHosts... - Scanning for running AppHosts... + Suche nach aktiven AppHosts … Select an AppHost: - Select an AppHost: + AppHost auswählen: Stream resource snapshots as they change (NDJSON format when used with --json). - Stream resource snapshots as they change (NDJSON format when used with --json). + Streamen Sie Ressourcenmomentaufnahmen, während sie sich ändern (NDJSON-Format bei Verwendung mit --json). diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.es.xlf index c88aa4d012a..6eb9dc07349 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.es.xlf @@ -4,57 +4,57 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + No se encontró ningún AppHost en ejecución. Usa 'aspire run' para iniciar uno primero. Display resource snapshots from a running Aspire apphost. - Display resource snapshots from a running Aspire apphost. + Muestra instantáneas de recursos de un apphost de Aspire en ejecución. Output in JSON format for machine consumption. - Output in JSON format for machine consumption. + Salida en formato JSON para el consumo de la máquina. No AppHost project found. - No AppHost project found. + No se encontró ningún proyecto de AppHost. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + No se encontró ningún AppHosts en el directorio actual. Mostrando todos los AppHosts en ejecución. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + La ruta de acceso al archivo del proyecto host de la AppHost Aspire. The name of the resource to display. If not specified, all resources are shown. - The name of the resource to display. If not specified, all resources are shown. + Nombre del recurso que se va a mostrar. Si no se especifica, se muestran todos los recursos. Resource '{0}' not found. - Resource '{0}' not found. + No se encuentra el recurso '{0}'. Scanning for running AppHosts... - Scanning for running AppHosts... + Buscando AppHosts en ejecución... Select an AppHost: - Select an AppHost: + Seleccione un AppHost: Stream resource snapshots as they change (NDJSON format when used with --json). - Stream resource snapshots as they change (NDJSON format when used with --json). + Transmita instantáneas de recursos a medida que cambian (formato NDJSON cuando se usa con --json). diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.fr.xlf index da86f968236..a1c90103ee8 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.fr.xlf @@ -4,57 +4,57 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Désolé, aucun AppHost en cours d’exécution n’a été trouvé. Utilisez « aspire run » pour en démarrer un. Display resource snapshots from a running Aspire apphost. - Display resource snapshots from a running Aspire apphost. + Afficher les instantanés de ressource d’un Apphost Aspire en cours d’exécution. Output in JSON format for machine consumption. - Output in JSON format for machine consumption. + Générer la sortie au format JSON pour traitement automatique. No AppHost project found. - No AppHost project found. + Désolé, aucun projet AppHost n’a été trouvé. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Désolé, aucun AppHosts n’a été trouvé dans le répertoire actif. Affichage de tous les AppHosts en cours d’exécution. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Chemin d’accès au fichier projet AppHost Aspire. The name of the resource to display. If not specified, all resources are shown. - The name of the resource to display. If not specified, all resources are shown. + Le nom de la ressource à afficher. Si aucun nom n’est spécifié, toutes les ressources sont affichées. Resource '{0}' not found. - Resource '{0}' not found. + Ressource « {0} » introuvable. Scanning for running AppHosts... - Scanning for running AppHosts... + Recherche des AppHosts en cours d’exécution... Select an AppHost: - Select an AppHost: + Sélectionner un AppHost : Stream resource snapshots as they change (NDJSON format when used with --json). - Stream resource snapshots as they change (NDJSON format when used with --json). + Diffuser les instantanés de ressource au fur et à mesure de leur modification (format NDJSON lorsqu’il est utilisé avec --json). diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.it.xlf index c32736122ae..d1487094a85 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.it.xlf @@ -4,57 +4,57 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Non è stato trovato alcun AppHost in esecuzione. Usare prima di tutto "aspire run" per avviarne uno. Display resource snapshots from a running Aspire apphost. - Display resource snapshots from a running Aspire apphost. + Consente di visualizzare gli snapshot delle risorse da un apphost Aspire in esecuzione. Output in JSON format for machine consumption. - Output in JSON format for machine consumption. + Output in formato JSON per l'utilizzo da parte del computer. No AppHost project found. - No AppHost project found. + Nessun progetto AppHost trovato. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Nessun AppHost trovato nella directory corrente. Visualizzazione di tutti gli AppHost in esecuzione. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Percorso del file di un progetto AppHost di Aspire. The name of the resource to display. If not specified, all resources are shown. - The name of the resource to display. If not specified, all resources are shown. + Nome della risorsa da visualizzare. Se non è specificato, vengono visualizzate tutte le risorse. Resource '{0}' not found. - Resource '{0}' not found. + Risorsa '{0}' non trovata. Scanning for running AppHosts... - Scanning for running AppHosts... + Analisi per l'esecuzione di AppHosts in corso... Select an AppHost: - Select an AppHost: + Selezionare un AppHost: Stream resource snapshots as they change (NDJSON format when used with --json). - Stream resource snapshots as they change (NDJSON format when used with --json). + Consente di trasmettere gli snapshot delle risorse man mano che cambiano (formato NDJSON se usato con --json). diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ja.xlf index 64d2fba9c84..396dec5e024 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ja.xlf @@ -4,57 +4,57 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + 実行中の AppHost は見つかりません。最初に 'aspire run' を使って起動してください。 Display resource snapshots from a running Aspire apphost. - Display resource snapshots from a running Aspire apphost. + 実行中の Aspire apphost からのリソース スナップショットを表示します。 Output in JSON format for machine consumption. - Output in JSON format for machine consumption. + 機械処理用の JSON 形式で出力します。 No AppHost project found. - No AppHost project found. + AppHost プロジェクトは見つかりません。 No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + 現在のディレクトリ内に AppHost が見つかりません。実行中のすべての AppHost を表示しています。 The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost プロジェクト ファイルへのパス。 The name of the resource to display. If not specified, all resources are shown. - The name of the resource to display. If not specified, all resources are shown. + 表示するリソースの名前。指定しない場合は、すべてのリソースが表示されます。 Resource '{0}' not found. - Resource '{0}' not found. + リソース '{0}' が見つかりません。 Scanning for running AppHosts... - Scanning for running AppHosts... + 実行中の AppHost をスキャンしています... Select an AppHost: - Select an AppHost: + AppHost を選択: Stream resource snapshots as they change (NDJSON format when used with --json). - Stream resource snapshots as they change (NDJSON format when used with --json). + 変化が発生したときにリソース スナップショットをストリームします (--json と組み合わせた場合は NDJSON 形式)。 diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ko.xlf index 1ffc0584a5a..25a5ea15eed 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ko.xlf @@ -4,57 +4,57 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + 실행 중인 AppHost를 찾을 수 없습니다. 'aspire run'을 사용하여 먼저 하나를 시작합니다. Display resource snapshots from a running Aspire apphost. - Display resource snapshots from a running Aspire apphost. + 실행 중인 Aspire AppHost의 리소스 스냅샷을 표시합니다. Output in JSON format for machine consumption. - Output in JSON format for machine consumption. + 컴퓨터 사용량에 대한 JSON형식의 출력입니다. No AppHost project found. - No AppHost project found. + AppHost 프로젝트를 찾을 수 없습니다. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + 현재 디렉터리에 AppHost가 없습니다. 실행 중인 모든 AppHost를 표시하는 중입니다. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost 프로젝트 파일의 경로입니다. The name of the resource to display. If not specified, all resources are shown. - The name of the resource to display. If not specified, all resources are shown. + 표시할 리소스의 이름입니다. 지정하지 않으면 모든 리소스가 표시됩니다. Resource '{0}' not found. - Resource '{0}' not found. + '{0}' 리소스를 찾을 수 없습니다. Scanning for running AppHosts... - Scanning for running AppHosts... + 실행 중인 AppHost를 검색하는 중... Select an AppHost: - Select an AppHost: + AppHost 선택: Stream resource snapshots as they change (NDJSON format when used with --json). - Stream resource snapshots as they change (NDJSON format when used with --json). + 리소스 스냅샷이 변경될 때 스트림으로 전송합니다(--json과 함께 사용 시 NDJSON 형식). diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.pl.xlf index 9439c845e32..a2f3f172672 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.pl.xlf @@ -4,57 +4,57 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Nie znaleziono działającego hosta AppHost. Najpierw uruchom go poleceniem „aspire run”. Display resource snapshots from a running Aspire apphost. - Display resource snapshots from a running Aspire apphost. + Wyświetl migawki zasobów z działającego hosta aplikacji Aspire. Output in JSON format for machine consumption. - Output in JSON format for machine consumption. + Wynik w formacie JSON do przetwarzania przez maszynę. No AppHost project found. - No AppHost project found. + Nie znaleziono projektu AppHost. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Nie znaleziono hostów aplikacji w bieżącym katalogu. Wyświetlanie wszystkich uruchomionych hostów aplikacji. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Ścieżka do pliku projektu hosta AppHost platformy Aspire. The name of the resource to display. If not specified, all resources are shown. - The name of the resource to display. If not specified, all resources are shown. + Nazwa zasobu do wyświetlenia. Jeśli nie zostanie określony, zostaną wyświetlone wszystkie zasoby. Resource '{0}' not found. - Resource '{0}' not found. + Nie znaleziono „{0}” zasobu. Scanning for running AppHosts... - Scanning for running AppHosts... + Skanowanie w poszukiwaniu uruchomionych hostów aplikacji... Select an AppHost: - Select an AppHost: + Wybierz hosta aplikacji: Stream resource snapshots as they change (NDJSON format when used with --json). - Stream resource snapshots as they change (NDJSON format when used with --json). + Strumieniuj migawki zasobów w miarę ich zmian (format NDJSON przy użyciu opcji --json). diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.pt-BR.xlf index 147d025d84d..492c5e8be4d 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.pt-BR.xlf @@ -4,57 +4,57 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Nenhum AppHost em execução encontrado. Use "aspire run" para iniciar um primeiro. Display resource snapshots from a running Aspire apphost. - Display resource snapshots from a running Aspire apphost. + Exiba instantâneos de recursos de um apphost do Aspire em execução. Output in JSON format for machine consumption. - Output in JSON format for machine consumption. + Saída no formato JSON para consumo do computador. No AppHost project found. - No AppHost project found. + Nenhum projeto AppHost encontrado. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Nenhum AppHosts encontrado no diretório atual. Mostrando todos os AppHosts em execução. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + O caminho para o arquivo de projeto do Aspire AppHost. The name of the resource to display. If not specified, all resources are shown. - The name of the resource to display. If not specified, all resources are shown. + O nome do recurso a ser exibido. Se não for especificado, todos os recursos serão mostrados. Resource '{0}' not found. - Resource '{0}' not found. + O recurso “{0}” não foi encontrado. Scanning for running AppHosts... - Scanning for running AppHosts... + Verificando se há AppHosts em execução... Select an AppHost: - Select an AppHost: + Selecione um AppHost: Stream resource snapshots as they change (NDJSON format when used with --json). - Stream resource snapshots as they change (NDJSON format when used with --json). + Transmita instantâneos de recursos conforme eles são alterados (formato NDJSON quando usado com --json). diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ru.xlf index ef3eaa9b88f..0f34b6164d8 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ru.xlf @@ -4,57 +4,57 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Запущенные хосты приложений не найдены. Сначала запустите один из них с помощью команды "aspire run". Display resource snapshots from a running Aspire apphost. - Display resource snapshots from a running Aspire apphost. + Отображать моментальные снимки ресурсов из запущенного хоста приложений Aspire. Output in JSON format for machine consumption. - Output in JSON format for machine consumption. + Вывод в формате JSON для потребления компьютером. No AppHost project found. - No AppHost project found. + Проект хоста приложений не найден. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Хосты приложений не найдены в текущем каталоге. Отображаются все запущенные хосты приложений. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Путь к файлу проекта Aspire AppHost. The name of the resource to display. If not specified, all resources are shown. - The name of the resource to display. If not specified, all resources are shown. + Имя ресурса для отображения. Если не указано, отображаются все ресурсы. Resource '{0}' not found. - Resource '{0}' not found. + Ресурс "{0}" не найден. Scanning for running AppHosts... - Scanning for running AppHosts... + Выполняется сканирование на наличие запущенных хостов приложений... Select an AppHost: - Select an AppHost: + Выберите хост приложения: Stream resource snapshots as they change (NDJSON format when used with --json). - Stream resource snapshots as they change (NDJSON format when used with --json). + Потоковая передача моментальных снимков ресурсов по мере их изменения (формат NDJSON при использовании с --json). diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.tr.xlf index 545619f2637..0bcfe9fd0d6 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.tr.xlf @@ -4,57 +4,57 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Çalışan AppHost bulunamadı. Önce birini başlatmak için 'aspire run' komutunu kullanın. Display resource snapshots from a running Aspire apphost. - Display resource snapshots from a running Aspire apphost. + Çalışan bir Aspire apphost'tan kaynak anlık görüntülerini göster. Output in JSON format for machine consumption. - Output in JSON format for machine consumption. + Makine tarafından kullanılmak üzere JSON biçiminde çıktı ver. No AppHost project found. - No AppHost project found. + AppHost projesi bulunamadı. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Geçerli dizinde AppHost bulunamadı. Tüm çalışan AppHost'lar gösteriliyor. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost proje dosyasının yolu. The name of the resource to display. If not specified, all resources are shown. - The name of the resource to display. If not specified, all resources are shown. + Görüntülenecek kaynağın adı. Belirtilmezse tüm kaynaklar gösterilir. Resource '{0}' not found. - Resource '{0}' not found. + '{0}' kaynağı bulunamadı. Scanning for running AppHosts... - Scanning for running AppHosts... + Çalışan AppHost'lar taranıyor... Select an AppHost: - Select an AppHost: + AppHost seçin: Stream resource snapshots as they change (NDJSON format when used with --json). - Stream resource snapshots as they change (NDJSON format when used with --json). + Kaynak anlık görüntülerini değiştikçe akışla aktarın (--json ile kullanıldığında NDJSON biçimi). diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.zh-Hans.xlf index 2fc1080aa5e..d5be650fc41 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.zh-Hans.xlf @@ -4,57 +4,57 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + 找不到正在运行的 AppHost。请先使用 "aspire run" 启动一个。 Display resource snapshots from a running Aspire apphost. - Display resource snapshots from a running Aspire apphost. + 显示正在运行的 Aspire 应用主机中的资源快照。 Output in JSON format for machine consumption. - Output in JSON format for machine consumption. + 以 JSON 格式输出,供计算机使用。 No AppHost project found. - No AppHost project found. + 找不到 AppHost 项目。 No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + 当前目录中未找到 AppHost。显示所有正在运行的 AppHost。 The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost 项目文件的路径。 The name of the resource to display. If not specified, all resources are shown. - The name of the resource to display. If not specified, all resources are shown. + 要显示的资源名称。如果未指定,则显示所有资源。 Resource '{0}' not found. - Resource '{0}' not found. + 找不到资源“{0}”。 Scanning for running AppHosts... - Scanning for running AppHosts... + 正在扫描处于运行状态的 AppHost... Select an AppHost: - Select an AppHost: + 选择 AppHost: Stream resource snapshots as they change (NDJSON format when used with --json). - Stream resource snapshots as they change (NDJSON format when used with --json). + 在资源快照改变时流式传输这些快照(与 --json 一起使用时为 NDJSON 格式)。 diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.zh-Hant.xlf index 2165bbbab46..a5f1fe7554d 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.zh-Hant.xlf @@ -4,57 +4,57 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + 找不到正在執行的 AppHost。請先使用 'aspire run' 啟動一個。 Display resource snapshots from a running Aspire apphost. - Display resource snapshots from a running Aspire apphost. + 顯示正在執行的 Aspire AppHost 的資源快照集。 Output in JSON format for machine consumption. - Output in JSON format for machine consumption. + 輸出為 JSON 格式供機器使用。 No AppHost project found. - No AppHost project found. + 找不到 AppHost 專案。 No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + 在目前的目錄中找不到 AppHost。顯示所有正在執行的 AppHost。 The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost 專案檔案的路徑。 The name of the resource to display. If not specified, all resources are shown. - The name of the resource to display. If not specified, all resources are shown. + 要顯示的資源名稱。如果未指定,則會顯示所有資源。 Resource '{0}' not found. - Resource '{0}' not found. + 找不到資源 '{0}'。 Scanning for running AppHosts... - Scanning for running AppHosts... + 正在掃描執行中的 AppHost... Select an AppHost: - Select an AppHost: + 選取 AppHost: Stream resource snapshots as they change (NDJSON format when used with --json). - Stream resource snapshots as they change (NDJSON format when used with --json). + 變更時串流資源快照集 (與 --json 一起使用時為 NDJSON 格式)。 diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf index fc8ba95d9c9..4605fd9329d 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf @@ -24,22 +24,22 @@ The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry - Telemetry + Telemetrie --------- -The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. +Rozhraní příkazového řádku Aspire shromažďuje data o využití. Shromažďuje je společnost Microsoft a slouží k vylepšování vašich možností. Výslovný nesouhlas s telemetrií můžete vyjádřit tak, že pomocí vašeho preferovaného prostředí nastavíte proměnnou prostředí ASPIRE_CLI_TELEMETRY_OPTOUT na hodnotu 1 nebo true. -Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry +Další informace o telemetrii rozhraní příkazového řádku Aspire: https://aka.ms/aspire/cli-telemetry Welcome to Aspire! Learn more about Aspire at https://aspire.dev - Welcome to Aspire! Learn more about Aspire at https://aspire.dev + Vítá vás Aspire! Další informace o Aspire najdete na https://aspire.dev Suppress the startup banner and telemetry notice. - Suppress the startup banner and telemetry notice. + Umožňuje potlačit banner spuštění a oznámení o telemetrii. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf index 59f0b2a8054..4fed320a607 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf @@ -24,22 +24,22 @@ The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry - Telemetry + Telemetrie --------- -The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. +Die Aspire-CLI sammelt Nutzungsdaten. Sie wird von Microsoft erfasst und verwendet, um uns bei der Verbesserung des Benutzererlebnisses zu unterstützen. Sie können das Erfassen von Telemetriedaten deaktivieren, indem Sie die Umgebungsvariable ASPIRE_CLI_TELEMETRY_OPTOUT in Ihrer bevorzugten Shell auf „1“ oder TRUE festlegen. -Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry +Weitere Informationen zur Aspire-CLI-Telemetrie finden Sie unter: https://aka.ms/aspire/cli-telemetry Welcome to Aspire! Learn more about Aspire at https://aspire.dev - Welcome to Aspire! Learn more about Aspire at https://aspire.dev + Willkommen bei Aspire! Weitere Informationen zu Aspire finden Sie unter https://aspire.dev Suppress the startup banner and telemetry notice. - Suppress the startup banner and telemetry notice. + Unterdrücken Sie das Startupbanner und den Telemetriehinweis. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf index 3cca393a357..b9984e9957e 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf @@ -24,22 +24,22 @@ The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry - Telemetry + Telemetría --------- -The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. +La CLI de Aspire recopila datos de uso. Lo recopila Microsoft y se usa para ayudarnos a mejorar su experiencia. Puede rechazar la telemetría estableciendo la variable de entorno ASPIRE_CLI_TELEMETRY_OPTOUT en "1" o "true" mediante el shell que prefiera. -Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry +Más información acerca de la telemetría de la CLI de Aspire: https://aka.ms/aspire/cli-telemetry Welcome to Aspire! Learn more about Aspire at https://aspire.dev - Welcome to Aspire! Learn more about Aspire at https://aspire.dev + Bienvenido a Aspire. Obtenga más información sobre Aspire en https://aspire.dev Suppress the startup banner and telemetry notice. - Suppress the startup banner and telemetry notice. + Suprima el banner de inicio y el aviso de telemetría. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf index 4b65a4f8b62..796580876d4 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf @@ -24,22 +24,22 @@ The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry - Telemetry + Télémétrie --------- -The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. +L’interface CLI Aspire collecte des données d’utilisation. Il est collecté par Microsoft et utilisé pour nous aider à améliorer votre expérience. Vous pouvez désactiver la télémétrie en définissant la variable d’environnement ASPIRE_CLI_TELEMETRY_OPTOUT sur « 1 » ou « true » à l’aide de votre shell préféré. -Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry +En savoir plus sur la télémétrie de l’interface CLI Aspire : https://aka.ms/aspire/cli-telemetry Welcome to Aspire! Learn more about Aspire at https://aspire.dev - Welcome to Aspire! Learn more about Aspire at https://aspire.dev + Bienvenue sur Aspire ! En savoir plus sur Aspire à l’adresse https://aspire.dev Suppress the startup banner and telemetry notice. - Suppress the startup banner and telemetry notice. + Supprimer la bannière de démarrage et la notification de télémétrie. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf index 5aa297db26a..8d25fbd20f3 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf @@ -24,22 +24,22 @@ The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry - Telemetry + Dati di telemetria --------- -The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. +L'interfaccia della riga di comando di Aspire raccoglie i dati di utilizzo. Vengono raccolti da Microsoft e usati per contribuire al miglioramento dell'esperienza. È possibile rifiutare esplicitamente la raccolta dei dati di telemetria impostando la variabile di ambiente ASPIRE_CLI_TELEMETRY_OPTOUT su "1" o "true" nella shell preferita. -Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry +Altre informazioni sui dati di telemetria dell'interfaccia della riga di comando di Aspire: https://aka.ms/aspire/cli-telemetry Welcome to Aspire! Learn more about Aspire at https://aspire.dev - Welcome to Aspire! Learn more about Aspire at https://aspire.dev + Vi diamo il benvenuto in Aspire! Per altre informazioni su Aspire, vedere https://aspire.dev Suppress the startup banner and telemetry notice. - Suppress the startup banner and telemetry notice. + Consente di disabilitare il banner di avvio e l'avviso di telemetria. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf index 3ee855bbe18..207378d7605 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf @@ -24,22 +24,22 @@ The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry - Telemetry + テレメトリ --------- -The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. +Aspire CLI は使用状況データを収集します。これは Microsoft によって収集され、エクスペリエンスを向上させるために役立てられます。テレメトリをオプトアウトするには、お好きなシェルを使って、ASPIRE_CLI_TELEMETRY_OPTOUT 環境変数を '1' または 'true' に設定します。 -Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry +Aspire CLI テレメトリの詳細情報: https://aka.ms/aspire/cli-telemetry Welcome to Aspire! Learn more about Aspire at https://aspire.dev - Welcome to Aspire! Learn more about Aspire at https://aspire.dev + Aspire へようこそ!https://aspire.dev で Aspire の詳細情報をご覧ください Suppress the startup banner and telemetry notice. - Suppress the startup banner and telemetry notice. + スタートアップ バナーとテレメトリ通知を非表示にします。 diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf index 6601df73ff3..71de041c0e6 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf @@ -24,22 +24,22 @@ The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry - Telemetry + 원격 분석 --------- -The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. +Aspire CLI는 사용량 현황 데이터를 수집합니다. Microsoft에서는 이를 수집하여 사용자 환경을 개선하는 데 활용합니다. 원하는 셸에서 ASPIRE_CLI_TELEMETRY_OPTOUT 환경 변수를 '1' 또는 'true'로 설정하여 원격 분석을 옵트아웃할 수 있습니다. -Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry +Aspire CLI 원격 분석에 대해 자세히 알아보기: https://aka.ms/aspire/cli-telemetry Welcome to Aspire! Learn more about Aspire at https://aspire.dev - Welcome to Aspire! Learn more about Aspire at https://aspire.dev + Aspire에 오신 것을 환영합니다! https://aspire.dev에서 Aspire에 대해 자세히 알아보기 Suppress the startup banner and telemetry notice. - Suppress the startup banner and telemetry notice. + 시작 배너 및 원격 분석 알림을 표시하지 않습니다. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf index 337463737fb..0997c599b14 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf @@ -24,22 +24,22 @@ The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry - Telemetry + Telemetria --------- -The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. +Interfejs wiersza polecenia usługi Aspire zbiera dane o użyciu. Dane te są zbierane przez firmę Microsoft i pomagają nam ulepszać Twoje doświadczenia. Możesz zrezygnować z telemetrii, ustawiając dla zmiennej środowiskowej DOTNET_CLI_TELEMETRY_OPTOUT wartość „1” lub „true” przy użyciu preferowanej powłoki. -Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry +Dowiedz się więcej o telemetrii wiersza polecenia usługi Aspire: https://aka.ms/aspire/cli-telemetry Welcome to Aspire! Learn more about Aspire at https://aspire.dev - Welcome to Aspire! Learn more about Aspire at https://aspire.dev + Witamy w usłudze Aspire! Dowiedz się więcej o usłudze Aspire na https://aspire.dev Suppress the startup banner and telemetry notice. - Suppress the startup banner and telemetry notice. + Wstrzymaj baner startowy i powiadomienie o telemetrii. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf index 73716cc5e3e..2b276eed5bf 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf @@ -24,22 +24,22 @@ The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry - Telemetry + Telemetria --------- -The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. +A CLI do Aspire coleta dados de uso. Eles são coletados pela Microsoft e usados para nos ajudar a melhorar sua experiência. Você pode recusar a telemetria definindo a variável de ambiente DOTNET_CLI_TELEMETRY_OPTOUT como "1" ou "true" usando seu shell favorito. -Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry +Leia mais sobre a telemetria da CLI do Aspire: https://aka.ms/aspire/cli-telemetry Welcome to Aspire! Learn more about Aspire at https://aspire.dev - Welcome to Aspire! Learn more about Aspire at https://aspire.dev + Bem-vindo(a) ao Aspire! Saiba mais sobre o Aspire em https://aspire.dev Suppress the startup banner and telemetry notice. - Suppress the startup banner and telemetry notice. + Suprima a faixa de inicialização e o aviso de telemetria. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf index 3e7744063b6..5300f6e9c4b 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf @@ -24,22 +24,22 @@ The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry - Telemetry + Телеметрия --------- -The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. +Aspire CLI собирает данные об использовании. Корпорация Майкрософт собирает и использует их для улучшения взаимодействия. Вы можете отказаться от отправки телеметрии, установив значение "1" или "true" для переменной среды ASPIRE_CLI_TELEMETRY_OPTOUT в предпочтительной оболочке. -Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry +Подробнее о телеметрии Aspire CLI: https://aka.ms/aspire/cli-telemetry Welcome to Aspire! Learn more about Aspire at https://aspire.dev - Welcome to Aspire! Learn more about Aspire at https://aspire.dev + Добро пожаловать в Aspire! Подробнее об Aspire см. на странице https://aspire.dev Suppress the startup banner and telemetry notice. - Suppress the startup banner and telemetry notice. + Отключить баннер запуска и уведомление о телеметрии. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf index 3c4d78ffe15..5242d9e48df 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf @@ -24,22 +24,22 @@ The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry - Telemetry ---------- + Telemetri +---------- -The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. +Aspire CLI kullanım verilerini toplar. Microsoft tarafından toplanır ve deneyiminizi geliştirmemize yardımcı olması için kullanılır. Telemetriyi geri çevirmek için tercih ettiğiniz kabuğu kullanarak ASPIRE_CLI_TELEMETRY_OPTOUT ortam değişkenini '1' veya 'true' olarak ayarlayabilirsiniz. -Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry +Aspire CLI telemetrisi hakkında daha fazla bilgi edinin: https://aka.ms/aspire/cli-telemetry Welcome to Aspire! Learn more about Aspire at https://aspire.dev - Welcome to Aspire! Learn more about Aspire at https://aspire.dev + Aspire'a hoş geldiniz! Aspire hakkında daha fazla bilgi için https://aspire.dev adresini ziyaret edin Suppress the startup banner and telemetry notice. - Suppress the startup banner and telemetry notice. + Başlangıç başlığını ve telemetri bildirimini gizle. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf index 3b1801706aa..9305acabf53 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf @@ -24,22 +24,22 @@ The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry - Telemetry + 遥测 --------- -The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. +Aspire CLI 收集使用情况数据。该数据由 Microsoft 收集,用于帮助我们提升你的体验。可以通过使用首选 shell 将环境变量 ASPIRE_CLI_TELEMETRY_OPTOUT 设置为 "1" 或 "true" 来选择退出遥测。 -Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry +详细了解 Aspire CLI 遥测: https://aka.ms/aspire/cli-telemetry Welcome to Aspire! Learn more about Aspire at https://aspire.dev - Welcome to Aspire! Learn more about Aspire at https://aspire.dev + 欢迎使用 Aspire!如需详细了解 Aspire,请访问 https://aspire.dev Suppress the startup banner and telemetry notice. - Suppress the startup banner and telemetry notice. + 抑制显示启动横幅和遥测通知。 diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf index f3444296559..93a273bddad 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf @@ -24,22 +24,22 @@ The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry - Telemetry + 遙測 --------- -The Aspire CLI collects usage data. It is collected by Microsoft and is used to help us improve your experience. You can opt out of telemetry by setting the ASPIRE_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your preferred shell. +Aspire CLI 會收集使用方式資料。它由 Microsoft 收集,用於協助我們改善您的體驗。您可以使用您偏好的殼層,將 ASPIRE_CLI_TELEMETRY_OPTOUT 環境變數設定為 '1' 或 'true',以選擇退出遙測。 -Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry +閱讀關於 Aspire CLI 遙測的詳細資訊: https://aka.ms/aspire/cli-telemetry Welcome to Aspire! Learn more about Aspire at https://aspire.dev - Welcome to Aspire! Learn more about Aspire at https://aspire.dev + 歡迎使用 Azure!請造訪 https://aspire.dev 以了解更多有關 Aspire 的資訊 Suppress the startup banner and telemetry notice. - Suppress the startup banner and telemetry notice. + 抑制啟動橫幅與遙測通知。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf index 7536f1098d2..32c62a9d23c 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf @@ -59,7 +59,7 @@ Copying user secrets for isolated mode... - Copying user secrets for isolated mode... + Kopírování tajných kódů uživatele pro izolovaný režim... @@ -99,7 +99,7 @@ The --format option can only be used together with --detach. - The --format option can only be used together with --detach. + Možnost --format lze použít pouze společně s parametrem --detach. @@ -114,17 +114,17 @@ Output result as JSON (only valid with --detach). - Output result as JSON (only valid with --detach). + Výsledek výstupu ve formátu JSON (platný jenom s parametrem --detach). Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. - Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Spouštějte v izolovaném režimu s náhodnými porty a izolovanými tajnými kódy uživatelů, což umožňuje, aby několik instancí běželo současně. A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. - A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. + Byla nalezena spuštěná instance tohoto hostitele aplikací a bude zastavena. Pokud chcete spustit více izolovaných instancí současně, spusťte je z různých adresářů, jako jsou adresáře pracovního stromu Gitu. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf index 15696f65567..615ff2db354 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf @@ -59,7 +59,7 @@ Copying user secrets for isolated mode... - Copying user secrets for isolated mode... + Benutzergeheimnisse für den isolierten Modus werden kopiert… @@ -99,7 +99,7 @@ The --format option can only be used together with --detach. - The --format option can only be used together with --detach. + Die Option „--format“ kann nur zusammen mit „--detach“ verwendet werden. @@ -114,17 +114,17 @@ Output result as JSON (only valid with --detach). - Output result as JSON (only valid with --detach). + Ausgabeergebnis als JSON (nur gültig mit --detach). Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. - Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Im isolierten Modus mit zufälligen Ports und getrennten Benutzergeheimnissen ausführen, sodass mehrere Instanzen gleichzeitig laufen können. A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. - A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. + Eine laufende Instanz dieses AppHosts wurde gefunden und wird beendet. Um mehrere isolierte Instanzen gleichzeitig auszuführen, starten Sie sie aus verschiedenen Verzeichnissen, zum Beispiel aus Git-Worktree-Verzeichnissen. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf index 67fb3781d84..b7fa0eb6c59 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf @@ -59,7 +59,7 @@ Copying user secrets for isolated mode... - Copying user secrets for isolated mode... + Copiando secretos de usuario para el modo aislado... @@ -99,7 +99,7 @@ The --format option can only be used together with --detach. - The --format option can only be used together with --detach. + La opción --format solo se puede usar junto con --detach. @@ -114,17 +114,17 @@ Output result as JSON (only valid with --detach). - Output result as JSON (only valid with --detach). + Resultado de salida como JSON (solo válido con --detach). Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. - Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Ejecutar en modo aislado con puertos aleatorios y secretos de usuario aislados, lo que permite que varias instancias se ejecuten simultáneamente. A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. - A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. + Se ha encontrado una instancia en ejecución de este AppHost y se detendrá. Para ejecutar varias instancias aisladas simultáneamente, ejecute desde directorios diferentes, como directorios de árbol de trabajo de Git. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf index 35c91f48f4f..96e854bda49 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf @@ -59,7 +59,7 @@ Copying user secrets for isolated mode... - Copying user secrets for isolated mode... + Copie des secrets utilisateur pour le mode isolé... @@ -99,7 +99,7 @@ The --format option can only be used together with --detach. - The --format option can only be used together with --detach. + L’option --format ne peut être utilisée qu’avec --detach. @@ -114,17 +114,17 @@ Output result as JSON (only valid with --detach). - Output result as JSON (only valid with --detach). + Afficher le résultat au format JSON (valide uniquement avec --detach). Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. - Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Exécuter en mode isolé avec des ports aléatoires et des secrets utilisateur isolés, ce qui permet à plusieurs instances de fonctionner simultanément. A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. - A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. + Une instance en cours d’exécution de cet AppHost a été détectée et va être arrêtée. Pour exécuter plusieurs instances isolées simultanément, lancez-les depuis des répertoires différents, comme des répertoires git worktree. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf index b35968d4ac2..264b03995c4 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf @@ -59,7 +59,7 @@ Copying user secrets for isolated mode... - Copying user secrets for isolated mode... + Copia dei segreti utente per la modalità isolata in corso... @@ -99,7 +99,7 @@ The --format option can only be used together with --detach. - The --format option can only be used together with --detach. + L'opzione --format può essere usata solo insieme a --detach. @@ -114,17 +114,17 @@ Output result as JSON (only valid with --detach). - Output result as JSON (only valid with --detach). + Restituisce il risultato in formato JSON (valido solo con --detach). Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. - Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Eseguire in modalità isolata con porte casuali e segreti utente separati, consentendo l'esecuzione simultanea di più istanze. A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. - A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. + È stata trovata un'istanza in esecuzione di questo AppHost e verrà arrestata. Per eseguire più istanze isolate contemporaneamente, avviare l'app da directory diverse, ad esempio dalle directory di un albero di lavoro Git. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf index 6e0eb34e25b..d7e7353efb1 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf @@ -59,7 +59,7 @@ Copying user secrets for isolated mode... - Copying user secrets for isolated mode... + ユーザー シークレットを分離モード用にコピーしています... @@ -99,7 +99,7 @@ The --format option can only be used together with --detach. - The --format option can only be used together with --detach. + --format オプションは、--detach との組み合わせでのみ使用できます。 @@ -114,17 +114,17 @@ Output result as JSON (only valid with --detach). - Output result as JSON (only valid with --detach). + 結果を JSON 形式で出力します (--detach オプション指定時のみ有効)。 Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. - Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + ランダムなポートと分離されたユーザー シークレットを使って分離モードで実行し、複数のインスタンスを同時に実行できるようにします。 A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. - A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. + この AppHost の実行中のインスタンスが見つかったため、停止します。複数の分離したインスタンスを同時に実行するには、異なるディレクトリ (git worktree ディレクトリなど) から実行してください。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf index 8ac35f6c629..eb1c9d676b1 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf @@ -59,7 +59,7 @@ Copying user secrets for isolated mode... - Copying user secrets for isolated mode... + 격리 모드용 사용자 암호를 복사하는 중... @@ -99,7 +99,7 @@ The --format option can only be used together with --detach. - The --format option can only be used together with --detach. + --format 옵션은 --detach와만 함께 사용할 수 있습니다. @@ -114,17 +114,17 @@ Output result as JSON (only valid with --detach). - Output result as JSON (only valid with --detach). + JSON으로 결과를 출력합니다(--detach와 함께 사용 시에만 유효). Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. - Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + 임의의 포트와 격리된 사용자 암호를 사용해 격리 모드로 실행하면 여러 인스턴스를 동시에 실행할 수 있습니다. A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. - A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. + 이 AppHost의 실행 중인 인스턴스가 발견되어 중지됩니다. 여러 개의 격리된 인스턴스를 동시에 실행하려면 git worktree 디렉터리와 같은 서로 다른 디렉터리에서 실행합니다. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf index 9dea80a1174..b9213a49188 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf @@ -59,7 +59,7 @@ Copying user secrets for isolated mode... - Copying user secrets for isolated mode... + Kopiowanie wpisów tajnych użytkownika do trybu izolowanego... @@ -99,7 +99,7 @@ The --format option can only be used together with --detach. - The --format option can only be used together with --detach. + Opcji --format można używać tylko razem z opcją --detach. @@ -114,17 +114,17 @@ Output result as JSON (only valid with --detach). - Output result as JSON (only valid with --detach). + Wyświetl wynik jako JSON (działa tylko z parametrem --detach). Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. - Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Uruchom w trybie izolowanym z losowymi portami i izolowanymi wpisami tajnymi użytkownika, co pozwala na jednoczesne działanie wielu wystąpień. A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. - A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. + Znaleziono działające wystąpienie tego hosta aplikacji, które zostanie zatrzymane. Aby uruchomić jednocześnie wiele izolowanych wystąpień, uruchom je z różnych katalogów, na przykład katalogów drzewa roboczego usługi Git. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf index f5403c09476..ce5daa4faaa 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf @@ -59,7 +59,7 @@ Copying user secrets for isolated mode... - Copying user secrets for isolated mode... + Copiando os segredos do usuário para o modo isolado... @@ -99,7 +99,7 @@ The --format option can only be used together with --detach. - The --format option can only be used together with --detach. + A opção --format só pode ser usada junto com --detach. @@ -114,17 +114,17 @@ Output result as JSON (only valid with --detach). - Output result as JSON (only valid with --detach). + Resultado de saída como JSON (válido somente com --detach). Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. - Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Execute no modo isolado com portas aleatórias e segredos de usuário isolados, permitindo que várias instâncias executem simultaneamente. A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. - A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. + Foi encontrada uma instância em execução deste AppHost, que será interrompida. Para executar várias instâncias isoladas simultaneamente, execute de diretórios diferentes, como diretórios do git worktree. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf index 1489db341f7..ddb4a96a5c9 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf @@ -59,7 +59,7 @@ Copying user secrets for isolated mode... - Copying user secrets for isolated mode... + Копирование пользовательских секретов для изолированного режима... @@ -99,7 +99,7 @@ The --format option can only be used together with --detach. - The --format option can only be used together with --detach. + Параметр --format можно использовать только вместе с --detach. @@ -114,17 +114,17 @@ Output result as JSON (only valid with --detach). - Output result as JSON (only valid with --detach). + Вывод результата в формате JSON (допустимо только с параметром --detach). Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. - Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Запускайте в изолированном режиме со случайными портами и изолированными пользовательскими секретами, что позволяет одновременно работать нескольким экземплярам. A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. - A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. + Обнаружен запущенный экземпляр этого хоста приложений, который будет остановлен. Чтобы одновременно запустить несколько изолированных экземпляров, запускайте их из разных каталогов, например из каталогов рабочих деревьев git. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf index 55c8f0bb37d..adf4a40b82a 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf @@ -59,7 +59,7 @@ Copying user secrets for isolated mode... - Copying user secrets for isolated mode... + Yalıtılmış mod için kullanıcı gizli bilgileri kopyalanıyor... @@ -99,7 +99,7 @@ The --format option can only be used together with --detach. - The --format option can only be used together with --detach. + --format seçeneği yalnızca --detach ile birlikte kullanılabilir. @@ -114,17 +114,17 @@ Output result as JSON (only valid with --detach). - Output result as JSON (only valid with --detach). + Sonucu JSON olarak çıkar (yalnızca --detach ile geçerlidir). Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. - Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Rastgele bağlantı noktaları ve yalıtılmış kullanıcı gizli dizileri ile yalıtılmış modda çalıştırarak birden çok örneğin aynı anda çalışmasına olanak tanıyın. A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. - A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. + Bu AppHost'un çalışan bir örneği bulundu ve durdurulacak. Birden çok yalıtılmış örneği aynı anda çalıştırmak için git çalışma ağacı dizinleri gibi farklı dizinlerden çalıştırın. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf index 93e3ad452b4..abbd08d1c33 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf @@ -59,7 +59,7 @@ Copying user secrets for isolated mode... - Copying user secrets for isolated mode... + 正在复制用户机密以使用独立模式... @@ -99,7 +99,7 @@ The --format option can only be used together with --detach. - The --format option can only be used together with --detach. + --format 选项只能与 --detach 一起使用。 @@ -114,17 +114,17 @@ Output result as JSON (only valid with --detach). - Output result as JSON (only valid with --detach). + 以 JSON 格式输出结果(仅在使用 --detach 时有效)。 Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. - Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + 使用随机端口和独立的用户机密以独立模式运行,支持多个实例同时运行。 A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. - A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. + 已发现此 AppHost 有正在运行的实例,并且该实例将停止。若要同时运行多个独立实例,请从不同目录(如 Git 工作树目录)运行。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf index d73ac624646..e38e4104b48 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf @@ -59,7 +59,7 @@ Copying user secrets for isolated mode... - Copying user secrets for isolated mode... + 正在複製隔離模式的使用者袐密... @@ -99,7 +99,7 @@ The --format option can only be used together with --detach. - The --format option can only be used together with --detach. + --format 選項只能搭配 --detach 使用。 @@ -114,17 +114,17 @@ Output result as JSON (only valid with --detach). - Output result as JSON (only valid with --detach). + 輸出結果為 JSON (僅於使用 --detach 時有效)。 Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. - Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + 在隔離模式中搭配隨機化連接埠和隔離的使用者袐密執行,允許同時執行多個執行個體。 A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. - A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. + 找到此 AppHost 的一個執行中執行個體,將會停止。若要同時執行多個隔離的執行個體,請從 git worktree 目錄等不同目錄執行。 diff --git a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.cs.xlf index e13b2565d39..675d218a29b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.cs.xlf @@ -109,7 +109,7 @@ GitHub Copilot chat - Chat s GitHub Copilotem + Copilot Chat na GitHubu @@ -164,7 +164,7 @@ GitHub Copilot chat - Chat s GitHub Copilotem + Copilot Chat na GitHubu diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf index ff709b916f5..d405f9616c0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf @@ -559,7 +559,7 @@ You need to add the API key to Aspire MCP before you can use it. In GitHub Copilot Chat, select the Tools button, then the Aspire MCP server. Enter the API key below in the text box. - Abyste mohli používat Aspire MCP, musíte do něj přidat klíč rozhraní API. V GitHub Copilot Chatu vyberte tlačítko Nástroje a pak server Aspire MCP. Do textového pole níže zadejte klíč rozhraní API. + Abyste mohli používat Aspire MCP, musíte do něj přidat klíč rozhraní API. V Copilot Chatu na GitHubu vyberte tlačítko Nástroje a pak server Aspire MCP. Do textového pole níže zadejte klíč rozhraní API. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index e276dd5bfb3..f5d80912dba 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -44,7 +44,7 @@ Untrusted apps can access telemetry data via the API. - Untrusted apps can access telemetry data via the API. + Nedůvěryhodné aplikace mohou přistupovat k telemetrickým datům přes rozhraní API. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index 227aef03b3a..3a5b0e804ae 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -44,7 +44,7 @@ Untrusted apps can access telemetry data via the API. - Untrusted apps can access telemetry data via the API. + Nicht vertrauenswürdige Apps können über die API auf Telemetriedaten zugreifen. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index fe87d95058f..77802cee091 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -44,7 +44,7 @@ Untrusted apps can access telemetry data via the API. - Untrusted apps can access telemetry data via the API. + Las aplicaciones que no son de confianza pueden acceder a los datos de telemetría a través de la API. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index 0c03cd6c19a..01fcd38b3be 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -44,7 +44,7 @@ Untrusted apps can access telemetry data via the API. - Untrusted apps can access telemetry data via the API. + Les applications non approuvées peuvent accéder aux données de télémétrie via l’API. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index 719e4ab45cf..fd7f0388aa1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -44,7 +44,7 @@ Untrusted apps can access telemetry data via the API. - Untrusted apps can access telemetry data via the API. + Le app non attendibili possono accedere ai dati di telemetria tramite l’API. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index aabc2a1c4eb..0ff053804f9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -44,7 +44,7 @@ Untrusted apps can access telemetry data via the API. - Untrusted apps can access telemetry data via the API. + 信頼されていないアプリは、API を介してテレメトリ データにアクセスできます。 diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index abaaaa85a7b..9d5e699b7d9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -44,7 +44,7 @@ Untrusted apps can access telemetry data via the API. - Untrusted apps can access telemetry data via the API. + 신뢰할 수 없는 앱은 API를 통해 원격 분석 데이터에 액세스할 수 있습니다. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index 9c771b0a942..afdce8d5959 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -44,7 +44,7 @@ Untrusted apps can access telemetry data via the API. - Untrusted apps can access telemetry data via the API. + Niezaufane aplikacje mogą uzyskiwać dostęp do danych telemetrycznych za pomocą interfejsu API. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index abfca720535..8f3c90d31d3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -44,7 +44,7 @@ Untrusted apps can access telemetry data via the API. - Untrusted apps can access telemetry data via the API. + Aplicativos não confiáveis podem acessar dados de telemetria por meio da API. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index e3df3fbd8f2..9d3580f7868 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -44,7 +44,7 @@ Untrusted apps can access telemetry data via the API. - Untrusted apps can access telemetry data via the API. + Недоверенные приложения могут получать доступ к данным телеметрии через API. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index 4f15196d33f..819f85642dc 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -44,7 +44,7 @@ Untrusted apps can access telemetry data via the API. - Untrusted apps can access telemetry data via the API. + Güvenilmeyen uygulamalar API aracılığıyla telemetri verilerine erişebilir. diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index 5941903f9ab..f33eeca4128 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -44,7 +44,7 @@ Untrusted apps can access telemetry data via the API. - Untrusted apps can access telemetry data via the API. + 不受信任的应用可以通过 API 访问遥测数据。 diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index 3367f5e2308..900976b6a25 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -44,7 +44,7 @@ Untrusted apps can access telemetry data via the API. - Untrusted apps can access telemetry data via the API. + 未受信任應用程式可以透過 API 存取遙測資料。 diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.cs.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.cs.xlf index 2eb02539e98..06a4adae3b6 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.cs.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.cs.xlf @@ -24,7 +24,7 @@ Restart resource. Source code is not recompiled. - Restart resource. Source code is not recompiled. + Restartujte prostředek. Zdrojový kód není znovu zkompilován. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.de.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.de.xlf index a5f2fed5e90..f5a1c6fbb66 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.de.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.de.xlf @@ -24,7 +24,7 @@ Restart resource. Source code is not recompiled. - Restart resource. Source code is not recompiled. + Ressource neu starten. Der Quellcode wird nicht neu kompiliert. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.es.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.es.xlf index b92f5dd33f8..3f9fe070d5c 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.es.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.es.xlf @@ -24,7 +24,7 @@ Restart resource. Source code is not recompiled. - Restart resource. Source code is not recompiled. + Reiniciar recurso. El código fuente no se vuelve a compilar. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.fr.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.fr.xlf index d5ff174c975..4f5ca2b9b0a 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.fr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.fr.xlf @@ -24,7 +24,7 @@ Restart resource. Source code is not recompiled. - Restart resource. Source code is not recompiled. + Redémarrer la ressource. Le code source n’est pas recompilé. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.it.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.it.xlf index 887490b0658..eafac1eaf02 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.it.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.it.xlf @@ -24,7 +24,7 @@ Restart resource. Source code is not recompiled. - Restart resource. Source code is not recompiled. + Riavviare la risorsa. Il codice sorgente non viene ricompilato. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ja.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ja.xlf index 1d1182ce908..7c675c66490 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ja.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ja.xlf @@ -24,7 +24,7 @@ Restart resource. Source code is not recompiled. - Restart resource. Source code is not recompiled. + リソースを再起動します。ソース コードは再コンパイルされません。 diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ko.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ko.xlf index 387a096e070..b3f777da3ad 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ko.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ko.xlf @@ -24,7 +24,7 @@ Restart resource. Source code is not recompiled. - Restart resource. Source code is not recompiled. + 리소스를 다시 시작합니다. 소스 코드는 다시 컴파일되지 않습니다. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.pl.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.pl.xlf index f3606b589df..05057938b2d 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.pl.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.pl.xlf @@ -24,7 +24,7 @@ Restart resource. Source code is not recompiled. - Restart resource. Source code is not recompiled. + Uruchom ponownie zasób. Kod źródłowy nie jest ponownie kompilowany. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.pt-BR.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.pt-BR.xlf index 4bb9f1d157d..8bc73b3962f 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.pt-BR.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.pt-BR.xlf @@ -24,7 +24,7 @@ Restart resource. Source code is not recompiled. - Restart resource. Source code is not recompiled. + Reinicie o recurso. O código-fonte não está recompilado. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ru.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ru.xlf index 5520a63c99c..172a22033db 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.ru.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.ru.xlf @@ -24,7 +24,7 @@ Restart resource. Source code is not recompiled. - Restart resource. Source code is not recompiled. + Перезапустить ресурс. Исходный код не перекомпилирован. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.tr.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.tr.xlf index 2717cf0963d..a5b1051903e 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.tr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.tr.xlf @@ -24,7 +24,7 @@ Restart resource. Source code is not recompiled. - Restart resource. Source code is not recompiled. + Kaynağı yeniden başlat. Kaynak kodu yeniden derlenmez. diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hans.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hans.xlf index b4858c4caba..a97c69a0f5f 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hans.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hans.xlf @@ -24,7 +24,7 @@ Restart resource. Source code is not recompiled. - Restart resource. Source code is not recompiled. + 重启资源。源代码未重新编译。 diff --git a/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hant.xlf b/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hant.xlf index 6c675437b5a..5e09f85b8e6 100644 --- a/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hant.xlf +++ b/src/Aspire.Hosting/Resources/xlf/CommandStrings.zh-Hant.xlf @@ -24,7 +24,7 @@ Restart resource. Source code is not recompiled. - Restart resource. Source code is not recompiled. + 重新啟動資源。未重新編譯原始程式碼。 From 8286a47cce7d1a5be3c0d76af84fc55e3d16bfc6 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 01:42:20 -0500 Subject: [PATCH 013/256] Properly sign VSIX (#14066) * Initial plan * Separate extension compilation and packaging, move VSIX to post-build signing Co-authored-by: adamint <20359921+adamint@users.noreply.github.com> * Add VSIX signature verification step Co-authored-by: adamint <20359921+adamint@users.noreply.github.com> * Fix misleading comments and improve VSIX verification error handling Co-authored-by: adamint <20359921+adamint@users.noreply.github.com> * Fix VSIX verification: use vsce directly instead of yarn vsce Co-authored-by: adamint <20359921+adamint@users.noreply.github.com> * Improve VSIX verification to handle Arcade signing format - Check for existence of signature files before verification - Change verification failures to warnings when signature files don't match vsce format - Add validation that VSIX file exists and is not empty - Arcade/MicroBuild signing may create signature files in different format than vsce sign Co-authored-by: adamint <20359921+adamint@users.noreply.github.com> * Remove vsce signature verification - use Arcade SigningValidation instead The VSIX is signed using Arcade/MicroBuild with VsixSHA2 certificate, NOT vsce sign. The vsce verify-signature command only works with signatures created by vsce sign, and fails with SignatureIsUnreadable when given Arcade-format signature files. Signature validation for Arcade-signed packages is performed by the post-build SigningValidation task (eng/common/core-templates/post-build/post-build.yml) which is the correct verification for this signing method. This step now just validates that the VSIX file exists and is not empty. * wip * try this instead? * Add MicroBuild FilesToSign with VSCodePublisher certificate for .p7s signing * try alternate approach 2 * Fix VS Code extension signing with VSCodePublisher certificate - Update Extension.proj to generate manifest using 'vsce generate-manifest' - Copy manifest to .signature.p7s file (ESRP signing overwrites input with signature) - Update Signing.props to use VSCodePublisher certificate for .p7s files - Remove VsixSHA2 signing from .vsix (VS Code signing is done via .p7s, not direct Authenticode) - Update Publishing.props to include manifest and .p7s files in blob feed publishing This follows the 1ES guidance for VS Code extension signing where DevDiv teams use MicroBuild with the VSCodePublisher certificate and VSCodePublisherSign operation on the .p7s signature file. * attempt at working around arcade not signing p7s * Add automated validation for VS Code extension signature * Use MicroBuild directly for VS Code extension signing Instead of trying to use Arcade's SignTool (which doesn't support .p7s files or the VSCodePublisher certificate), use a dedicated signing project that invokes MicroBuild directly, similar to dotnet/vscode-csharp. Changes: - Create extension/signing/signVsix.proj using MicroBuild with VSCodePublisher - Update BuildAndTest.yml to invoke signVsix.proj after main build - Remove .signature.p7s entries from Signing.props (Arcade won't handle them) - Update Extension.proj to only generate manifest (signing proj creates .p7s) * Use centrally managed MicroBuild version variable * Simplify signing - MicroBuild is already installed by Arcade * Fix: use -c instead of -configuration for dotnet build * Remove explicit version from PackageReference - use central package management * Fix: disable central package management and use placeholder version for MicroBuild.Core * use -c * Fix: set OutDir to vscode output dir so MicroBuild can sign files * Revert CPVM changes - only OutDir fix is needed * Use Arcade-provided MicroBuild.Core version variable * undo * use specific microbuild core version * opt out cpvm * try a new approach (dev kit) * add publish option * add variables * add PAT * dont sign extraneous files * address pr comemnts * try to exclude vsix * sign vsix after all * add 3rd party dlls to filesigninfo * add 3rd party dlls to filesigninfo * add back in validation checks * remove check in wrong place * add small initial doc * remove extra line --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: adamint <20359921+adamint@users.noreply.github.com> Co-authored-by: Adam Ratzman --- docs/extension-signing.md | 52 +++++++++ eng/Publishing.props | 23 ++++ eng/Signing.props | 17 ++- eng/Versions.props | 2 + eng/pipelines/azure-pipelines.yml | 17 +++ eng/pipelines/templates/BuildAndTest.yml | 139 +++++++++++++++++++++++ extension/Extension.proj | 26 ++++- extension/signing/signVsix.proj | 81 +++++++++++++ 8 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 docs/extension-signing.md create mode 100644 extension/signing/signVsix.proj diff --git a/docs/extension-signing.md b/docs/extension-signing.md new file mode 100644 index 00000000000..c8b4b716b1c --- /dev/null +++ b/docs/extension-signing.md @@ -0,0 +1,52 @@ +# VS Code Extension Signing + +This document explains how the Aspire VS Code extension is signed for publication to the Visual Studio Marketplace. The signing process involves several files across the repository and runs as part of the internal CI pipeline. + +VS Code extensions require a PKCS#7 signature file (`.signature.p7s`) alongside a manifest to be verified by the Marketplace and by users. Unlike VS extensions that are Authenticode-signed, the VSIX package itself should remain unchanged after the manifest is generated—otherwise the integrity check fails. + +## Key Files + +The signing process is spread across these files: + +- **extension/Extension.proj** — Builds the VSIX and generates the manifest +- **extension/signing/signVsix.proj** — Signs the `.signature.p7s` file with MicroBuild +- **eng/Signing.props** — Configures which files get which certificates +- **eng/Publishing.props** — Publishes signed artifacts to blob storage + +## Signing Flow + +The signing happens in distinct phases during the internal CI build. + +### 1. Build and Package + +The main build step (`./build.sh -restore -build -pack -sign -publish`) builds the VS Code extension via `extension/Extension.proj`. This project runs `vsce package` to create the `.vsix` file and `vsce generate-manifest` to create a manifest file that contains a hash of the VSIX contents. + +> **Note:** The manifest is generated from the VSIX *before* any signing occurs. The VSIX is not be modified after this point, or else the hash won't match and signature verification will fail. + +### 2. Sign the Signature File + +After the main build completes, the pipeline runs `extension/signing/signVsix.proj`. This project: + +1. Copies the manifest file to create a `.signature.p7s` file +2. Signs the `.signature.p7s` with the `VSCodePublisher` certificate using MicroBuild +3. Validates exactly one manifest and one signature file exist + +The signed `.signature.p7s` is a PKCS#7 format file that the VS Marketplace and `vsce verify-signature` can validate. + +### 3. Verify + +The pipeline runs `vsce verify-signature` to confirm the signature is valid before publishing. + +### 4. Publish + +Finally, `vsce publish` uploads the VSIX along with its manifest and signature to the VS Marketplace. + +## Configuration + +In `eng/Signing.props`, the `.vsix` extension is mapped to `CertificateName="None"`: + +```xml + +``` + +The VSIX is also excluded from `ItemsToSign` to prevent Arcade's signing infrastructure from modifying it. Again, VS Code extensions are authenticated using the signature file and manifest, which is which `vsce publish` accepts signature and manifest arguments. \ No newline at end of file diff --git a/eng/Publishing.props b/eng/Publishing.props index 8d23869ffc6..678c4265cfd 100644 --- a/eng/Publishing.props +++ b/eng/Publishing.props @@ -31,8 +31,18 @@ <_ArchiveFiles Include="$(ArtifactsPackagesDir)\**\aspire-cli-*.zip" /> <_ArchiveFiles Include="$(ArtifactsPackagesDir)\**\aspire-cli-*.tar.gz" /> + <_ExtensionFilesToPublish Include="$(ArtifactsPackagesDir)**\aspire-vscode-*.vsix" /> + <_ExtensionManifestFiles Include="$(ArtifactsPackagesDir)**\aspire-vscode-*.manifest" /> + <_ExtensionSignatureFiles Include="$(ArtifactsPackagesDir)**\aspire-vscode-*.signature.p7s" /> + + + + + + <_ArchiveFilesWithRid Include="@(_ArchiveFiles)"> @@ -122,6 +132,19 @@ true $(_UploadPathRoot)/$(_PackageVersion)/%(Filename)%(Extension) + + + false + true + $(_UploadPathRoot)/$(_PackageVersion)/%(Filename)%(Extension) + + + + false + true + $(_UploadPathRoot)/$(_PackageVersion)/%(Filename)%(Extension) + diff --git a/eng/Signing.props b/eng/Signing.props index b5a04ee25a1..3400bb5677b 100644 --- a/eng/Signing.props +++ b/eng/Signing.props @@ -3,7 +3,13 @@ - + + @@ -40,8 +46,11 @@ + + + @@ -59,7 +68,11 @@ - + diff --git a/eng/Versions.props b/eng/Versions.props index 2be7c94dc1e..e99c5e131df 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -59,6 +59,8 @@ 1.5.0 2.23.32-alpha + + 1.0.0 diff --git a/eng/pipelines/azure-pipelines.yml b/eng/pipelines/azure-pipelines.yml index 1a22a94f58b..3cc62a0c7ea 100644 --- a/eng/pipelines/azure-pipelines.yml +++ b/eng/pipelines/azure-pipelines.yml @@ -1,3 +1,14 @@ +# Runtime parameters for VS Code extension publishing (shown in "Run pipeline" UI) +parameters: + - name: publishVSCodeExtension + displayName: 'Publish VS Code Extension to Marketplace' + type: boolean + default: false + - name: vscePublishPreRelease + displayName: 'Publish as Pre-Release' + type: boolean + default: false + trigger: batch: true branches: @@ -39,6 +50,9 @@ pr: variables: - template: /eng/pipelines/common-variables.yml@self - template: /eng/common/templates-official/variables/pool-providers.yml@self + # Variable group containing VscePublishToken for VS Code Marketplace publishing + - ${{ if eq(parameters.publishVSCodeExtension, true) }}: + - group: Aspire-Release-Secrets - name: _BuildConfig value: Release @@ -273,6 +287,9 @@ extends: targetRids: - win-x64 - win-arm64 + publishVSCodeExtension: ${{ parameters.publishVSCodeExtension }} + vscePublishToken: $(VscePublishToken) + vscePublishPreRelease: ${{ parameters.vscePublishPreRelease }} - ${{ if and(notin(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/main')) }}: - template: /eng/common/templates-official/job/onelocbuild.yml@self parameters: diff --git a/eng/pipelines/templates/BuildAndTest.yml b/eng/pipelines/templates/BuildAndTest.yml index f603446b9bc..04e532ea7c2 100644 --- a/eng/pipelines/templates/BuildAndTest.yml +++ b/eng/pipelines/templates/BuildAndTest.yml @@ -28,6 +28,15 @@ parameters: - name: dockerCliVersion type: string default: '28.0.0' + - name: publishVSCodeExtension + type: boolean + default: false + - name: vscePublishToken + type: string + default: '' + - name: vscePublishPreRelease + type: boolean + default: false steps: # Internal pipeline: Build with pack+sign @@ -46,6 +55,136 @@ steps: /p:BuildExtension=true displayName: 🟣Build + # Log MicroBuild environment for debugging + # MicroBuildOutputFolderOverride is set by the MicroBuildSigningPlugin task in eng/common/templates-official/job/onelocbuild.yml + # which is installed via the Arcade SDK's install-microbuild.yml template that runs before our build steps. + # This environment variable points to where MicroBuild plugins are installed on the build agent. + - pwsh: | + Write-Host "=== MicroBuild Environment Check ===" + Write-Host "MicroBuildOutputFolderOverride: $env:MicroBuildOutputFolderOverride" + if ($env:MicroBuildOutputFolderOverride) { + $pluginPath = Join-Path $env:MicroBuildOutputFolderOverride "MicroBuild.Plugins.Signing" + if (Test-Path $pluginPath) { + Write-Host "✅ MicroBuild.Plugins.Signing found at: $pluginPath" + Get-ChildItem $pluginPath -Recurse | Select-Object FullName | Format-Table + } else { + Write-Host "##[warning]MicroBuild.Plugins.Signing NOT found at: $pluginPath" + Write-Host "Contents of MicroBuildOutputFolderOverride:" + Get-ChildItem $env:MicroBuildOutputFolderOverride -Recurse -ErrorAction SilentlyContinue | Select-Object FullName | Format-Table + } + } else { + Write-Host "##[warning]MicroBuildOutputFolderOverride environment variable is not set!" + } + Write-Host "=== End MicroBuild Environment Check ===" + displayName: 🟣Check MicroBuild environment + condition: and(succeeded(), eq(variables['_SignType'], 'real')) + + # Sign the VS Code extension .signature.p7s file using MicroBuild with VSCodePublisher certificate + # This must happen after the main build (which creates the manifest) but before publishing + # MicroBuild is already installed by the install-microbuild.yml step that runs before our build + # Pass MicroBuildOutputFolderOverride as a property to ensure MSBuild can find the signing plugin + - script: ${{ parameters.dotnetScript }} + build + extension/signing/signVsix.proj + -c ${{ parameters.buildConfig }} + /bl:${{ parameters.repoLogPath }}/signVsix.binlog + /p:SignType=$(_SignType) + "/p:MicroBuildOutputFolderOverride=%MicroBuildOutputFolderOverride%" + displayName: 🟣Sign VS Code extension + condition: and(succeeded(), eq(variables['_SignType'], 'real')) + + # Verify the VS Code extension signature using vsce verify-signature + - pwsh: | + $vsixDir = '${{ parameters.repoArtifactsPath }}/packages/Release/vscode' + $vsixFiles = Get-ChildItem -Path "$vsixDir/*.vsix" -ErrorAction SilentlyContinue + if ($vsixFiles.Count -eq 0) { + Write-Host "##[warning]No .vsix files found - VS Code extension may not have been built" + exit 0 + } + foreach ($vsix in $vsixFiles) { + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($vsix.FullName) + $manifestPath = Join-Path $vsixDir "$baseName.manifest" + $signaturePath = Join-Path $vsixDir "$baseName.signature.p7s" + + if (-not (Test-Path $manifestPath)) { + Write-Host "##[error]Manifest file not found: $manifestPath" + exit 1 + } + if (-not (Test-Path $signaturePath)) { + Write-Host "##[error]Signature file not found: $signaturePath" + exit 1 + } + + # First verify the .signature.p7s is in PKCS#7 format (binary) + $bytes = [System.IO.File]::ReadAllBytes($signaturePath) + if ($bytes.Length -eq 0 -or $bytes[0] -ne 0x30) { + $firstByte = if ($bytes.Length -gt 0) { "0x{0:X2}" -f $bytes[0] } else { "empty" } + Write-Host "##[error]$baseName.signature.p7s does NOT appear to be signed. First byte: $firstByte (expected 0x30 for PKCS#7)" + exit 1 + } + + Write-Host "Verifying signature for $($vsix.Name)..." + npx vsce verify-signature --packagePath $vsix.FullName --manifestPath $manifestPath --signaturePath $signaturePath + if ($LASTEXITCODE -ne 0) { + Write-Host "##[error]Signature verification failed for $($vsix.Name)" + exit 1 + } + Write-Host "✅ $($vsix.Name) signature verified successfully" + } + displayName: 🟣Verify VS Code extension signature + condition: and(succeeded(), eq(variables['_SignType'], 'real')) + + # Publish the signed VS Code extension to the Marketplace + # Requires vscePublishToken parameter to be set with a Personal Access Token for the VS Marketplace + - ${{ if and(eq(parameters.publishVSCodeExtension, true), ne(parameters.vscePublishToken, '')) }}: + # Verify the PAT is valid before attempting to publish + - pwsh: | + Write-Host "Verifying VS Code Marketplace PAT..." + $publisher = "microsoft-aspire" + npx vsce verify-pat $publisher + if ($LASTEXITCODE -ne 0) { + Write-Host "##[error]PAT verification failed for publisher '$publisher'. Ensure the token has 'Marketplace: Manage' scope." + exit 1 + } + Write-Host "✅ PAT verified successfully for publisher '$publisher'" + displayName: 🟣Verify VS Code Marketplace PAT + condition: and(succeeded(), eq(variables['_SignType'], 'real')) + env: + VSCE_PAT: ${{ parameters.vscePublishToken }} + + - pwsh: | + $vsixDir = '${{ parameters.repoArtifactsPath }}/packages/Release/vscode' + $preRelease = '${{ parameters.vscePublishPreRelease }}' + $vsixFiles = Get-ChildItem -Path "$vsixDir/*.vsix" -ErrorAction SilentlyContinue + if ($vsixFiles.Count -eq 0) { + Write-Host "##[error]No .vsix files found to publish" + exit 1 + } + foreach ($vsix in $vsixFiles) { + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($vsix.FullName) + $manifestPath = Join-Path $vsixDir "$baseName.manifest" + $signaturePath = Join-Path $vsixDir "$baseName.signature.p7s" + + $publishArgs = @("vsce", "publish", "--packagePath", $vsix.FullName, "--manifestPath", $manifestPath, "--signaturePath", $signaturePath) + if ($preRelease -eq 'True') { + $publishArgs += "--pre-release" + Write-Host "Publishing $($vsix.Name) to VS Code Marketplace as PRE-RELEASE..." + } else { + Write-Host "Publishing $($vsix.Name) to VS Code Marketplace..." + } + + & npx @publishArgs + if ($LASTEXITCODE -ne 0) { + Write-Host "##[error]Failed to publish $($vsix.Name)" + exit 1 + } + Write-Host "✅ $($vsix.Name) published successfully" + } + displayName: 🟣Publish VS Code extension to Marketplace + condition: and(succeeded(), eq(variables['_SignType'], 'real')) + env: + VSCE_PAT: ${{ parameters.vscePublishToken }} + - task: 1ES.PublishBuildArtifacts@1 displayName: 🟣Publish vscode extension condition: always() diff --git a/extension/Extension.proj b/extension/Extension.proj index 94add8bc570..2aa1b95558d 100644 --- a/extension/Extension.proj +++ b/extension/Extension.proj @@ -7,7 +7,6 @@ <_PackageJsonPath>$([MSBuild]::NormalizePath($(ExtensionSrcDir), 'package.json')) - <_VsixPath>$([MSBuild]::NormalizePath($(ArtifactsPackagesDir), 'aspire-vscode-$(Version).vsix')) @@ -20,17 +19,38 @@ + + <_VscodeOutputDir>$(ArtifactsPackagesDir)vscode + <_VsixPath>$(_VscodeOutputDir)\aspire-vscode-$(_ExtractedVersion).vsix + <_ManifestPath>$(_VscodeOutputDir)\aspire-vscode-$(_ExtractedVersion).manifest + + - + - + + + + + + + + diff --git a/extension/signing/signVsix.proj b/extension/signing/signVsix.proj new file mode 100644 index 00000000000..73b026fde85 --- /dev/null +++ b/extension/signing/signVsix.proj @@ -0,0 +1,81 @@ + + + + + false + netstandard2.0 + false + false + true + false + + + $(ArtifactsPackagesDir)vscode\ + $(VscodeOutputDir) + + + + + + + + + + test + + + + + + + + + + + + + + + + + + VSCodePublisher + + + + + + + + + + + + + + + + + + + + From 3080093602d75ff4eeabb53ccc2c692014d8eace Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 2 Feb 2026 15:41:41 +0800 Subject: [PATCH 014/256] Remove argument null checks in command constructors (#14281) --- .vscode/mcp.json | 12 ++++++---- src/Aspire.Cli/Commands/AddCommand.cs | 9 -------- src/Aspire.Cli/Commands/AgentCommand.cs | 3 --- src/Aspire.Cli/Commands/AgentInitCommand.cs | 4 ---- src/Aspire.Cli/Commands/CacheCommand.cs | 2 -- src/Aspire.Cli/Commands/ConfigCommand.cs | 8 +------ src/Aspire.Cli/Commands/DoctorCommand.cs | 3 --- src/Aspire.Cli/Commands/ExecCommand.cs | 9 -------- .../Commands/ExtensionInternalCommand.cs | 3 --- src/Aspire.Cli/Commands/InitCommand.cs | 13 ----------- src/Aspire.Cli/Commands/LogsCommand.cs | 5 ---- src/Aspire.Cli/Commands/McpCommand.cs | 2 -- src/Aspire.Cli/Commands/NewCommand.cs | 10 -------- .../Commands/PipelineCommandBase.cs | 9 -------- src/Aspire.Cli/Commands/PsCommand.cs | 4 ---- src/Aspire.Cli/Commands/PublishCommand.cs | 1 - src/Aspire.Cli/Commands/ResourcesCommand.cs | 4 ---- src/Aspire.Cli/Commands/RootCommand.cs | 23 ------------------- src/Aspire.Cli/Commands/RunCommand.cs | 12 ---------- src/Aspire.Cli/Commands/Sdk/SdkCommand.cs | 3 --- src/Aspire.Cli/Commands/StopCommand.cs | 4 ---- src/Aspire.Cli/Commands/UpdateCommand.cs | 8 ------- 22 files changed, 9 insertions(+), 142 deletions(-) diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 458fca1eab0..59a47c2cc4b 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -3,8 +3,9 @@ "aspire": { "type": "stdio", "command": "aspire", - "args": ["mcp", "start"] + "args": ["agent", "mcp"] }, + /* "aspire-local": { "type": "stdio", "command": "dotnet", @@ -13,12 +14,14 @@ "--project", "${workspaceFolder}/src/Aspire.Cli/Aspire.Cli.csproj", "--", - "mcp", - "start" + "agent", + "mcp" ], }, + */ // After starting this MCP server: Debug -> // Attach to Aspire MCP server -> Select the "aspire" process... + /* "aspire-debug": { "type": "stdio", "command": "dotnet", @@ -27,11 +30,12 @@ "--project", "${workspaceFolder}/src/Aspire.Cli/Aspire.Cli.csproj", "--", + "agent", "mcp", - "start", "--cli-wait-for-debugger" ], }, + */ "hex1b": { "type": "stdio", "command": "dnx", diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index d4ae49a3517..c0c1053185f 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -47,15 +47,6 @@ internal sealed class AddCommand : BaseCommand public AddCommand(IPackagingService packagingService, IInteractionService interactionService, IProjectLocator projectLocator, IAddCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory) : base("add", AddCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(packagingService); - ArgumentNullException.ThrowIfNull(interactionService); - ArgumentNullException.ThrowIfNull(projectLocator); - ArgumentNullException.ThrowIfNull(prompter); - ArgumentNullException.ThrowIfNull(sdkInstaller); - ArgumentNullException.ThrowIfNull(hostEnvironment); - ArgumentNullException.ThrowIfNull(features); - ArgumentNullException.ThrowIfNull(projectFactory); - _packagingService = packagingService; _projectLocator = projectLocator; _prompter = prompter; diff --git a/src/Aspire.Cli/Commands/AgentCommand.cs b/src/Aspire.Cli/Commands/AgentCommand.cs index 520627624f6..e0e35ee05d2 100644 --- a/src/Aspire.Cli/Commands/AgentCommand.cs +++ b/src/Aspire.Cli/Commands/AgentCommand.cs @@ -26,9 +26,6 @@ public AgentCommand( AspireCliTelemetry telemetry) : base("agent", AgentCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(mcpCommand); - ArgumentNullException.ThrowIfNull(initCommand); - Subcommands.Add(mcpCommand); Subcommands.Add(initCommand); } diff --git a/src/Aspire.Cli/Commands/AgentInitCommand.cs b/src/Aspire.Cli/Commands/AgentInitCommand.cs index 2459da0b589..76074b53d2d 100644 --- a/src/Aspire.Cli/Commands/AgentInitCommand.cs +++ b/src/Aspire.Cli/Commands/AgentInitCommand.cs @@ -45,10 +45,6 @@ public AgentInitCommand( AspireCliTelemetry telemetry) : base("init", AgentCommandStrings.InitCommand_Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(interactionService); - ArgumentNullException.ThrowIfNull(agentEnvironmentDetector); - ArgumentNullException.ThrowIfNull(gitRepository); - _interactionService = interactionService; _agentEnvironmentDetector = agentEnvironmentDetector; _gitRepository = gitRepository; diff --git a/src/Aspire.Cli/Commands/CacheCommand.cs b/src/Aspire.Cli/Commands/CacheCommand.cs index 65253538dd8..13043cb3f57 100644 --- a/src/Aspire.Cli/Commands/CacheCommand.cs +++ b/src/Aspire.Cli/Commands/CacheCommand.cs @@ -17,8 +17,6 @@ internal sealed class CacheCommand : BaseCommand public CacheCommand(IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, AspireCliTelemetry telemetry) : base("cache", CacheCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(interactionService); - var clearCommand = new ClearCommand(InteractionService, features, updateNotifier, executionContext, telemetry); Subcommands.Add(clearCommand); diff --git a/src/Aspire.Cli/Commands/ConfigCommand.cs b/src/Aspire.Cli/Commands/ConfigCommand.cs index deeec7988ab..3f2ef7e7cd5 100644 --- a/src/Aspire.Cli/Commands/ConfigCommand.cs +++ b/src/Aspire.Cli/Commands/ConfigCommand.cs @@ -6,7 +6,6 @@ using System.Diagnostics; using System.Globalization; using Aspire.Cli.Configuration; -using Aspire.Cli.DotNet; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; @@ -22,14 +21,9 @@ internal sealed class ConfigCommand : BaseCommand private readonly IConfiguration _configuration; private readonly IInteractionService _interactionService; - public ConfigCommand(IConfiguration configuration, IConfigurationService configurationService, IInteractionService interactionService, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, AspireCliTelemetry telemetry) + public ConfigCommand(IConfiguration configuration, IConfigurationService configurationService, IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, AspireCliTelemetry telemetry) : base("config", ConfigCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(configuration); - ArgumentNullException.ThrowIfNull(configurationService); - ArgumentNullException.ThrowIfNull(interactionService); - ArgumentNullException.ThrowIfNull(sdkInstaller); - _configuration = configuration; _interactionService = interactionService; diff --git a/src/Aspire.Cli/Commands/DoctorCommand.cs b/src/Aspire.Cli/Commands/DoctorCommand.cs index 3308bd70cbc..f7290678661 100644 --- a/src/Aspire.Cli/Commands/DoctorCommand.cs +++ b/src/Aspire.Cli/Commands/DoctorCommand.cs @@ -32,9 +32,6 @@ public DoctorCommand( AspireCliTelemetry telemetry) : base("doctor", DoctorCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(environmentChecker); - ArgumentNullException.ThrowIfNull(ansiConsole); - _environmentChecker = environmentChecker; _ansiConsole = ansiConsole; diff --git a/src/Aspire.Cli/Commands/ExecCommand.cs b/src/Aspire.Cli/Commands/ExecCommand.cs index d4b6db2c112..29707d34844 100644 --- a/src/Aspire.Cli/Commands/ExecCommand.cs +++ b/src/Aspire.Cli/Commands/ExecCommand.cs @@ -61,15 +61,6 @@ public ExecCommand( CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment) : base("exec", ExecCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(runner); - ArgumentNullException.ThrowIfNull(interactionService); - ArgumentNullException.ThrowIfNull(certificateService); - ArgumentNullException.ThrowIfNull(projectLocator); - ArgumentNullException.ThrowIfNull(ansiConsole); - ArgumentNullException.ThrowIfNull(sdkInstaller); - ArgumentNullException.ThrowIfNull(hostEnvironment); - ArgumentNullException.ThrowIfNull(features); - _runner = runner; _certificateService = certificateService; _projectLocator = projectLocator; diff --git a/src/Aspire.Cli/Commands/ExtensionInternalCommand.cs b/src/Aspire.Cli/Commands/ExtensionInternalCommand.cs index 8df24431e2f..9bbcd19c59e 100644 --- a/src/Aspire.Cli/Commands/ExtensionInternalCommand.cs +++ b/src/Aspire.Cli/Commands/ExtensionInternalCommand.cs @@ -17,9 +17,6 @@ internal sealed class ExtensionInternalCommand : BaseCommand { public ExtensionInternalCommand(IFeatures features, ICliUpdateNotifier updateNotifier, IProjectLocator projectLocator, CliExecutionContext executionContext, IInteractionService interactionService, AspireCliTelemetry telemetry) : base("extension", "Hidden command for extension integration", features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(features); - ArgumentNullException.ThrowIfNull(updateNotifier); - this.Hidden = true; this.Subcommands.Add(new GetAppHostCandidatesCommand(features, updateNotifier, projectLocator, executionContext, interactionService, telemetry)); } diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index a05f3c2a01a..00fe1556598 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -85,19 +85,6 @@ public InitCommand( IScaffoldingService scaffoldingService) : base("init", InitCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(runner); - ArgumentNullException.ThrowIfNull(certificateService); - ArgumentNullException.ThrowIfNull(prompter); - ArgumentNullException.ThrowIfNull(templateFactory); - ArgumentNullException.ThrowIfNull(packagingService); - ArgumentNullException.ThrowIfNull(solutionLocator); - ArgumentNullException.ThrowIfNull(sdkInstaller); - ArgumentNullException.ThrowIfNull(hostEnvironment); - ArgumentNullException.ThrowIfNull(configurationService); - ArgumentNullException.ThrowIfNull(languageService); - ArgumentNullException.ThrowIfNull(languageDiscovery); - ArgumentNullException.ThrowIfNull(scaffoldingService); - _runner = runner; _certificateService = certificateService; _prompter = prompter; diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs index 2bc837065f2..de11ef4d8e1 100644 --- a/src/Aspire.Cli/Commands/LogsCommand.cs +++ b/src/Aspire.Cli/Commands/LogsCommand.cs @@ -125,11 +125,6 @@ public LogsCommand( ILogger logger) : base("logs", LogsCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(interactionService); - ArgumentNullException.ThrowIfNull(backchannelMonitor); - ArgumentNullException.ThrowIfNull(hostEnvironment); - ArgumentNullException.ThrowIfNull(logger); - _interactionService = interactionService; _hostEnvironment = hostEnvironment; _logger = logger; diff --git a/src/Aspire.Cli/Commands/McpCommand.cs b/src/Aspire.Cli/Commands/McpCommand.cs index cf42194aa29..589804412ad 100644 --- a/src/Aspire.Cli/Commands/McpCommand.cs +++ b/src/Aspire.Cli/Commands/McpCommand.cs @@ -40,8 +40,6 @@ public McpCommand( AspireCliTelemetry telemetry) : base("mcp", McpCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(interactionService); - // Mark as hidden - use 'aspire agent' instead Hidden = true; diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index ab94352d6a5..caace48b9b9 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -87,16 +87,6 @@ public NewCommand( IScaffoldingService scaffoldingService) : base("new", NewCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(runner); - ArgumentNullException.ThrowIfNull(nuGetPackageCache); - ArgumentNullException.ThrowIfNull(certificateService); - ArgumentNullException.ThrowIfNull(prompter); - ArgumentNullException.ThrowIfNull(templateProvider); - ArgumentNullException.ThrowIfNull(sdkInstaller); - ArgumentNullException.ThrowIfNull(hostEnvironment); - ArgumentNullException.ThrowIfNull(languageDiscovery); - ArgumentNullException.ThrowIfNull(scaffoldingService); - _runner = runner; _nuGetPackageCache = nuGetPackageCache; _certificateService = certificateService; diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index 46813c44d5d..801ce805ca2 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -70,15 +70,6 @@ private static bool IsCompletionStateWarning(string completionState) => protected PipelineCommandBase(string name, string description, IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) : base(name, description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(runner); - ArgumentNullException.ThrowIfNull(projectLocator); - ArgumentNullException.ThrowIfNull(sdkInstaller); - ArgumentNullException.ThrowIfNull(hostEnvironment); - ArgumentNullException.ThrowIfNull(features); - ArgumentNullException.ThrowIfNull(projectFactory); - ArgumentNullException.ThrowIfNull(logger); - ArgumentNullException.ThrowIfNull(ansiConsole); - _runner = runner; _projectLocator = projectLocator; _sdkInstaller = sdkInstaller; diff --git a/src/Aspire.Cli/Commands/PsCommand.cs b/src/Aspire.Cli/Commands/PsCommand.cs index 1a897efe331..5b8142e9b7c 100644 --- a/src/Aspire.Cli/Commands/PsCommand.cs +++ b/src/Aspire.Cli/Commands/PsCommand.cs @@ -62,10 +62,6 @@ public PsCommand( ILogger logger) : base("ps", PsCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(interactionService); - ArgumentNullException.ThrowIfNull(backchannelMonitor); - ArgumentNullException.ThrowIfNull(logger); - _interactionService = interactionService; _backchannelMonitor = backchannelMonitor; _logger = logger; diff --git a/src/Aspire.Cli/Commands/PublishCommand.cs b/src/Aspire.Cli/Commands/PublishCommand.cs index e7395aaf559..7ccbe87ad46 100644 --- a/src/Aspire.Cli/Commands/PublishCommand.cs +++ b/src/Aspire.Cli/Commands/PublishCommand.cs @@ -39,7 +39,6 @@ internal sealed class PublishCommand : PipelineCommandBase public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, IPublishCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) : base("publish", PublishCommandStrings.Description, runner, interactionService, projectLocator, telemetry, sdkInstaller, features, updateNotifier, executionContext, hostEnvironment, projectFactory, logger, ansiConsole) { - ArgumentNullException.ThrowIfNull(prompter); _prompter = prompter; } diff --git a/src/Aspire.Cli/Commands/ResourcesCommand.cs b/src/Aspire.Cli/Commands/ResourcesCommand.cs index a203c5c748b..95479c848da 100644 --- a/src/Aspire.Cli/Commands/ResourcesCommand.cs +++ b/src/Aspire.Cli/Commands/ResourcesCommand.cs @@ -98,10 +98,6 @@ public ResourcesCommand( ILogger logger) : base("resources", ResourcesCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(interactionService); - ArgumentNullException.ThrowIfNull(backchannelMonitor); - ArgumentNullException.ThrowIfNull(logger); - _interactionService = interactionService; _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index c7b9bdf63eb..e5e408799ff 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -78,29 +78,6 @@ public RootCommand( IInteractionService interactionService) : base(RootCommandStrings.Description) { - ArgumentNullException.ThrowIfNull(newCommand); - ArgumentNullException.ThrowIfNull(initCommand); - ArgumentNullException.ThrowIfNull(runCommand); - ArgumentNullException.ThrowIfNull(stopCommand); - ArgumentNullException.ThrowIfNull(psCommand); - ArgumentNullException.ThrowIfNull(resourcesCommand); - ArgumentNullException.ThrowIfNull(logsCommand); - ArgumentNullException.ThrowIfNull(addCommand); - ArgumentNullException.ThrowIfNull(publishCommand); - ArgumentNullException.ThrowIfNull(configCommand); - ArgumentNullException.ThrowIfNull(cacheCommand); - ArgumentNullException.ThrowIfNull(doctorCommand); - ArgumentNullException.ThrowIfNull(deployCommand); - ArgumentNullException.ThrowIfNull(doCommand); - ArgumentNullException.ThrowIfNull(updateCommand); - ArgumentNullException.ThrowIfNull(execCommand); - ArgumentNullException.ThrowIfNull(mcpCommand); - ArgumentNullException.ThrowIfNull(agentCommand); - ArgumentNullException.ThrowIfNull(sdkCommand); - ArgumentNullException.ThrowIfNull(extensionInternalCommand); - ArgumentNullException.ThrowIfNull(featureFlags); - ArgumentNullException.ThrowIfNull(interactionService); - _interactionService = interactionService; #if DEBUG diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 99bfd4539d4..0d7ce4aa4a1 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -105,18 +105,6 @@ public RunCommand( TimeProvider? timeProvider) : base("run", RunCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(runner); - ArgumentNullException.ThrowIfNull(interactionService); - ArgumentNullException.ThrowIfNull(certificateService); - ArgumentNullException.ThrowIfNull(projectLocator); - ArgumentNullException.ThrowIfNull(ansiConsole); - ArgumentNullException.ThrowIfNull(configuration); - ArgumentNullException.ThrowIfNull(sdkInstaller); - ArgumentNullException.ThrowIfNull(hostEnvironment); - ArgumentNullException.ThrowIfNull(logger); - ArgumentNullException.ThrowIfNull(projectFactory); - ArgumentNullException.ThrowIfNull(backchannelMonitor); - _runner = runner; _interactionService = interactionService; _certificateService = certificateService; diff --git a/src/Aspire.Cli/Commands/Sdk/SdkCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkCommand.cs index 4facbf12877..b3d15e46496 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkCommand.cs @@ -26,9 +26,6 @@ public SdkCommand( AspireCliTelemetry telemetry) : base("sdk", "Commands for generating SDKs for building Aspire integrations in other languages.", features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(generateCommand); - ArgumentNullException.ThrowIfNull(dumpCommand); - Subcommands.Add(generateCommand); Subcommands.Add(dumpCommand); } diff --git a/src/Aspire.Cli/Commands/StopCommand.cs b/src/Aspire.Cli/Commands/StopCommand.cs index 9d4bac3011f..a74ed27961b 100644 --- a/src/Aspire.Cli/Commands/StopCommand.cs +++ b/src/Aspire.Cli/Commands/StopCommand.cs @@ -36,10 +36,6 @@ public StopCommand( TimeProvider? timeProvider = null) : base("stop", StopCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(interactionService); - ArgumentNullException.ThrowIfNull(backchannelMonitor); - ArgumentNullException.ThrowIfNull(logger); - _interactionService = interactionService; _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); _logger = logger; diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 9f8b5fd2a2e..ebe2d9fdf09 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -56,14 +56,6 @@ public UpdateCommand( AspireCliTelemetry telemetry) : base("update", UpdateCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - ArgumentNullException.ThrowIfNull(projectLocator); - ArgumentNullException.ThrowIfNull(packagingService); - ArgumentNullException.ThrowIfNull(projectFactory); - ArgumentNullException.ThrowIfNull(logger); - ArgumentNullException.ThrowIfNull(updateNotifier); - ArgumentNullException.ThrowIfNull(features); - ArgumentNullException.ThrowIfNull(configurationService); - _projectLocator = projectLocator; _packagingService = packagingService; _projectFactory = projectFactory; From 8748d92b80e5fdb0fef0836428961a5fc758c7f7 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 2 Feb 2026 00:05:49 -0800 Subject: [PATCH 015/256] Upgrade HTTP endpoints to HTTPS in Azure Container Apps (#14267) * Upgrade HTTP endpoints to HTTPS in Azure Container Apps - HTTP endpoints are now upgraded to HTTPS:443 by default - Explicit dev ports (e.g., 8080) are treated as dev-only hints - Add WithHttpsUpgrade(false) to opt out at the environment level - Removes NotSupportedException throws for port validation * Consolidate HTTPS upgrade logging to single message - Log once per environment instead of per endpoint - Shows all affected resources/endpoints in one message - Example: 'HTTP endpoints will use HTTPS: keycloak:http, vault:http, app:http' --- .../AzureContainerAppEnvironmentResource.cs | 6 ++ .../AzureContainerAppExtensions.cs | 18 +++++ .../AzureContainerAppsInfrastructure.cs | 3 + .../ContainerAppContext.cs | 26 ++++--- .../ContainerAppEnvironmentContext.cs | 36 ++++++++++ .../AzureContainerAppsTests.cs | 71 ++++++++++++++++--- ...SchemeUsingWithHttpsUpgrade.verified.bicep | 33 +++++++++ ...pSchemeUsingWithHttpsUpgrade.verified.json | 8 +++ ...t80EvenWithDifferentDevPort.verified.bicep | 33 +++++++++ ...rt80EvenWithDifferentDevPort.verified.json | 8 +++ ...443EvenWithDifferentDevPort.verified.bicep | 33 +++++++++ ...t443EvenWithDifferentDevPort.verified.json | 8 +++ ...ctWithManyReferenceTypes#00.verified.bicep | 12 ++-- ...dContainerAppEnvironment#00.verified.bicep | 12 ++-- 14 files changed, 276 insertions(+), 31 deletions(-) create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CanPreserveHttpSchemeUsingWithHttpsUpgrade.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CanPreserveHttpSchemeUsingWithHttpsUpgrade.verified.json create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpIngressUsesPort80EvenWithDifferentDevPort.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpIngressUsesPort80EvenWithDifferentDevPort.verified.json create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpsIngressUsesPort443EvenWithDifferentDevPort.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpsIngressUsesPort443EvenWithDifferentDevPort.verified.json diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs index 0e342253bd7..ccd76135e29 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs @@ -140,6 +140,12 @@ await context.ReportingStep.CompleteAsync( /// internal bool EnableDashboard { get; set; } = true; + /// + /// Gets or sets a value indicating whether HTTP endpoints should be preserved as HTTP instead of being upgraded to HTTPS. + /// Default is false (HTTP endpoints are upgraded to HTTPS). + /// + internal bool PreserveHttpEndpoints { get; set; } + /// /// Gets the unique identifier of the Container App Environment. /// diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs index 0358403423f..b000795d993 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs @@ -371,6 +371,24 @@ public static IResourceBuilder WithDashboa return builder; } + /// + /// Configures whether HTTP endpoints should be upgraded to HTTPS in Azure Container Apps. + /// By default, HTTP endpoints are upgraded to HTTPS for security and WebSocket compatibility. + /// + /// The AzureContainerAppEnvironmentResource to configure. + /// Whether to upgrade HTTP endpoints to HTTPS. Default is true. + /// + /// + /// When disabled (false), HTTP endpoints will use HTTP scheme and port 80 in Azure Container Apps. + /// Note that explicit ports specified for development (e.g., port 8080) are still normalized + /// to standard ports (80/443) as required by Azure Container Apps. + /// + public static IResourceBuilder WithHttpsUpgrade(this IResourceBuilder builder, bool upgrade = true) + { + builder.Resource.PreserveHttpEndpoints = !upgrade; + return builder; + } + /// /// Configures the container app environment resource to use the specified Log Analytics Workspace. /// diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs index 463527a7851..85a6d034c54 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppsInfrastructure.cs @@ -63,6 +63,9 @@ private async Task OnBeforeStartAsync(BeforeStartEvent @event, CancellationToken ComputeEnvironment = environment }); } + + // Log once about all HTTP endpoints upgraded to HTTPS + containerAppEnvironmentContext.LogHttpsUpgradeIfNeeded(); } } diff --git a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs index 1911be2d0e6..6fac60a28b1 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppContext.cs @@ -226,21 +226,25 @@ static bool Compatible(string[] schemes) => foreach (var resolved in httpIngress.ResolvedEndpoints) { var endpoint = resolved.Endpoint; + var preserveHttp = _containerAppEnvironmentContext.Environment.PreserveHttpEndpoints; - if (endpoint.UriScheme is "http" && endpoint.Port is not null and not 80) - { - throw new NotSupportedException($"The endpoint '{endpoint.Name}' is an http endpoint and must use port 80"); - } + // By default, HTTP ingress uses HTTPS in ACA (HTTP→HTTPS redirect breaks WebSocket upgrades) + // If PreserveHttpEndpoints is true, keep the original scheme + var scheme = preserveHttp ? endpoint.UriScheme : "https"; + var port = scheme is "http" ? 80 : 443; - if (endpoint.UriScheme is "https" && endpoint.Port is not null and not 443) - { - throw new NotSupportedException($"The endpoint '{endpoint.Name}' is an https endpoint and must use port 443"); - } + _endpointMapping[endpoint.Name] = new(scheme, NormalizedContainerAppName, port, targetPort, true, httpIngress.External); + } - // For the http ingress port is always 80 or 443 - var port = endpoint.UriScheme is "http" ? 80 : 443; + // Record HTTP endpoints being upgraded (logged once at environment level) + if (!_containerAppEnvironmentContext.Environment.PreserveHttpEndpoints) + { + var upgradedEndpoints = httpIngress.ResolvedEndpoints + .Where(r => r.Endpoint.UriScheme is "http") + .Select(r => r.Endpoint.Name) + .ToArray(); - _endpointMapping[endpoint.Name] = new(endpoint.UriScheme, NormalizedContainerAppName, port, targetPort, true, httpIngress.External); + _containerAppEnvironmentContext.RecordHttpsUpgrade(Resource.Name, upgradedEndpoints); } } diff --git a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppEnvironmentContext.cs b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppEnvironmentContext.cs index 3387616d398..9c645c60d3e 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/ContainerAppEnvironmentContext.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/ContainerAppEnvironmentContext.cs @@ -24,6 +24,42 @@ internal sealed class ContainerAppEnvironmentContext( public IServiceProvider ServiceProvider => serviceProvider; private readonly Dictionary _containerApps = new(new ResourceNameComparer()); + private readonly List<(string ResourceName, string[] EndpointNames)> _upgradedEndpoints = []; + private bool _hasLoggedHttpsUpgrade; + + /// + /// Records HTTP endpoints that were upgraded to HTTPS for a resource. + /// + public void RecordHttpsUpgrade(string resourceName, string[] endpointNames) + { + if (endpointNames.Length > 0) + { + _upgradedEndpoints.Add((resourceName, endpointNames)); + } + } + + /// + /// Logs a single message about all HTTP endpoints that were upgraded to HTTPS. + /// + public void LogHttpsUpgradeIfNeeded() + { + if (_hasLoggedHttpsUpgrade || _upgradedEndpoints.Count == 0) + { + return; + } + + _hasLoggedHttpsUpgrade = true; + + var details = string.Join(", ", _upgradedEndpoints.Select(x => + x.EndpointNames.Length == 1 + ? $"{x.ResourceName}:{x.EndpointNames[0]}" + : $"{x.ResourceName}:{{{string.Join(", ", x.EndpointNames)}}}")); + + Logger.LogInformation( + "HTTP endpoints will use HTTPS (port 443) in Azure Container Apps: {Details}. " + + "To opt out, use .WithHttpsUpgrade(false) on the container app environment.", + details); + } public BaseContainerAppContext GetContainerAppContext(IResource resource) { diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 53c55b419dc..a0fe44a8a1a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -1182,41 +1182,96 @@ public async Task HttpAndTcpEndpointsCannotHaveTheSameTargetPort() } [Fact] - public async Task DefaultHttpIngressMustUsePort80() + public async Task DefaultHttpIngressUsesPort80EvenWithDifferentDevPort() { var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); builder.AddAzureContainerAppEnvironment("env"); + // Dev port 8081 should be ignored in ACA, mapped to port 80 builder.AddContainer("api", "myimage") - .WithHttpEndpoint(port: 8081); + .WithHttpEndpoint(port: 8081, targetPort: 8080); using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, default); + var model = app.Services.GetRequiredService(); - var ex = await Assert.ThrowsAsync(() => ExecuteBeforeStartHooksAsync(app, default)); + var container = Assert.Single(model.GetContainerResources()); + + container.TryGetLastAnnotation(out var target); + + var resource = target?.DeploymentTarget as AzureProvisioningResource; + + Assert.NotNull(resource); - Assert.Equal($"The endpoint 'http' is an http endpoint and must use port 80", ex.Message); + var (manifest, bicep) = await GetManifestWithBicep(resource); + + await Verify(manifest.ToString(), "json") + .AppendContentAsFile(bicep, "bicep"); } [Fact] - public async Task DefaultHttpsIngressMustUsePort443() + public async Task DefaultHttpsIngressUsesPort443EvenWithDifferentDevPort() { var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); builder.AddAzureContainerAppEnvironment("env"); + // Dev port 8081 should be ignored in ACA, mapped to port 443 builder.AddContainer("api", "myimage") - .WithHttpsEndpoint(port: 8081); + .WithHttpsEndpoint(port: 8081, targetPort: 8443); using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, default); + var model = app.Services.GetRequiredService(); - var ex = await Assert.ThrowsAsync(() => ExecuteBeforeStartHooksAsync(app, default)); + var container = Assert.Single(model.GetContainerResources()); + + container.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"); + } + + [Fact] + public async Task CanPreserveHttpSchemeUsingWithHttpsUpgrade() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddAzureContainerAppEnvironment("env") + .WithHttpsUpgrade(false); // Preserve HTTP scheme, don't upgrade to HTTPS + + builder.AddContainer("api", "myimage") + .WithHttpEndpoint(port: 8080, targetPort: 80); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); - Assert.Equal($"The endpoint 'https' is an https endpoint and must use port 443", ex.Message); + var model = app.Services.GetRequiredService(); + + var container = Assert.Single(model.GetContainerResources()); + + container.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"); } [Fact] diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CanPreserveHttpSchemeUsingWithHttpsUpgrade.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CanPreserveHttpSchemeUsingWithHttpsUpgrade.verified.bicep new file mode 100644 index 00000000000..e93d62d9130 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CanPreserveHttpSchemeUsingWithHttpsUpgrade.verified.bicep @@ -0,0 +1,33 @@ +@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 + +resource api 'Microsoft.App/containerApps@2025-01-01' = { + name: 'api' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + ingress: { + external: false + targetPort: 80 + transport: 'http' + } + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: 'myimage:latest' + name: 'api' + } + ] + scale: { + minReplicas: 1 + } + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CanPreserveHttpSchemeUsingWithHttpsUpgrade.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CanPreserveHttpSchemeUsingWithHttpsUpgrade.verified.json new file mode 100644 index 00000000000..e4d4267563d --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CanPreserveHttpSchemeUsingWithHttpsUpgrade.verified.json @@ -0,0 +1,8 @@ +{ + "type": "azure.bicep.v0", + "path": "api-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}" + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpIngressUsesPort80EvenWithDifferentDevPort.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpIngressUsesPort80EvenWithDifferentDevPort.verified.bicep new file mode 100644 index 00000000000..0a6ed93d5ad --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpIngressUsesPort80EvenWithDifferentDevPort.verified.bicep @@ -0,0 +1,33 @@ +@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 + +resource api 'Microsoft.App/containerApps@2025-01-01' = { + name: 'api' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + ingress: { + external: false + targetPort: 8080 + transport: 'http' + } + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: 'myimage:latest' + name: 'api' + } + ] + scale: { + minReplicas: 1 + } + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpIngressUsesPort80EvenWithDifferentDevPort.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpIngressUsesPort80EvenWithDifferentDevPort.verified.json new file mode 100644 index 00000000000..e4d4267563d --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpIngressUsesPort80EvenWithDifferentDevPort.verified.json @@ -0,0 +1,8 @@ +{ + "type": "azure.bicep.v0", + "path": "api-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}" + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpsIngressUsesPort443EvenWithDifferentDevPort.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpsIngressUsesPort443EvenWithDifferentDevPort.verified.bicep new file mode 100644 index 00000000000..4edbff63cbd --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpsIngressUsesPort443EvenWithDifferentDevPort.verified.bicep @@ -0,0 +1,33 @@ +@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 + +resource api 'Microsoft.App/containerApps@2025-01-01' = { + name: 'api' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + ingress: { + external: false + targetPort: 8443 + transport: 'http' + } + } + environmentId: env_outputs_azure_container_apps_environment_id + template: { + containers: [ + { + image: 'myimage:latest' + name: 'api' + } + ] + scale: { + minReplicas: 1 + } + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpsIngressUsesPort443EvenWithDifferentDevPort.verified.json b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpsIngressUsesPort443EvenWithDifferentDevPort.verified.json new file mode 100644 index 00000000000..e4d4267563d --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.DefaultHttpsIngressUsesPort443EvenWithDifferentDevPort.verified.json @@ -0,0 +1,8 @@ +{ + "type": "azure.bicep.v0", + "path": "api-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}" + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectWithManyReferenceTypes#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectWithManyReferenceTypes#00.verified.bicep index 236bc4fac14..48969a8f251 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectWithManyReferenceTypes#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectWithManyReferenceTypes#00.verified.bicep @@ -1,4 +1,4 @@ -@description('The location for the resource(s) to be deployed.') +@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 @@ -203,7 +203,7 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { } { name: 'HTTP_EP' - value: 'http://api.internal.${env_outputs_azure_container_apps_environment_default_domain}' + value: 'https://api.internal.${env_outputs_azure_container_apps_environment_default_domain}' } { name: 'HTTPS_EP' @@ -219,7 +219,7 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { } { name: 'PORT' - value: '80' + value: '443' } { name: 'HOST' @@ -227,11 +227,11 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { } { name: 'HOSTANDPORT' - value: 'api.internal.${env_outputs_azure_container_apps_environment_default_domain}:80' + value: 'api.internal.${env_outputs_azure_container_apps_environment_default_domain}:443' } { name: 'SCHEME' - value: 'http' + value: 'https' } { name: 'INTERNAL_HOSTANDPORT' @@ -260,4 +260,4 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { '${env_outputs_azure_container_registry_managed_identity_id}': { } } } -} +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectWithManyReferenceTypesAndContainerAppEnvironment#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectWithManyReferenceTypesAndContainerAppEnvironment#00.verified.bicep index 80cf6d0f10c..ed489a1b11d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectWithManyReferenceTypesAndContainerAppEnvironment#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.ProjectWithManyReferenceTypesAndContainerAppEnvironment#00.verified.bicep @@ -1,4 +1,4 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location param cae_outputs_azure_container_apps_environment_default_domain string @@ -192,7 +192,7 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { } { name: 'HTTP_EP' - value: 'http://api.internal.${cae_outputs_azure_container_apps_environment_default_domain}' + value: 'https://api.internal.${cae_outputs_azure_container_apps_environment_default_domain}' } { name: 'HTTPS_EP' @@ -208,7 +208,7 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { } { name: 'PORT' - value: '80' + value: '443' } { name: 'HOST' @@ -216,11 +216,11 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { } { name: 'HOSTANDPORT' - value: 'api.internal.${cae_outputs_azure_container_apps_environment_default_domain}:80' + value: 'api.internal.${cae_outputs_azure_container_apps_environment_default_domain}:443' } { name: 'SCHEME' - value: 'http' + value: 'https' } { name: 'INTERNAL_HOSTANDPORT' @@ -249,4 +249,4 @@ resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { '${cae_outputs_azure_container_registry_managed_identity_id}': { } } } -} +} \ No newline at end of file From a646b56a999c227d9dd606ba9416373794df18fa Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 2 Feb 2026 00:19:54 -0800 Subject: [PATCH 016/256] Add CLI telemetry commands (logs, spans, traces) (#14256) * Add CLI telemetry commands (logs, spans, traces) - Add 'aspire telemetry logs' command to view structured logs - Add 'aspire telemetry spans' command to view distributed tracing spans - Add 'aspire telemetry traces' command to view trace summaries - Add Dashboard HTTP API endpoints for telemetry data (/api/telemetry/*) - Support streaming mode with --follow flag for real-time updates - Support filtering by resource name (with replica expansion) - Add shared OtlpHelpers for time/ID formatting between CLI and Dashboard - Update ResourcesCommand to use Spectre Console Table with colored state/health - Add comprehensive tests for telemetry commands and API endpoints * Unify backchannel connection factory and improve XML docs * Consolidate Dashboard API URL builders in DashboardUrls.cs * Fix test to use DashboardUrls.BuildResourceQueryString * Fix streaming API to filter all data when resource not found - Added tests for FollowSpansAsync and FollowLogsAsync with invalid resource names - Fixed bug where invalid resource filter would return all data instead of none - When resourceNames are specified but can't be resolved, now correctly skips all items --- src/Aspire.Cli/Aspire.Cli.csproj | 5 + .../AppHostAuxiliaryBackchannel.cs | 131 +++--- .../Backchannel/AppHostConnectionResolver.cs | 6 +- .../AuxiliaryBackchannelMonitor.cs | 40 +- src/Aspire.Cli/Commands/ResourcesCommand.cs | 44 +- src/Aspire.Cli/Commands/RootCommand.cs | 2 + src/Aspire.Cli/Commands/TelemetryCommand.cs | 46 ++ .../Commands/TelemetryCommandHelpers.cs | 300 +++++++++++++ .../Commands/TelemetryLogsCommand.cs | 264 +++++++++++ .../Commands/TelemetrySpansCommand.cs | 270 ++++++++++++ .../Commands/TelemetryTracesCommand.cs | 410 ++++++++++++++++++ .../Otlp/OtlpCliJsonSerializerContext.cs | 115 +++++ src/Aspire.Cli/Program.cs | 4 + .../Projects/RunningInstanceManager.cs | 4 +- .../TelemetryCommandStrings.Designer.cs | 261 +++++++++++ .../Resources/TelemetryCommandStrings.resx | 186 ++++++++ .../xlf/TelemetryCommandStrings.cs.xlf | 117 +++++ .../xlf/TelemetryCommandStrings.de.xlf | 117 +++++ .../xlf/TelemetryCommandStrings.es.xlf | 117 +++++ .../xlf/TelemetryCommandStrings.fr.xlf | 117 +++++ .../xlf/TelemetryCommandStrings.it.xlf | 117 +++++ .../xlf/TelemetryCommandStrings.ja.xlf | 117 +++++ .../xlf/TelemetryCommandStrings.ko.xlf | 117 +++++ .../xlf/TelemetryCommandStrings.pl.xlf | 117 +++++ .../xlf/TelemetryCommandStrings.pt-BR.xlf | 117 +++++ .../xlf/TelemetryCommandStrings.ru.xlf | 117 +++++ .../xlf/TelemetryCommandStrings.tr.xlf | 117 +++++ .../xlf/TelemetryCommandStrings.zh-Hans.xlf | 117 +++++ .../xlf/TelemetryCommandStrings.zh-Hant.xlf | 117 +++++ .../Api/TelemetryApiService.cs | 226 ++++++++-- src/Aspire.Dashboard/Aspire.Dashboard.csproj | 5 + .../PostConfigureDashboardOptions.cs | 14 +- .../DashboardEndpointsBuilder.cs | 22 +- .../Model/Assistant/AIHelpers.cs | 17 +- .../Model/TelemetryExportService.cs | 1 + .../Model/TelemetryImportService.cs | 1 + .../Otlp/Model/OtlpHelpers.cs | 35 +- .../Model/Serialization/OtlpCommonJson.cs | 174 ++------ .../OtlpJsonProtobufConverter.cs | 1 + .../OtlpJsonSerializerContext.cs | 3 + .../Model/Serialization/OtlpMetricsJson.cs | 1 + .../AuxiliaryBackchannelRpcTarget.cs | 21 +- .../Backchannel/BackchannelDataTypes.cs | 11 + .../Backchannel/DashboardUrlsHelper.cs | 137 ++++-- .../Dashboard/DashboardEventHandlers.cs | 19 +- .../Dashboard/DashboardOptions.cs | 2 + .../DistributedApplicationBuilder.cs | 4 + src/Shared/DashboardUrls.cs | 98 +++++ src/Shared/Otlp/OtlpHelpers.cs | 91 ++++ .../Otlp/Serialization/OtlpCommonJson.cs | 159 +++++++ .../Otlp}/Serialization/OtlpLogsJson.cs | 34 +- .../Otlp}/Serialization/OtlpResourceJson.cs | 23 +- .../Otlp}/Serialization/OtlpTraceJson.cs | 34 +- .../ResourcesCommandTests.cs | 2 +- .../Commands/TelemetryCommandTests.cs | 243 +++++++++++ .../TestServices/TestDocsFetcher.cs | 17 + .../TestServices/TestHttpClientFactory.cs | 15 + tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 9 +- .../Integration/TelemetryApiTests.cs | 16 +- .../Model/TelemetryImportServiceTests.cs | 1 + .../TelemetryApiServiceTests.cs | 106 ++++- .../Dashboard/DashboardResourceTests.cs | 5 + 62 files changed, 4693 insertions(+), 463 deletions(-) create mode 100644 src/Aspire.Cli/Commands/TelemetryCommand.cs create mode 100644 src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs create mode 100644 src/Aspire.Cli/Commands/TelemetryLogsCommand.cs create mode 100644 src/Aspire.Cli/Commands/TelemetrySpansCommand.cs create mode 100644 src/Aspire.Cli/Commands/TelemetryTracesCommand.cs create mode 100644 src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs create mode 100644 src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs create mode 100644 src/Aspire.Cli/Resources/TelemetryCommandStrings.resx create mode 100644 src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf create mode 100644 src/Shared/Otlp/OtlpHelpers.cs create mode 100644 src/Shared/Otlp/Serialization/OtlpCommonJson.cs rename src/{Aspire.Dashboard/Otlp/Model => Shared/Otlp}/Serialization/OtlpLogsJson.cs (79%) rename src/{Aspire.Dashboard/Otlp/Model => Shared/Otlp}/Serialization/OtlpResourceJson.cs (63%) rename src/{Aspire.Dashboard/Otlp/Model => Shared/Otlp}/Serialization/OtlpTraceJson.cs (87%) create mode 100644 tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/TestDocsFetcher.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/TestHttpClientFactory.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 735aebb190a..cd93b5c2f7a 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -73,6 +73,11 @@ + + + + + diff --git a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs index 6f33d27b992..c27ba375905 100644 --- a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs @@ -5,6 +5,7 @@ using System.Net.Sockets; using System.Runtime.CompilerServices; using System.Text.Json; +using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; using StreamJsonRpc; @@ -20,27 +21,20 @@ internal sealed class AppHostAuxiliaryBackchannel : IDisposable private readonly ILogger? _logger; private JsonRpc? _rpc; private bool _disposed; - private ImmutableHashSet _capabilities = ImmutableHashSet.Empty; + private readonly ImmutableHashSet _capabilities; /// - /// Initializes a new instance of the class - /// for an existing connection. + /// Private constructor - use factory methods to create instances. /// - /// The hash identifier for this AppHost instance. - /// The socket path for this connection. - /// The JSON-RPC proxy for communicating with the AppHost. - /// The MCP connection information for the Dashboard. - /// The AppHost information. - /// Whether this AppHost is within the scope of the MCP server's working directory. - /// Optional logger for diagnostic messages. - public AppHostAuxiliaryBackchannel( + private AppHostAuxiliaryBackchannel( string hash, string socketPath, JsonRpc rpc, DashboardMcpConnectionInfo? mcpInfo, AppHostInformation? appHostInfo, bool isInScope, - ILogger? logger = null) + ImmutableHashSet capabilities, + ILogger? logger) { Hash = hash; SocketPath = socketPath; @@ -48,22 +42,23 @@ public AppHostAuxiliaryBackchannel( McpInfo = mcpInfo; AppHostInfo = appHostInfo; IsInScope = isInScope; + _capabilities = capabilities; ConnectedAt = DateTimeOffset.UtcNow; _logger = logger; } /// - /// Initializes a new instance of the class - /// for a new connection that needs to be established. + /// Internal constructor for testing purposes. /// - /// The socket path to connect to. - /// Optional logger for diagnostic messages. - private AppHostAuxiliaryBackchannel(string socketPath, ILogger? logger = null) + internal AppHostAuxiliaryBackchannel( + string hash, + string socketPath, + JsonRpc rpc, + DashboardMcpConnectionInfo? mcpInfo, + AppHostInformation? appHostInfo, + bool isInScope) + : this(hash, socketPath, rpc, mcpInfo, appHostInfo, isInScope, ImmutableHashSet.Empty, null) { - SocketPath = socketPath; - Hash = string.Empty; - ConnectedAt = DateTimeOffset.UtcNow; - _logger = logger; } /// @@ -89,7 +84,7 @@ private AppHostAuxiliaryBackchannel(string socketPath, ILogger? logger = null) /// /// Gets a value indicating whether this AppHost is within the scope of the MCP server's working directory. /// - public bool IsInScope { get; private set; } + public bool IsInScope { get; internal set; } /// /// Gets the timestamp when this connection was established. @@ -128,62 +123,88 @@ private JsonRpc EnsureConnected() /// Optional logger for diagnostic messages. /// Cancellation token. /// A connected AppHostAuxiliaryBackchannel instance. - public static async Task ConnectAsync( + public static Task ConnectAsync( string socketPath, ILogger? logger = null, CancellationToken cancellationToken = default) { - var backchannel = new AppHostAuxiliaryBackchannel(socketPath, logger); - await backchannel.ConnectInternalAsync(cancellationToken).ConfigureAwait(false); - return backchannel; + var hash = AppHostHelper.ExtractHashFromSocketPath(socketPath) ?? string.Empty; + return CreateFromSocketAsync(hash, socketPath, isInScope: true, socket: null, logger, cancellationToken); } - private async Task ConnectInternalAsync(CancellationToken cancellationToken) + /// + /// Creates an AppHostAuxiliaryBackchannel by connecting to the specified socket path, + /// or using an already-connected socket if provided. + /// This is the single path for all connection creation, ensuring capabilities are always fetched. + /// + /// The AppHost hash identifier. + /// The socket path. + /// Whether this AppHost is within the scope of the working directory. + /// Optional already-connected socket. If null, a new connection will be established. + /// Optional logger. + /// Cancellation token (only used when socket is null). + /// A connected AppHostAuxiliaryBackchannel instance. + internal static async Task CreateFromSocketAsync( + string hash, + string socketPath, + bool isInScope, + Socket? socket = null, + ILogger? logger = null, + CancellationToken cancellationToken = default) { - _logger?.LogDebug("Connecting to auxiliary backchannel at {SocketPath}", SocketPath); - - // Connect to the Unix socket - var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); - var endpoint = new UnixDomainSocketEndPoint(SocketPath); + // Connect if no socket provided + if (socket is null) + { + logger?.LogDebug("Connecting to auxiliary backchannel at {SocketPath}", socketPath); - await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false); + socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + var endpoint = new UnixDomainSocketEndPoint(socketPath); + await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false); + } // Create JSON-RPC connection with proper formatter var stream = new NetworkStream(socket, ownsSocket: true); - _rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, stream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter())); - _rpc.StartListening(); + var rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, stream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter())); + rpc.StartListening(); - _logger?.LogDebug("Connected to auxiliary backchannel at {SocketPath}", SocketPath); + logger?.LogDebug("Connected to auxiliary backchannel at {SocketPath}", socketPath); - // Fetch capabilities to determine API version support - await FetchCapabilitiesAsync(cancellationToken).ConfigureAwait(false); + // Fetch all connection info + var appHostInfo = await rpc.InvokeAsync("GetAppHostInformationAsync").ConfigureAwait(false); + var mcpInfo = await rpc.InvokeAsync("GetDashboardMcpConnectionInfoAsync").ConfigureAwait(false); + var capabilities = await FetchCapabilitiesAsync(rpc, logger).ConfigureAwait(false); - // Get the AppHost information - AppHostInfo = await GetAppHostInformationAsync(cancellationToken).ConfigureAwait(false); + var capabilitiesSet = capabilities?.ToImmutableHashSet() ?? ImmutableHashSet.Create(AuxiliaryBackchannelCapabilities.V1); + + return new AppHostAuxiliaryBackchannel(hash, socketPath, rpc, mcpInfo, appHostInfo, isInScope, capabilitiesSet, logger); } /// - /// Fetches the capabilities from the AppHost to determine supported API versions. + /// Fetches capabilities from an AppHost via RPC. /// - private async Task FetchCapabilitiesAsync(CancellationToken cancellationToken) + /// The JSON-RPC connection. + /// Optional logger. + /// The capabilities array, or null if not supported. + private static async Task FetchCapabilitiesAsync(JsonRpc rpc, ILogger? logger = null) { - var rpc = EnsureConnected(); - try { - var response = await rpc.InvokeWithCancellationAsync( - "GetCapabilitiesAsync", - [null], // Pass null request - cancellationToken).ConfigureAwait(false); - - _capabilities = response?.Capabilities?.ToImmutableHashSet() ?? ImmutableHashSet.Create(AuxiliaryBackchannelCapabilities.V1); - _logger?.LogDebug("AppHost capabilities: {Capabilities}", string.Join(", ", _capabilities)); + var response = await rpc.InvokeAsync("GetCapabilitiesAsync", [null]).ConfigureAwait(false); + var capabilities = response?.Capabilities; + logger?.LogDebug("AppHost capabilities: {Capabilities}", capabilities is not null ? string.Join(", ", capabilities) : "null"); + return capabilities; } catch (RemoteMethodNotFoundException) { // Older AppHost without GetCapabilitiesAsync - assume v1 only - _capabilities = ImmutableHashSet.Create(AuxiliaryBackchannelCapabilities.V1); - _logger?.LogDebug("AppHost does not support GetCapabilitiesAsync, assuming v1 only"); + logger?.LogDebug("AppHost does not support GetCapabilitiesAsync, assuming v1 only"); + return null; + } + catch (Exception ex) + { + // Log any other exception + logger?.LogWarning(ex, "Failed to fetch capabilities from AppHost"); + return null; } } @@ -464,7 +485,7 @@ public async Task CallResourceMcpToolAsync( { if (!SupportsV2) { - // Fall back to v1 and combine results + // Fall back to v1 - ApiBaseUrl and ApiToken are only available in v2 var mcpInfo = await GetDashboardMcpConnectionInfoAsync(cancellationToken).ConfigureAwait(false); var urlsState = await GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false); @@ -482,6 +503,8 @@ public async Task CallResourceMcpToolAsync( { McpBaseUrl = mcpInfo?.EndpointUrl, McpApiToken = mcpInfo?.ApiToken, + ApiBaseUrl = null, // Not available in v1 + ApiToken = null, // Not available in v1 DashboardUrls = urls.ToArray(), IsHealthy = urlsState?.DashboardHealthy ?? false }; diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index fd5ab6e8c28..575a3d52c15 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs @@ -22,8 +22,10 @@ internal sealed class AppHostConnectionResult } /// -/// Helper for resolving connections to running AppHosts. -/// Used by commands that need to connect to a running AppHost (stop, resources, logs, etc.). +/// Discovers and resolves connections to running AppHosts when the socket path is not known. +/// Scans for running AppHosts and prompts the user to select one if multiple are found. +/// Used by CLI commands (stop, resources, logs, telemetry) that need to find a running AppHost. +/// For managing a specific instance when the socket path is known, use instead. /// internal sealed class AppHostConnectionResolver( IAuxiliaryBackchannelMonitor backchannelMonitor, diff --git a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs index f3571dae231..094cea949fb 100644 --- a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs +++ b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs @@ -10,7 +10,6 @@ using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using StreamJsonRpc; namespace Aspire.Cli.Backchannel; @@ -401,24 +400,19 @@ private async Task TryConnectToSocketAsync(string socketPath, ConcurrentBag("GetAppHostInformationAsync").ConfigureAwait(false); - - // Get the MCP connection info - var mcpInfo = await rpc.InvokeAsync("GetDashboardMcpConnectionInfoAsync").ConfigureAwait(false); - // Determine if this AppHost is in scope of the MCP server's working directory - var isInScope = IsAppHostInScope(appHostInfo?.AppHostPath); + // We need to do a quick check before full connection to avoid unnecessary work + var isInScope = true; // Will be updated after we get appHostInfo + + // Use the centralized factory to create the connection + // This ensures capabilities are always fetched + var connection = await AppHostAuxiliaryBackchannel.CreateFromSocketAsync(hash, socketPath, isInScope, socket, logger, cancellationToken).ConfigureAwait(false); - var connection = new AppHostAuxiliaryBackchannel(hash, socketPath, rpc, mcpInfo, appHostInfo, isInScope, logger); + // Update isInScope based on actual appHostInfo now that we have it + connection.IsInScope = IsAppHostInScope(connection.AppHostInfo?.AppHostPath); // Set up disconnect handler - rpc.Disconnected += (sender, args) => + connection.Rpc!.Disconnected += (sender, args) => { logger.LogInformation("Disconnected from AppHost at {SocketPath}: {Reason}", socketPath, args.Reason); if (_connectionsByHash.TryGetValue(hash, out var connectionsForHash) && @@ -447,15 +441,17 @@ private async Task TryConnectToSocketAsync(string socketPath, ConcurrentBag snapshots) // Get display names for all resources var orderedItems = snapshots.Select(s => (Snapshot: s, DisplayName: ResourceSnapshotMapper.GetResourceName(s, snapshots))) .OrderBy(x => x.DisplayName) - .ToList();; + .ToList(); - // Calculate column widths based on data - var nameWidth = Math.Max("NAME".Length, orderedItems.Max(i => i.DisplayName.Length)); - var typeWidth = Math.Max("TYPE".Length, orderedItems.Max(i => i.Snapshot.ResourceType?.Length ?? 0)); - var stateWidth = Math.Max("STATE".Length, orderedItems.Max(i => i.Snapshot.State?.Length ?? "Unknown".Length)); - var healthWidth = Math.Max("HEALTH".Length, orderedItems.Max(i => i.Snapshot.HealthStatus?.Length ?? 1)); - - var totalWidth = nameWidth + typeWidth + stateWidth + healthWidth + 12 + 20; // 12 for spacing, 20 for endpoints min - - // Header - _interactionService.DisplayPlainText(""); - _interactionService.DisplayPlainText($"{"NAME".PadRight(nameWidth)} {"TYPE".PadRight(typeWidth)} {"STATE".PadRight(stateWidth)} {"HEALTH".PadRight(healthWidth)} {"ENDPOINTS"}"); - _interactionService.DisplayPlainText(new string('-', totalWidth)); + var table = new Table(); + table.AddColumn("Name"); + table.AddColumn("Type"); + table.AddColumn("State"); + table.AddColumn("Health"); + table.AddColumn("Endpoints"); foreach (var (snapshot, displayName) in orderedItems) { @@ -260,10 +255,29 @@ private void DisplayResourcesTable(IReadOnlyList snapshots) var state = snapshot.State ?? "Unknown"; var health = snapshot.HealthStatus ?? "-"; - _interactionService.DisplayPlainText($"{displayName.PadRight(nameWidth)} {type.PadRight(typeWidth)} {state.PadRight(stateWidth)} {health.PadRight(healthWidth)} {endpoints}"); + // Color the state based on value + var stateText = state.ToUpperInvariant() switch + { + "RUNNING" => $"[green]{state}[/]", + "FINISHED" or "EXITED" => $"[grey]{state}[/]", + "FAILEDTOSTART" or "FAILED" => $"[red]{state}[/]", + "STARTING" or "WAITING" => $"[yellow]{state}[/]", + _ => state + }; + + // Color the health based on value + var healthText = health.ToUpperInvariant() switch + { + "HEALTHY" => $"[green]{health}[/]", + "UNHEALTHY" => $"[red]{health}[/]", + "DEGRADED" => $"[yellow]{health}[/]", + _ => health + }; + + table.AddRow(displayName, type, stateText, healthText, endpoints); } - _interactionService.DisplayPlainText(""); + AnsiConsole.Write(table); } private void DisplayResourceUpdate(ResourceSnapshot snapshot, IDictionary allResources) diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index e5e408799ff..cb201b4e4f1 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -72,6 +72,7 @@ public RootCommand( UpdateCommand updateCommand, McpCommand mcpCommand, AgentCommand agentCommand, + TelemetryCommand telemetryCommand, SdkCommand sdkCommand, ExtensionInternalCommand extensionInternalCommand, IFeatures featureFlags, @@ -127,6 +128,7 @@ public RootCommand( Subcommands.Add(extensionInternalCommand); Subcommands.Add(mcpCommand); Subcommands.Add(agentCommand); + Subcommands.Add(telemetryCommand); if (featureFlags.IsFeatureEnabled(KnownFeatures.ExecCommandEnabled, false)) { diff --git a/src/Aspire.Cli/Commands/TelemetryCommand.cs b/src/Aspire.Cli/Commands/TelemetryCommand.cs new file mode 100644 index 00000000000..b6127e903c9 --- /dev/null +++ b/src/Aspire.Cli/Commands/TelemetryCommand.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 System.CommandLine; +using System.CommandLine.Help; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Commands; + +/// +/// Parent command for telemetry operations. Contains subcommands for viewing logs, spans, and traces. +/// +internal sealed class TelemetryCommand : BaseCommand +{ + public TelemetryCommand( + TelemetryLogsCommand logsCommand, + TelemetrySpansCommand spansCommand, + TelemetryTracesCommand tracesCommand, + IInteractionService interactionService, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + AspireCliTelemetry telemetry) + : base("telemetry", TelemetryCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) + { + ArgumentNullException.ThrowIfNull(logsCommand); + ArgumentNullException.ThrowIfNull(spansCommand); + ArgumentNullException.ThrowIfNull(tracesCommand); + + Subcommands.Add(logsCommand); + Subcommands.Add(spansCommand); + Subcommands.Add(tracesCommand); + } + + protected override bool UpdateNotificationsEnabled => false; + + protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + new HelpAction().Invoke(parseResult); + return Task.FromResult(ExitCodeConstants.InvalidCommand); + } +} diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs new file mode 100644 index 00000000000..9d4e8241e53 --- /dev/null +++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs @@ -0,0 +1,300 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Globalization; +using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Interaction; +using Aspire.Cli.Otlp; +using Aspire.Cli.Resources; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; +using Aspire.Shared; +using Spectre.Console; + +namespace Aspire.Cli.Commands; + +/// +/// Shared helper methods for telemetry commands. +/// +internal static class TelemetryCommandHelpers +{ + /// + /// HTTP header name for API authentication. + /// + internal const string ApiKeyHeaderName = "X-API-Key"; + + #region Shared Command Options + + /// + /// Resource name argument shared across telemetry commands. + /// + internal static Argument CreateResourceArgument() => new("resource") + { + Description = TelemetryCommandStrings.ResourceArgumentDescription, + Arity = ArgumentArity.ZeroOrOne + }; + + /// + /// Project option shared across telemetry commands. + /// + internal static Option CreateProjectOption() => new("--project") + { + Description = TelemetryCommandStrings.ProjectOptionDescription + }; + + /// + /// Output format option shared across telemetry commands. + /// + internal static Option CreateFormatOption() => new("--format") + { + Description = TelemetryCommandStrings.FormatOptionDescription + }; + + /// + /// Limit option shared across telemetry commands. + /// + internal static Option CreateLimitOption() => new("--limit", "-n") + { + Description = TelemetryCommandStrings.LimitOptionDescription + }; + + /// + /// Follow/streaming option for logs and spans commands. + /// + internal static Option CreateFollowOption() => new("--follow", "-f") + { + Description = TelemetryCommandStrings.FollowOptionDescription + }; + + /// + /// Trace ID filter option shared across telemetry commands. + /// + internal static Option CreateTraceIdOption(string name, string? alias = null) + { + var option = alias is null ? new Option(name) : new Option(name, alias); + option.Description = TelemetryCommandStrings.TraceIdOptionDescription; + return option; + } + + /// + /// Has error filter option for spans and traces commands. + /// + internal static Option CreateHasErrorOption() => new("--has-error") + { + Description = TelemetryCommandStrings.HasErrorOptionDescription + }; + + #endregion + + /// + /// Validates that an HTTP response has a JSON content type. + /// + /// The HTTP response to validate. + /// True if the response has a JSON content type; false otherwise. + public static bool HasJsonContentType(HttpResponseMessage response) + { + var mediaType = response.Content.Headers.ContentType?.MediaType; + return mediaType is "application/json" or "text/json" or "application/x-ndjson"; + } + + /// + /// Resolves an AppHost connection and gets Dashboard API info. + /// + /// A tuple with success status, base URL, API token, dashboard UI URL, and exit code if failed. + public static async Task<(bool Success, string? BaseUrl, string? ApiToken, string? DashboardUrl, int ExitCode)> GetDashboardApiAsync( + AppHostConnectionResolver connectionResolver, + IInteractionService interactionService, + FileInfo? projectFile, + OutputFormat format, + CancellationToken cancellationToken) + { + // When outputting JSON, suppress status messages to keep output machine-readable + var scanningMessage = format == OutputFormat.Json ? string.Empty : TelemetryCommandStrings.ScanningForRunningAppHosts; + + var result = await connectionResolver.ResolveConnectionAsync( + projectFile, + scanningMessage, + TelemetryCommandStrings.SelectAppHost, + TelemetryCommandStrings.NoInScopeAppHostsShowingAll, + TelemetryCommandStrings.AppHostNotRunning, + cancellationToken); + + if (!result.Success) + { + return (false, null, null, null, ExitCodeConstants.Success); + } + + var dashboardInfo = await result.Connection!.GetDashboardInfoV2Async(cancellationToken); + if (dashboardInfo?.ApiBaseUrl is null || dashboardInfo.ApiToken is null) + { + interactionService.DisplayError(TelemetryCommandStrings.DashboardApiNotAvailable); + return (false, null, null, null, ExitCodeConstants.DashboardFailure); + } + + // Extract dashboard base URL (without /login path) for hyperlinks + var dashboardUrl = ExtractDashboardBaseUrl(dashboardInfo.DashboardUrls?.FirstOrDefault()); + + return (true, dashboardInfo.ApiBaseUrl, dashboardInfo.ApiToken, dashboardUrl, 0); + } + + /// + /// Extracts the base URL from a dashboard URL (removes /login?t=... path). + /// + private static string? ExtractDashboardBaseUrl(string? dashboardUrlWithToken) + { + if (string.IsNullOrEmpty(dashboardUrlWithToken)) + { + return null; + } + + // Dashboard URLs look like: http://localhost:18888/login?t=abcd1234 + // We want: http://localhost:18888 + var uri = new Uri(dashboardUrlWithToken); + return $"{uri.Scheme}://{uri.Authority}"; + } + + /// + /// Creates an HTTP client configured for Dashboard API access. + /// + public static HttpClient CreateApiClient(IHttpClientFactory factory, string apiToken) + { + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add(ApiKeyHeaderName, apiToken); + return client; + } + + /// + /// Fetches available resources from the Dashboard API and resolves a resource name to specific instances. + /// If the resource name matches a base name with multiple replicas, returns all matching replica names. + /// + /// The HTTP client configured for Dashboard API access. + /// The Dashboard API base URL. + /// The resource name to resolve (can be base name or full instance name). + /// Cancellation token. + /// A list of resolved resource display names to query, or null if resource not found. + public static async Task?> ResolveResourceNamesAsync( + HttpClient client, + string baseUrl, + string? resourceName, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(resourceName)) + { + // No filter - return null to indicate no resource filter + return null; + } + + // Fetch available resources + var url = DashboardUrls.TelemetryResourcesApiUrl(baseUrl); + var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var resources = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + if (resources is null || resources.Length == 0) + { + return null; + } + + // First, try exact match on display name (full instance name like "catalogservice-abc123") + var exactMatch = resources.FirstOrDefault(r => + string.Equals(r.DisplayName, resourceName, StringComparison.OrdinalIgnoreCase)); + if (exactMatch is not null) + { + return [exactMatch.DisplayName]; + } + + // Then, try matching by base name to find all replicas + var matchingReplicas = resources + .Where(r => string.Equals(r.Name, resourceName, StringComparison.OrdinalIgnoreCase)) + .Select(r => r.DisplayName) + .ToList(); + + if (matchingReplicas.Count > 0) + { + return matchingReplicas; + } + + // No match found + return []; + } + + /// + /// Displays a "no data found" message with consistent styling. + /// + /// The type of data (e.g., "logs", "spans", "traces"). + public static void DisplayNoData(string dataType) + { + AnsiConsole.MarkupLine($"[yellow]No {dataType} found[/]"); + } + + /// + /// Creates a Spectre Console hyperlink markup for a trace detail in the Dashboard. + /// + /// The base dashboard URL. + /// The trace ID. + /// The text to display (defaults to shortened trace ID). + /// A Spectre markup string with hyperlink, or plain text if dashboardUrl is null. + public static string FormatTraceLink(string? dashboardUrl, string traceId, string? displayText = null) + { + var text = displayText ?? OtlpHelpers.ToShortenedId(traceId); + if (string.IsNullOrEmpty(dashboardUrl)) + { + return text; + } + + // Dashboard trace detail URL: /traces/detail/{traceId} + var url = DashboardUrls.CombineUrl(dashboardUrl, DashboardUrls.TraceDetailUrl(traceId)); + return $"[link={url}]{text}[/]"; + } + + /// + /// Formats a duration using the shared DurationFormatter. + /// + public static string FormatDuration(TimeSpan duration) + { + return DurationFormatter.FormatDuration(duration, CultureInfo.InvariantCulture); + } + + /// + /// Gets Spectre Console color for a log severity number. + /// OTLP severity numbers: 1-4=TRACE, 5-8=DEBUG, 9-12=INFO, 13-16=WARN, 17-20=ERROR, 21-24=FATAL + /// + public static Color GetSeverityColor(int? severityNumber) + { + return severityNumber switch + { + >= 17 => Color.Red, // ERROR/FATAL + >= 13 => Color.Yellow, // WARN + >= 9 => Color.Blue, // INFO + >= 5 => Color.Grey, // DEBUG + >= 1 => Color.Grey, // TRACE + _ => Color.White + }; + } + + /// + /// Reads lines from an HTTP streaming response, yielding each complete line as it arrives. + /// + public static async IAsyncEnumerable ReadLinesAsync( + this StreamReader reader, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (line is null) + { + yield break; + } + + if (!string.IsNullOrEmpty(line)) + { + yield return line; + } + } + } +} diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs new file mode 100644 index 00000000000..46afd08b45b --- /dev/null +++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs @@ -0,0 +1,264 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Globalization; +using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Otlp; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; +using Aspire.Otlp.Serialization; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace Aspire.Cli.Commands; + +/// +/// Command to view structured logs from the Dashboard telemetry API. +/// +internal sealed class TelemetryLogsCommand : BaseCommand +{ + private readonly IInteractionService _interactionService; + private readonly AppHostConnectionResolver _connectionResolver; + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + + // Shared options from TelemetryCommandHelpers + private static readonly Argument s_resourceArgument = TelemetryCommandHelpers.CreateResourceArgument(); + private static readonly Option s_projectOption = TelemetryCommandHelpers.CreateProjectOption(); + private static readonly Option s_followOption = TelemetryCommandHelpers.CreateFollowOption(); + private static readonly Option s_formatOption = TelemetryCommandHelpers.CreateFormatOption(); + private static readonly Option s_limitOption = TelemetryCommandHelpers.CreateLimitOption(); + private static readonly Option s_traceIdOption = TelemetryCommandHelpers.CreateTraceIdOption("--trace-id"); + // Logs-specific option + private static readonly Option s_severityOption = new("--severity") + { + Description = TelemetryCommandStrings.SeverityOptionDescription + }; + + public TelemetryLogsCommand( + IInteractionService interactionService, + IAuxiliaryBackchannelMonitor backchannelMonitor, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + AspireCliTelemetry telemetry, + IHttpClientFactory httpClientFactory, + ILogger logger) + : base("logs", TelemetryCommandStrings.LogsDescription, features, updateNotifier, executionContext, interactionService, telemetry) + { + _interactionService = interactionService; + _httpClientFactory = httpClientFactory; + _logger = logger; + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); + + Arguments.Add(s_resourceArgument); + Options.Add(s_projectOption); + Options.Add(s_followOption); + Options.Add(s_formatOption); + Options.Add(s_limitOption); + Options.Add(s_traceIdOption); + Options.Add(s_severityOption); + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + using var activity = Telemetry.StartDiagnosticActivity(Name); + + var resourceName = parseResult.GetValue(s_resourceArgument); + var passedAppHostProjectFile = parseResult.GetValue(s_projectOption); + var follow = parseResult.GetValue(s_followOption); + var format = parseResult.GetValue(s_formatOption); + var limit = parseResult.GetValue(s_limitOption); + var traceId = parseResult.GetValue(s_traceIdOption); + var severity = parseResult.GetValue(s_severityOption); + + // Validate --limit value + if (limit.HasValue && limit.Value < 1) + { + _interactionService.DisplayError(TelemetryCommandStrings.LimitMustBePositive); + return ExitCodeConstants.InvalidCommand; + } + + var (success, baseUrl, apiToken, _, exitCode) = await TelemetryCommandHelpers.GetDashboardApiAsync( + _connectionResolver, _interactionService, passedAppHostProjectFile, format, cancellationToken); + + if (!success) + { + return exitCode; + } + + return await FetchLogsAsync(baseUrl!, apiToken!, resourceName, traceId, severity, limit, follow, format, cancellationToken); + } + + private async Task FetchLogsAsync( + string baseUrl, + string apiToken, + string? resource, + string? traceId, + string? severity, + int? limit, + bool follow, + OutputFormat format, + CancellationToken cancellationToken) + { + using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); + + // Resolve resource name to specific instances (handles replicas) + var resolvedResources = await TelemetryCommandHelpers.ResolveResourceNamesAsync( + client, baseUrl, resource, cancellationToken); + + // If a resource was specified but not found, show error + if (!string.IsNullOrEmpty(resource) && resolvedResources?.Count == 0) + { + _interactionService.DisplayError($"Resource '{resource}' not found."); + return ExitCodeConstants.InvalidCommand; + } + + // Build query string with multiple resource parameters + var additionalParams = new List<(string key, string? value)> + { + ("traceId", traceId), + ("severity", severity) + }; + if (limit.HasValue && !follow) + { + additionalParams.Add(("limit", limit.Value.ToString(CultureInfo.InvariantCulture))); + } + if (follow) + { + additionalParams.Add(("follow", "true")); + } + + var url = DashboardUrls.TelemetryLogsApiUrl(baseUrl, resolvedResources, [.. additionalParams]); + + try + { + if (follow) + { + return await StreamLogsAsync(client, url, format, cancellationToken); + } + else + { + return await GetLogsSnapshotAsync(client, url, format, cancellationToken); + } + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to fetch logs from Dashboard API"); + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); + return ExitCodeConstants.DashboardFailure; + } + } + + private async Task GetLogsSnapshotAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken) + { + var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + if (!TelemetryCommandHelpers.HasJsonContentType(response)) + { + _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType); + return ExitCodeConstants.DashboardFailure; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + + if (format == OutputFormat.Json) + { + _interactionService.DisplayRawText(json); + } + else + { + DisplayLogsSnapshot(json); + } + + return ExitCodeConstants.Success; + } + + private async Task StreamLogsAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken) + { + using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + if (!TelemetryCommandHelpers.HasJsonContentType(response)) + { + _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType); + return ExitCodeConstants.DashboardFailure; + } + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var reader = new StreamReader(stream); + + await foreach (var line in reader.ReadLinesAsync(cancellationToken)) + { + if (format == OutputFormat.Json) + { + _interactionService.DisplayRawText(line); + } + else + { + DisplayLogsStreamLine(line); + } + } + + return ExitCodeConstants.Success; + } + + private static void DisplayLogsSnapshot(string json) + { + var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + var resourceLogs = response?.Data?.ResourceLogs; + + if (resourceLogs is null or { Length: 0 }) + { + TelemetryCommandHelpers.DisplayNoData("logs"); + return; + } + + DisplayResourceLogs(resourceLogs); + } + + private static void DisplayLogsStreamLine(string json) + { + var request = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.OtlpExportLogsServiceRequestJson); + DisplayResourceLogs(request?.ResourceLogs ?? []); + } + + private static void DisplayResourceLogs(IEnumerable resourceLogs) + { + foreach (var resourceLog in resourceLogs) + { + var resourceName = resourceLog.Resource?.GetServiceName() ?? "unknown"; + + foreach (var scopeLog in resourceLog.ScopeLogs ?? []) + { + foreach (var log in scopeLog.LogRecords ?? []) + { + DisplayLogEntry(resourceName, log); + } + } + } + } + + // Using simple text lines instead of Spectre.Console Table for streaming support. + // Tables require knowing all data upfront, but streaming mode displays logs as they arrive. + private static void DisplayLogEntry(string resourceName, OtlpLogRecordJson log) + { + var timestamp = OtlpHelpers.FormatNanoTimestamp(log.TimeUnixNano); + var severity = log.SeverityText ?? ""; + var body = log.Body?.StringValue ?? ""; + + // Use severity number for color mapping (more reliable than text) + var severityColor = TelemetryCommandHelpers.GetSeverityColor(log.SeverityNumber); + + var escapedBody = body.EscapeMarkup(); + AnsiConsole.MarkupLine($"[grey]{timestamp}[/] [{severityColor}]{severity,-5}[/] [cyan]{resourceName}[/] {escapedBody}"); + } +} diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs new file mode 100644 index 00000000000..4f884ee9a65 --- /dev/null +++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs @@ -0,0 +1,270 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Globalization; +using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Otlp; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; +using Aspire.Otlp.Serialization; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace Aspire.Cli.Commands; + +/// +/// Command to view spans from the Dashboard telemetry API. +/// +internal sealed class TelemetrySpansCommand : BaseCommand +{ + private readonly IInteractionService _interactionService; + private readonly AppHostConnectionResolver _connectionResolver; + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + + // Shared options from TelemetryCommandHelpers + private static readonly Argument s_resourceArgument = TelemetryCommandHelpers.CreateResourceArgument(); + private static readonly Option s_projectOption = TelemetryCommandHelpers.CreateProjectOption(); + private static readonly Option s_followOption = TelemetryCommandHelpers.CreateFollowOption(); + private static readonly Option s_formatOption = TelemetryCommandHelpers.CreateFormatOption(); + private static readonly Option s_limitOption = TelemetryCommandHelpers.CreateLimitOption(); + private static readonly Option s_traceIdOption = TelemetryCommandHelpers.CreateTraceIdOption("--trace-id"); + private static readonly Option s_hasErrorOption = TelemetryCommandHelpers.CreateHasErrorOption(); + + public TelemetrySpansCommand( + IInteractionService interactionService, + IAuxiliaryBackchannelMonitor backchannelMonitor, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + AspireCliTelemetry telemetry, + IHttpClientFactory httpClientFactory, + ILogger logger) + : base("spans", TelemetryCommandStrings.SpansDescription, features, updateNotifier, executionContext, interactionService, telemetry) + { + _interactionService = interactionService; + _httpClientFactory = httpClientFactory; + _logger = logger; + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); + + Arguments.Add(s_resourceArgument); + Options.Add(s_projectOption); + Options.Add(s_followOption); + Options.Add(s_formatOption); + Options.Add(s_limitOption); + Options.Add(s_traceIdOption); + Options.Add(s_hasErrorOption); + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + using var activity = Telemetry.StartDiagnosticActivity(Name); + + var resourceName = parseResult.GetValue(s_resourceArgument); + var passedAppHostProjectFile = parseResult.GetValue(s_projectOption); + var follow = parseResult.GetValue(s_followOption); + var format = parseResult.GetValue(s_formatOption); + var limit = parseResult.GetValue(s_limitOption); + var traceId = parseResult.GetValue(s_traceIdOption); + var hasError = parseResult.GetValue(s_hasErrorOption); + + // Validate --limit value + if (limit.HasValue && limit.Value < 1) + { + _interactionService.DisplayError(TelemetryCommandStrings.LimitMustBePositive); + return ExitCodeConstants.InvalidCommand; + } + + var (success, baseUrl, apiToken, _, exitCode) = await TelemetryCommandHelpers.GetDashboardApiAsync( + _connectionResolver, _interactionService, passedAppHostProjectFile, format, cancellationToken); + + if (!success) + { + return exitCode; + } + + return await FetchSpansAsync(baseUrl!, apiToken!, resourceName, traceId, hasError, limit, follow, format, cancellationToken); + } + + private async Task FetchSpansAsync( + string baseUrl, + string apiToken, + string? resource, + string? traceId, + bool? hasError, + int? limit, + bool follow, + OutputFormat format, + CancellationToken cancellationToken) + { + using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); + + // Resolve resource name to specific instances (handles replicas) + var resolvedResources = await TelemetryCommandHelpers.ResolveResourceNamesAsync( + client, baseUrl, resource, cancellationToken); + + // If a resource was specified but not found, show error + if (!string.IsNullOrEmpty(resource) && resolvedResources?.Count == 0) + { + _interactionService.DisplayError($"Resource '{resource}' not found."); + return ExitCodeConstants.InvalidCommand; + } + + // Build query string with multiple resource parameters + var additionalParams = new List<(string key, string? value)> + { + ("traceId", traceId) + }; + if (hasError.HasValue) + { + additionalParams.Add(("hasError", hasError.Value.ToString().ToLowerInvariant())); + } + if (limit.HasValue && !follow) + { + additionalParams.Add(("limit", limit.Value.ToString(CultureInfo.InvariantCulture))); + } + if (follow) + { + additionalParams.Add(("follow", "true")); + } + + var url = DashboardUrls.TelemetrySpansApiUrl(baseUrl, resolvedResources, [.. additionalParams]); + + _logger.LogDebug("Fetching spans from {Url}", url); + + try + { + if (follow) + { + return await StreamSpansAsync(client, url, format, cancellationToken); + } + else + { + return await GetSpansSnapshotAsync(client, url, format, cancellationToken); + } + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to fetch spans from Dashboard API"); + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); + return ExitCodeConstants.DashboardFailure; + } + } + + private async Task GetSpansSnapshotAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken) + { + var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + if (!TelemetryCommandHelpers.HasJsonContentType(response)) + { + _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType); + return ExitCodeConstants.DashboardFailure; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + + if (format == OutputFormat.Json) + { + _interactionService.DisplayRawText(json); + } + else + { + // Parse OTLP JSON and display in table format + DisplaySpansSnapshot(json); + } + + return ExitCodeConstants.Success; + } + + private async Task StreamSpansAsync(HttpClient client, string url, OutputFormat format, CancellationToken cancellationToken) + { + using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + if (!TelemetryCommandHelpers.HasJsonContentType(response)) + { + _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType); + return ExitCodeConstants.DashboardFailure; + } + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var reader = new StreamReader(stream); + + await foreach (var line in reader.ReadLinesAsync(cancellationToken)) + { + if (format == OutputFormat.Json) + { + _interactionService.DisplayRawText(line); + } + else + { + DisplaySpansStreamLine(line); + } + } + + return ExitCodeConstants.Success; + } + + private static void DisplaySpansSnapshot(string json) + { + var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + var resourceSpans = response?.Data?.ResourceSpans; + + if (resourceSpans is null or { Length: 0 }) + { + TelemetryCommandHelpers.DisplayNoData("spans"); + return; + } + + DisplayResourceSpans(resourceSpans); + } + + private static void DisplaySpansStreamLine(string json) + { + var request = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.OtlpExportTraceServiceRequestJson); + DisplayResourceSpans(request?.ResourceSpans ?? []); + } + + private static void DisplayResourceSpans(IEnumerable resourceSpans) + { + foreach (var resourceSpan in resourceSpans) + { + var resourceName = resourceSpan.Resource?.GetServiceName() ?? "unknown"; + + foreach (var scopeSpan in resourceSpan.ScopeSpans ?? []) + { + foreach (var span in scopeSpan.Spans ?? []) + { + DisplaySpanEntry(resourceName, span); + } + } + } + } + + // Using simple text lines instead of Spectre.Console Table for streaming support. + // Tables require knowing all data upfront, but streaming mode displays spans as they arrive. + private static void DisplaySpanEntry(string resourceName, OtlpSpanJson span) + { + var name = span.Name ?? ""; + var spanId = span.SpanId ?? ""; + var duration = OtlpHelpers.CalculateDuration(span.StartTimeUnixNano, span.EndTimeUnixNano); + var hasError = span.Status?.Code == 2; // ERROR status + + var statusColor = hasError ? Color.Red : Color.Green; + var statusText = hasError ? "ERR" : "OK"; + + var shortSpanId = OtlpHelpers.ToShortenedId(spanId); + var durationStr = TelemetryCommandHelpers.FormatDuration(duration); + + var escapedName = name.EscapeMarkup(); + AnsiConsole.MarkupLine($"[grey]{shortSpanId}[/] [cyan]{resourceName,-15}[/] [{statusColor}]{statusText}[/] [white]{durationStr,8}[/] {escapedName}"); + } +} diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs new file mode 100644 index 00000000000..efadfc7efcc --- /dev/null +++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs @@ -0,0 +1,410 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Globalization; +using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Otlp; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace Aspire.Cli.Commands; + +/// +/// Command to view traces from the Dashboard telemetry API. +/// +internal sealed class TelemetryTracesCommand : BaseCommand +{ + private readonly IInteractionService _interactionService; + private readonly AppHostConnectionResolver _connectionResolver; + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + + // Shared options from TelemetryCommandHelpers + private static readonly Argument s_resourceArgument = TelemetryCommandHelpers.CreateResourceArgument(); + private static readonly Option s_projectOption = TelemetryCommandHelpers.CreateProjectOption(); + private static readonly Option s_formatOption = TelemetryCommandHelpers.CreateFormatOption(); + private static readonly Option s_limitOption = TelemetryCommandHelpers.CreateLimitOption(); + private static readonly Option s_traceIdOption = TelemetryCommandHelpers.CreateTraceIdOption("--trace-id", "-t"); + private static readonly Option s_hasErrorOption = TelemetryCommandHelpers.CreateHasErrorOption(); + + public TelemetryTracesCommand( + IInteractionService interactionService, + IAuxiliaryBackchannelMonitor backchannelMonitor, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + AspireCliTelemetry telemetry, + IHttpClientFactory httpClientFactory, + ILogger logger) + : base("traces", TelemetryCommandStrings.TracesDescription, features, updateNotifier, executionContext, interactionService, telemetry) + { + _interactionService = interactionService; + _httpClientFactory = httpClientFactory; + _logger = logger; + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); + + Arguments.Add(s_resourceArgument); + Options.Add(s_projectOption); + Options.Add(s_formatOption); + Options.Add(s_limitOption); + Options.Add(s_traceIdOption); + Options.Add(s_hasErrorOption); + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + using var activity = Telemetry.StartDiagnosticActivity(Name); + + var resourceName = parseResult.GetValue(s_resourceArgument); + var passedAppHostProjectFile = parseResult.GetValue(s_projectOption); + var format = parseResult.GetValue(s_formatOption); + var limit = parseResult.GetValue(s_limitOption); + var traceId = parseResult.GetValue(s_traceIdOption); + var hasError = parseResult.GetValue(s_hasErrorOption); + + // Validate --limit value + if (limit.HasValue && limit.Value < 1) + { + _interactionService.DisplayError(TelemetryCommandStrings.LimitMustBePositive); + return ExitCodeConstants.InvalidCommand; + } + + var (success, baseUrl, apiToken, _, exitCode) = await TelemetryCommandHelpers.GetDashboardApiAsync( + _connectionResolver, _interactionService, passedAppHostProjectFile, format, cancellationToken); + + if (!success) + { + return exitCode; + } + + if (!string.IsNullOrEmpty(traceId)) + { + return await FetchSingleTraceAsync(baseUrl!, apiToken!, traceId, format, cancellationToken); + } + else + { + return await FetchTracesAsync(baseUrl!, apiToken!, resourceName, hasError, limit, format, cancellationToken); + } + } + + private async Task FetchSingleTraceAsync( + string baseUrl, + string apiToken, + string traceId, + OutputFormat format, + CancellationToken cancellationToken) + { + using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); + + var url = DashboardUrls.TelemetryTraceDetailApiUrl(baseUrl, traceId); + + _logger.LogDebug("Fetching trace {TraceId} from {Url}", traceId, url); + + try + { + var response = await client.GetAsync(url, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.TraceNotFound, traceId)); + return ExitCodeConstants.InvalidCommand; + } + + response.EnsureSuccessStatusCode(); + + if (!TelemetryCommandHelpers.HasJsonContentType(response)) + { + _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType); + return ExitCodeConstants.DashboardFailure; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + + if (format == OutputFormat.Json) + { + _interactionService.DisplayRawText(json); + } + else + { + DisplayTraceDetails(json, traceId); + } + + return ExitCodeConstants.Success; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to fetch trace from Dashboard API"); + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); + return ExitCodeConstants.DashboardFailure; + } + } + + private async Task FetchTracesAsync( + string baseUrl, + string apiToken, + string? resource, + bool? hasError, + int? limit, + OutputFormat format, + CancellationToken cancellationToken) + { + using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); + + // Resolve resource name to specific instances (handles replicas) + var resolvedResources = await TelemetryCommandHelpers.ResolveResourceNamesAsync( + client, baseUrl, resource, cancellationToken); + + // If a resource was specified but not found, show error + if (!string.IsNullOrEmpty(resource) && resolvedResources?.Count == 0) + { + _interactionService.DisplayError($"Resource '{resource}' not found."); + return ExitCodeConstants.InvalidCommand; + } + + // Build query string with multiple resource parameters + var additionalParams = new List<(string key, string? value)>(); + if (hasError.HasValue) + { + additionalParams.Add(("hasError", hasError.Value.ToString().ToLowerInvariant())); + } + if (limit.HasValue) + { + additionalParams.Add(("limit", limit.Value.ToString(CultureInfo.InvariantCulture))); + } + + var url = DashboardUrls.TelemetryTracesApiUrl(baseUrl, resolvedResources, [.. additionalParams]); + + _logger.LogDebug("Fetching traces from {Url}", url); + + try + { + var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + if (!TelemetryCommandHelpers.HasJsonContentType(response)) + { + _interactionService.DisplayError(TelemetryCommandStrings.UnexpectedContentType); + return ExitCodeConstants.DashboardFailure; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + + if (format == OutputFormat.Json) + { + _interactionService.DisplayRawText(json); + } + else + { + DisplayTracesTable(json); + } + + return ExitCodeConstants.Success; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to fetch traces from Dashboard API"); + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message)); + return ExitCodeConstants.DashboardFailure; + } + } + + private static void DisplayTracesTable(string json) + { + var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + var resourceSpans = response?.Data?.ResourceSpans; + + if (resourceSpans is null or { Length: 0 }) + { + TelemetryCommandHelpers.DisplayNoData("traces"); + return; + } + + var table = new Table(); + table.AddColumn("Trace ID"); + table.AddColumn("Resource"); + table.AddColumn("Duration"); + table.AddColumn("Spans"); + table.AddColumn("Status"); + + // Group by traceId to show trace summary + var traceInfos = new Dictionary(); + + foreach (var resourceSpan in resourceSpans) + { + var resourceName = resourceSpan.Resource?.GetServiceName() ?? "unknown"; + + foreach (var scopeSpan in resourceSpan.ScopeSpans ?? []) + { + foreach (var span in scopeSpan.Spans ?? []) + { + var traceIdValue = span.TraceId ?? ""; + + if (string.IsNullOrEmpty(traceIdValue)) + { + continue; + } + + var duration = OtlpHelpers.CalculateDuration(span.StartTimeUnixNano, span.EndTimeUnixNano); + var hasError = span.Status?.Code == 2; // ERROR status + + if (traceInfos.TryGetValue(traceIdValue, out var info)) + { + var maxDuration = info.Duration > duration ? info.Duration : duration; + traceInfos[traceIdValue] = (info.Resource, maxDuration, info.SpanCount + 1, info.HasError || hasError); + } + else + { + traceInfos[traceIdValue] = (resourceName, duration, 1, hasError); + } + } + } + } + + foreach (var (traceIdKey, info) in traceInfos.OrderByDescending(x => x.Value.Duration)) + { + var statusText = info.HasError ? "[red]ERR[/]" : "[green]OK[/]"; + var durationStr = TelemetryCommandHelpers.FormatDuration(info.Duration); + table.AddRow(traceIdKey, info.Resource, durationStr, info.SpanCount.ToString(CultureInfo.InvariantCulture), statusText); + } + + AnsiConsole.Write(table); + AnsiConsole.MarkupLine($"[grey]Showing {traceInfos.Count} of {response?.TotalCount ?? traceInfos.Count} traces[/]"); + } + + private static void DisplayTraceDetails(string json, string traceId) + { + var response = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + var resourceSpans = response?.Data?.ResourceSpans; + + // Collect all spans with their metadata + var spans = new List(); + + foreach (var resourceSpan in resourceSpans ?? []) + { + var resourceName = resourceSpan.Resource?.GetServiceName() ?? "unknown"; + + foreach (var scopeSpan in resourceSpan.ScopeSpans ?? []) + { + foreach (var span in scopeSpan.Spans ?? []) + { + var spanId = span.SpanId ?? ""; + var parentSpanId = span.ParentSpanId; + var name = span.Name ?? ""; + var duration = OtlpHelpers.CalculateDuration(span.StartTimeUnixNano, span.EndTimeUnixNano); + var startNano = (long)(span.StartTimeUnixNano ?? 0); + var hasError = span.Status?.Code == 2; // ERROR status + + spans.Add(new SpanInfo(spanId, parentSpanId, resourceName, name, duration, startNano, hasError)); + } + } + } + + if (spans.Count == 0) + { + AnsiConsole.MarkupLine($"[bold]Trace: {traceId}[/]"); + AnsiConsole.MarkupLine("[dim]No spans found[/]"); + return; + } + + // Calculate total duration from root spans + var rootSpans = spans.Where(s => string.IsNullOrEmpty(s.ParentSpanId)).ToList(); + var totalDuration = rootSpans.Count > 0 ? rootSpans.Max(s => s.Duration) : spans.Max(s => s.Duration); + + // Header + AnsiConsole.MarkupLine($"[bold]Trace:[/] {traceId}"); + AnsiConsole.MarkupLine($"[bold]Duration:[/] {TelemetryCommandHelpers.FormatDuration(totalDuration)} [bold]Spans:[/] {spans.Count}"); + AnsiConsole.WriteLine(); + + // Build tree and display + DisplaySpanTree(spans); + } + + private static void DisplaySpanTree(List spans) + { + // Build a lookup of children by parent ID + var childrenByParent = spans + .Where(s => !string.IsNullOrEmpty(s.ParentSpanId)) + .GroupBy(s => s.ParentSpanId!) + .ToDictionary(g => g.Key, g => g.OrderBy(s => s.StartNano).ToList()); + + // Find root spans (no parent or parent not in this trace) + var spanIds = spans.Select(s => s.SpanId).ToHashSet(); + var rootSpans = spans + .Where(s => string.IsNullOrEmpty(s.ParentSpanId) || !spanIds.Contains(s.ParentSpanId!)) + .OrderBy(s => s.StartNano) + .ToList(); + + // Track which resources we've seen to show resource transitions + string? lastResource = null; + + foreach (var root in rootSpans) + { + DisplaySpanNode(root, childrenByParent, "", true, ref lastResource); + } + } + + private static void DisplaySpanNode( + SpanInfo span, + Dictionary> childrenByParent, + string indent, + bool isLast, + ref string? lastResource) + { + // Show resource name when it changes (indicates cross-service call) + if (span.ResourceName != lastResource) + { + if (lastResource != null) + { + AnsiConsole.WriteLine(); // Blank line between resources + } + AnsiConsole.MarkupLine($"{indent}[bold blue]{span.ResourceName.EscapeMarkup()}[/]"); + lastResource = span.ResourceName; + } + + // Build the connector + var connector = isLast ? "└─" : "├─"; + var childIndent = indent + (isLast ? " " : "│ "); + + // Format span line with spanId + var statusColor = span.HasError ? "red" : "green"; + var statusText = span.HasError ? "ERR" : "OK"; + var durationStr = TelemetryCommandHelpers.FormatDuration(span.Duration).PadLeft(8); + var shortenedSpanId = OtlpHelpers.ToShortenedId(span.SpanId); + var escapedName = span.Name.EscapeMarkup(); + + // Truncate long names + var maxNameLength = 50; + var displayName = escapedName.Length > maxNameLength + ? escapedName[..(maxNameLength - 3)] + "..." + : escapedName; + + AnsiConsole.MarkupLine($"{indent}{connector} [dim]{shortenedSpanId}[/] {displayName} [{statusColor}]{statusText}[/] [dim]{durationStr}[/]"); + + // Render children + if (childrenByParent.TryGetValue(span.SpanId, out var children)) + { + for (var i = 0; i < children.Count; i++) + { + DisplaySpanNode(children[i], childrenByParent, childIndent, i == children.Count - 1, ref lastResource); + } + } + } + + private sealed record SpanInfo( + string SpanId, + string? ParentSpanId, + string ResourceName, + string Name, + TimeSpan Duration, + long StartNano, + bool HasError); +} diff --git a/src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs b/src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs new file mode 100644 index 00000000000..4f5a76f7bbd --- /dev/null +++ b/src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs @@ -0,0 +1,115 @@ +// 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.Serialization; +using Aspire.Otlp.Serialization; + +namespace Aspire.Cli.Otlp; + +/// +/// Represents the telemetry data returned by the Dashboard API. +/// Contains logs, traces, and/or metrics data. +/// +internal sealed class TelemetryDataJson +{ + [JsonPropertyName("resourceSpans")] + public OtlpResourceSpansJson[]? ResourceSpans { get; set; } + + [JsonPropertyName("resourceLogs")] + public OtlpResourceLogsJson[]? ResourceLogs { get; set; } +} + +/// +/// Represents the API response wrapper for telemetry data. +/// +internal sealed class TelemetryApiResponse +{ + [JsonPropertyName("data")] + public TelemetryDataJson? Data { get; set; } + + [JsonPropertyName("totalCount")] + public int TotalCount { get; set; } + + [JsonPropertyName("returnedCount")] + public int ReturnedCount { get; set; } +} + +/// +/// Information about a resource that has telemetry data. +/// +internal sealed class ResourceInfoJson +{ + /// + /// The base resource name (e.g., "catalogservice"). + /// + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// + /// The instance ID if this is a replica (e.g., "abc123"), or null if single instance. + /// + [JsonPropertyName("instanceId")] + public string? InstanceId { get; set; } + + /// + /// The full display name including instance ID (e.g., "catalogservice-abc123" or "catalogservice"). + /// Use this when querying the telemetry API. + /// + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; + + /// + /// Whether this resource has structured logs. + /// + [JsonPropertyName("hasLogs")] + public bool HasLogs { get; set; } + + /// + /// Whether this resource has traces/spans. + /// + [JsonPropertyName("hasTraces")] + public bool HasTraces { get; set; } + + /// + /// Whether this resource has metrics. + /// + [JsonPropertyName("hasMetrics")] + public bool HasMetrics { get; set; } +} + +/// +/// Source-generated JSON serializer context for OTLP types used by CLI telemetry commands. +/// Provides AOT-compatible serialization for logs and trace types. +/// +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true)] +[JsonSerializable(typeof(TelemetryApiResponse))] +[JsonSerializable(typeof(TelemetryDataJson))] +[JsonSerializable(typeof(ResourceInfoJson))] +[JsonSerializable(typeof(ResourceInfoJson[]))] +[JsonSerializable(typeof(OtlpAnyValueJson))] +[JsonSerializable(typeof(OtlpArrayValueJson))] +[JsonSerializable(typeof(OtlpKeyValueListJson))] +[JsonSerializable(typeof(OtlpKeyValueJson))] +[JsonSerializable(typeof(OtlpInstrumentationScopeJson))] +[JsonSerializable(typeof(OtlpEntityRefJson))] +[JsonSerializable(typeof(OtlpResourceJson))] +[JsonSerializable(typeof(OtlpResourceSpansJson))] +[JsonSerializable(typeof(OtlpScopeSpansJson))] +[JsonSerializable(typeof(OtlpSpanJson))] +[JsonSerializable(typeof(OtlpSpanEventJson))] +[JsonSerializable(typeof(OtlpSpanLinkJson))] +[JsonSerializable(typeof(OtlpSpanStatusJson))] +[JsonSerializable(typeof(OtlpExportTraceServiceRequestJson))] +[JsonSerializable(typeof(OtlpResourceLogsJson))] +[JsonSerializable(typeof(OtlpScopeLogsJson))] +[JsonSerializable(typeof(OtlpLogRecordJson))] +[JsonSerializable(typeof(OtlpExportLogsServiceRequestJson))] +internal sealed partial class OtlpCliJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index ec92fe87aca..15a6dd35ff7 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -259,6 +259,10 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Projects/RunningInstanceManager.cs b/src/Aspire.Cli/Projects/RunningInstanceManager.cs index 9dc2ca9ee07..4bcb9b3e424 100644 --- a/src/Aspire.Cli/Projects/RunningInstanceManager.cs +++ b/src/Aspire.Cli/Projects/RunningInstanceManager.cs @@ -11,7 +11,9 @@ namespace Aspire.Cli.Projects; /// -/// Provides shared utilities for managing running AppHost instances. +/// Manages running AppHost instances when the socket path is already known. +/// Used for stopping instances during startup conflicts or after user selection. +/// For discovering and selecting AppHosts, use instead. /// internal sealed class RunningInstanceManager { diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs new file mode 100644 index 00000000000..cd9c891b389 --- /dev/null +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs @@ -0,0 +1,261 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Aspire.Cli.Resources { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class TelemetryCommandStrings { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal TelemetryCommandStrings() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Aspire.Cli.Resources.TelemetryCommandStrings", typeof(TelemetryCommandStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to View telemetry data (logs, spans, traces) from a running Aspire application.. + /// + internal static string Description { + get { + return ResourceManager.GetString("Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to View structured logs from the Dashboard telemetry API.. + /// + internal static string LogsDescription { + get { + return ResourceManager.GetString("LogsDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to View spans from the Dashboard telemetry API.. + /// + internal static string SpansDescription { + get { + return ResourceManager.GetString("SpansDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to View traces from the Dashboard telemetry API.. + /// + internal static string TracesDescription { + get { + return ResourceManager.GetString("TracesDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Filter by resource name.. + /// + internal static string ResourceArgumentDescription { + get { + return ResourceManager.GetString("ResourceArgumentDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The path to the Aspire AppHost project file.. + /// + internal static string ProjectOptionDescription { + get { + return ResourceManager.GetString("ProjectOptionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Stream telemetry in real-time as it arrives.. + /// + internal static string FollowOptionDescription { + get { + return ResourceManager.GetString("FollowOptionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Output format (Table or Json).. + /// + internal static string FormatOptionDescription { + get { + return ResourceManager.GetString("FormatOptionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Maximum number of items to return.. + /// + internal static string LimitOptionDescription { + get { + return ResourceManager.GetString("LimitOptionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Filter by trace ID.. + /// + internal static string TraceIdOptionDescription { + get { + return ResourceManager.GetString("TraceIdOptionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The trace ID to view. If not specified, lists all traces.. + /// + internal static string TraceIdArgumentDescription { + get { + return ResourceManager.GetString("TraceIdArgumentDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical).. + /// + internal static string SeverityOptionDescription { + get { + return ResourceManager.GetString("SeverityOptionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Filter by error status (true to show only errors, false to exclude errors).. + /// + internal static string HasErrorOptionDescription { + get { + return ResourceManager.GetString("HasErrorOptionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The --limit value must be a positive number.. + /// + internal static string LimitMustBePositive { + get { + return ResourceManager.GetString("LimitMustBePositive", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Scanning for running AppHosts.... + /// + internal static string ScanningForRunningAppHosts { + get { + return ResourceManager.GetString("ScanningForRunningAppHosts", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Select an AppHost:. + /// + internal static string SelectAppHost { + get { + return ResourceManager.GetString("SelectAppHost", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No AppHosts found in current directory. Showing all running AppHosts.. + /// + internal static string NoInScopeAppHostsShowingAll { + get { + return ResourceManager.GetString("NoInScopeAppHostsShowingAll", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No running AppHost found. Use 'aspire run' to start one first.. + /// + internal static string AppHostNotRunning { + get { + return ResourceManager.GetString("AppHostNotRunning", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled.. + /// + internal static string DashboardApiNotAvailable { + get { + return ResourceManager.GetString("DashboardApiNotAvailable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to fetch telemetry: {0}. + /// + internal static string FailedToFetchTelemetry { + get { + return ResourceManager.GetString("FailedToFetchTelemetry", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Trace with ID '{0}' was not found.. + /// + internal static string TraceNotFound { + get { + return ResourceManager.GetString("TraceNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Dashboard API returned unexpected content type. Expected JSON response.. + /// + internal static string UnexpectedContentType { + get { + return ResourceManager.GetString("UnexpectedContentType", resourceCulture); + } + } + } +} diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx new file mode 100644 index 00000000000..c79dbbf0118 --- /dev/null +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + View telemetry data (logs, spans, traces) from a running Aspire application. + + + View structured logs from the Dashboard telemetry API. + + + View spans from the Dashboard telemetry API. + + + View traces from the Dashboard telemetry API. + + + Filter by resource name. + + + The path to the Aspire AppHost project file. + + + Stream telemetry in real-time as it arrives. + + + Output format (Table or Json). + + + Maximum number of items to return. + + + Filter by trace ID. + + + The trace ID to view. If not specified, lists all traces. + + + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + + + Filter by error status (true to show only errors, false to exclude errors). + + + The --limit value must be a positive number. + + + Scanning for running AppHosts... + + + Select an AppHost: + + + No AppHosts found in current directory. Showing all running AppHosts. + + + No running AppHost found. Use 'aspire run' to start one first. + + + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + + + Failed to fetch telemetry: {0} + + + Trace with ID '{0}' was not found. + + + Dashboard API returned unexpected content type. Expected JSON response. + + diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf new file mode 100644 index 00000000000..0a71d47b0af --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf @@ -0,0 +1,117 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + + + + View telemetry data (logs, spans, traces) from a running Aspire application. + View telemetry data (logs, spans, traces) from a running Aspire application. + + + + Failed to fetch telemetry: {0} + Failed to fetch telemetry: {0} + + + + Stream telemetry in real-time as it arrives. + Stream telemetry in real-time as it arrives. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Filter by error status (true to show only errors, false to exclude errors). + Filter by error status (true to show only errors, false to exclude errors). + + + + The --limit value must be a positive number. + The --limit value must be a positive number. + + + + Maximum number of items to return. + Maximum number of items to return. + + + + View structured logs from the Dashboard telemetry API. + View structured logs from the Dashboard telemetry API. + + + + No AppHosts found in current directory. Showing all running AppHosts. + No AppHosts found in current directory. Showing all running AppHosts. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Filter by resource name. + Filter by resource name. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + + + + View spans from the Dashboard telemetry API. + View spans from the Dashboard telemetry API. + + + + The trace ID to view. If not specified, lists all traces. + The trace ID to view. If not specified, lists all traces. + + + + Filter by trace ID. + Filter by trace ID. + + + + Trace with ID '{0}' was not found. + Trace with ID '{0}' was not found. + + + + View traces from the Dashboard telemetry API. + View traces from the Dashboard telemetry API. + + + + Dashboard API returned unexpected content type. Expected JSON response. + Dashboard API returned unexpected content type. Expected JSON response. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf new file mode 100644 index 00000000000..35b6ae39020 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf @@ -0,0 +1,117 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + + + + View telemetry data (logs, spans, traces) from a running Aspire application. + View telemetry data (logs, spans, traces) from a running Aspire application. + + + + Failed to fetch telemetry: {0} + Failed to fetch telemetry: {0} + + + + Stream telemetry in real-time as it arrives. + Stream telemetry in real-time as it arrives. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Filter by error status (true to show only errors, false to exclude errors). + Filter by error status (true to show only errors, false to exclude errors). + + + + The --limit value must be a positive number. + The --limit value must be a positive number. + + + + Maximum number of items to return. + Maximum number of items to return. + + + + View structured logs from the Dashboard telemetry API. + View structured logs from the Dashboard telemetry API. + + + + No AppHosts found in current directory. Showing all running AppHosts. + No AppHosts found in current directory. Showing all running AppHosts. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Filter by resource name. + Filter by resource name. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + + + + View spans from the Dashboard telemetry API. + View spans from the Dashboard telemetry API. + + + + The trace ID to view. If not specified, lists all traces. + The trace ID to view. If not specified, lists all traces. + + + + Filter by trace ID. + Filter by trace ID. + + + + Trace with ID '{0}' was not found. + Trace with ID '{0}' was not found. + + + + View traces from the Dashboard telemetry API. + View traces from the Dashboard telemetry API. + + + + Dashboard API returned unexpected content type. Expected JSON response. + Dashboard API returned unexpected content type. Expected JSON response. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf new file mode 100644 index 00000000000..990c32ce810 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf @@ -0,0 +1,117 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + + + + View telemetry data (logs, spans, traces) from a running Aspire application. + View telemetry data (logs, spans, traces) from a running Aspire application. + + + + Failed to fetch telemetry: {0} + Failed to fetch telemetry: {0} + + + + Stream telemetry in real-time as it arrives. + Stream telemetry in real-time as it arrives. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Filter by error status (true to show only errors, false to exclude errors). + Filter by error status (true to show only errors, false to exclude errors). + + + + The --limit value must be a positive number. + The --limit value must be a positive number. + + + + Maximum number of items to return. + Maximum number of items to return. + + + + View structured logs from the Dashboard telemetry API. + View structured logs from the Dashboard telemetry API. + + + + No AppHosts found in current directory. Showing all running AppHosts. + No AppHosts found in current directory. Showing all running AppHosts. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Filter by resource name. + Filter by resource name. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + + + + View spans from the Dashboard telemetry API. + View spans from the Dashboard telemetry API. + + + + The trace ID to view. If not specified, lists all traces. + The trace ID to view. If not specified, lists all traces. + + + + Filter by trace ID. + Filter by trace ID. + + + + Trace with ID '{0}' was not found. + Trace with ID '{0}' was not found. + + + + View traces from the Dashboard telemetry API. + View traces from the Dashboard telemetry API. + + + + Dashboard API returned unexpected content type. Expected JSON response. + Dashboard API returned unexpected content type. Expected JSON response. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf new file mode 100644 index 00000000000..564772f1a0c --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf @@ -0,0 +1,117 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + + + + View telemetry data (logs, spans, traces) from a running Aspire application. + View telemetry data (logs, spans, traces) from a running Aspire application. + + + + Failed to fetch telemetry: {0} + Failed to fetch telemetry: {0} + + + + Stream telemetry in real-time as it arrives. + Stream telemetry in real-time as it arrives. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Filter by error status (true to show only errors, false to exclude errors). + Filter by error status (true to show only errors, false to exclude errors). + + + + The --limit value must be a positive number. + The --limit value must be a positive number. + + + + Maximum number of items to return. + Maximum number of items to return. + + + + View structured logs from the Dashboard telemetry API. + View structured logs from the Dashboard telemetry API. + + + + No AppHosts found in current directory. Showing all running AppHosts. + No AppHosts found in current directory. Showing all running AppHosts. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Filter by resource name. + Filter by resource name. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + + + + View spans from the Dashboard telemetry API. + View spans from the Dashboard telemetry API. + + + + The trace ID to view. If not specified, lists all traces. + The trace ID to view. If not specified, lists all traces. + + + + Filter by trace ID. + Filter by trace ID. + + + + Trace with ID '{0}' was not found. + Trace with ID '{0}' was not found. + + + + View traces from the Dashboard telemetry API. + View traces from the Dashboard telemetry API. + + + + Dashboard API returned unexpected content type. Expected JSON response. + Dashboard API returned unexpected content type. Expected JSON response. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf new file mode 100644 index 00000000000..e521d0f1938 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf @@ -0,0 +1,117 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + + + + View telemetry data (logs, spans, traces) from a running Aspire application. + View telemetry data (logs, spans, traces) from a running Aspire application. + + + + Failed to fetch telemetry: {0} + Failed to fetch telemetry: {0} + + + + Stream telemetry in real-time as it arrives. + Stream telemetry in real-time as it arrives. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Filter by error status (true to show only errors, false to exclude errors). + Filter by error status (true to show only errors, false to exclude errors). + + + + The --limit value must be a positive number. + The --limit value must be a positive number. + + + + Maximum number of items to return. + Maximum number of items to return. + + + + View structured logs from the Dashboard telemetry API. + View structured logs from the Dashboard telemetry API. + + + + No AppHosts found in current directory. Showing all running AppHosts. + No AppHosts found in current directory. Showing all running AppHosts. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Filter by resource name. + Filter by resource name. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + + + + View spans from the Dashboard telemetry API. + View spans from the Dashboard telemetry API. + + + + The trace ID to view. If not specified, lists all traces. + The trace ID to view. If not specified, lists all traces. + + + + Filter by trace ID. + Filter by trace ID. + + + + Trace with ID '{0}' was not found. + Trace with ID '{0}' was not found. + + + + View traces from the Dashboard telemetry API. + View traces from the Dashboard telemetry API. + + + + Dashboard API returned unexpected content type. Expected JSON response. + Dashboard API returned unexpected content type. Expected JSON response. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf new file mode 100644 index 00000000000..b1788796279 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf @@ -0,0 +1,117 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + + + + View telemetry data (logs, spans, traces) from a running Aspire application. + View telemetry data (logs, spans, traces) from a running Aspire application. + + + + Failed to fetch telemetry: {0} + Failed to fetch telemetry: {0} + + + + Stream telemetry in real-time as it arrives. + Stream telemetry in real-time as it arrives. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Filter by error status (true to show only errors, false to exclude errors). + Filter by error status (true to show only errors, false to exclude errors). + + + + The --limit value must be a positive number. + The --limit value must be a positive number. + + + + Maximum number of items to return. + Maximum number of items to return. + + + + View structured logs from the Dashboard telemetry API. + View structured logs from the Dashboard telemetry API. + + + + No AppHosts found in current directory. Showing all running AppHosts. + No AppHosts found in current directory. Showing all running AppHosts. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Filter by resource name. + Filter by resource name. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + + + + View spans from the Dashboard telemetry API. + View spans from the Dashboard telemetry API. + + + + The trace ID to view. If not specified, lists all traces. + The trace ID to view. If not specified, lists all traces. + + + + Filter by trace ID. + Filter by trace ID. + + + + Trace with ID '{0}' was not found. + Trace with ID '{0}' was not found. + + + + View traces from the Dashboard telemetry API. + View traces from the Dashboard telemetry API. + + + + Dashboard API returned unexpected content type. Expected JSON response. + Dashboard API returned unexpected content type. Expected JSON response. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf new file mode 100644 index 00000000000..091342ab9ca --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf @@ -0,0 +1,117 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + + + + View telemetry data (logs, spans, traces) from a running Aspire application. + View telemetry data (logs, spans, traces) from a running Aspire application. + + + + Failed to fetch telemetry: {0} + Failed to fetch telemetry: {0} + + + + Stream telemetry in real-time as it arrives. + Stream telemetry in real-time as it arrives. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Filter by error status (true to show only errors, false to exclude errors). + Filter by error status (true to show only errors, false to exclude errors). + + + + The --limit value must be a positive number. + The --limit value must be a positive number. + + + + Maximum number of items to return. + Maximum number of items to return. + + + + View structured logs from the Dashboard telemetry API. + View structured logs from the Dashboard telemetry API. + + + + No AppHosts found in current directory. Showing all running AppHosts. + No AppHosts found in current directory. Showing all running AppHosts. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Filter by resource name. + Filter by resource name. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + + + + View spans from the Dashboard telemetry API. + View spans from the Dashboard telemetry API. + + + + The trace ID to view. If not specified, lists all traces. + The trace ID to view. If not specified, lists all traces. + + + + Filter by trace ID. + Filter by trace ID. + + + + Trace with ID '{0}' was not found. + Trace with ID '{0}' was not found. + + + + View traces from the Dashboard telemetry API. + View traces from the Dashboard telemetry API. + + + + Dashboard API returned unexpected content type. Expected JSON response. + Dashboard API returned unexpected content type. Expected JSON response. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf new file mode 100644 index 00000000000..52e108c29b3 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf @@ -0,0 +1,117 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + + + + View telemetry data (logs, spans, traces) from a running Aspire application. + View telemetry data (logs, spans, traces) from a running Aspire application. + + + + Failed to fetch telemetry: {0} + Failed to fetch telemetry: {0} + + + + Stream telemetry in real-time as it arrives. + Stream telemetry in real-time as it arrives. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Filter by error status (true to show only errors, false to exclude errors). + Filter by error status (true to show only errors, false to exclude errors). + + + + The --limit value must be a positive number. + The --limit value must be a positive number. + + + + Maximum number of items to return. + Maximum number of items to return. + + + + View structured logs from the Dashboard telemetry API. + View structured logs from the Dashboard telemetry API. + + + + No AppHosts found in current directory. Showing all running AppHosts. + No AppHosts found in current directory. Showing all running AppHosts. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Filter by resource name. + Filter by resource name. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + + + + View spans from the Dashboard telemetry API. + View spans from the Dashboard telemetry API. + + + + The trace ID to view. If not specified, lists all traces. + The trace ID to view. If not specified, lists all traces. + + + + Filter by trace ID. + Filter by trace ID. + + + + Trace with ID '{0}' was not found. + Trace with ID '{0}' was not found. + + + + View traces from the Dashboard telemetry API. + View traces from the Dashboard telemetry API. + + + + Dashboard API returned unexpected content type. Expected JSON response. + Dashboard API returned unexpected content type. Expected JSON response. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf new file mode 100644 index 00000000000..695853fa7be --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf @@ -0,0 +1,117 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + + + + View telemetry data (logs, spans, traces) from a running Aspire application. + View telemetry data (logs, spans, traces) from a running Aspire application. + + + + Failed to fetch telemetry: {0} + Failed to fetch telemetry: {0} + + + + Stream telemetry in real-time as it arrives. + Stream telemetry in real-time as it arrives. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Filter by error status (true to show only errors, false to exclude errors). + Filter by error status (true to show only errors, false to exclude errors). + + + + The --limit value must be a positive number. + The --limit value must be a positive number. + + + + Maximum number of items to return. + Maximum number of items to return. + + + + View structured logs from the Dashboard telemetry API. + View structured logs from the Dashboard telemetry API. + + + + No AppHosts found in current directory. Showing all running AppHosts. + No AppHosts found in current directory. Showing all running AppHosts. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Filter by resource name. + Filter by resource name. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + + + + View spans from the Dashboard telemetry API. + View spans from the Dashboard telemetry API. + + + + The trace ID to view. If not specified, lists all traces. + The trace ID to view. If not specified, lists all traces. + + + + Filter by trace ID. + Filter by trace ID. + + + + Trace with ID '{0}' was not found. + Trace with ID '{0}' was not found. + + + + View traces from the Dashboard telemetry API. + View traces from the Dashboard telemetry API. + + + + Dashboard API returned unexpected content type. Expected JSON response. + Dashboard API returned unexpected content type. Expected JSON response. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf new file mode 100644 index 00000000000..975f0595226 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf @@ -0,0 +1,117 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + + + + View telemetry data (logs, spans, traces) from a running Aspire application. + View telemetry data (logs, spans, traces) from a running Aspire application. + + + + Failed to fetch telemetry: {0} + Failed to fetch telemetry: {0} + + + + Stream telemetry in real-time as it arrives. + Stream telemetry in real-time as it arrives. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Filter by error status (true to show only errors, false to exclude errors). + Filter by error status (true to show only errors, false to exclude errors). + + + + The --limit value must be a positive number. + The --limit value must be a positive number. + + + + Maximum number of items to return. + Maximum number of items to return. + + + + View structured logs from the Dashboard telemetry API. + View structured logs from the Dashboard telemetry API. + + + + No AppHosts found in current directory. Showing all running AppHosts. + No AppHosts found in current directory. Showing all running AppHosts. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Filter by resource name. + Filter by resource name. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + + + + View spans from the Dashboard telemetry API. + View spans from the Dashboard telemetry API. + + + + The trace ID to view. If not specified, lists all traces. + The trace ID to view. If not specified, lists all traces. + + + + Filter by trace ID. + Filter by trace ID. + + + + Trace with ID '{0}' was not found. + Trace with ID '{0}' was not found. + + + + View traces from the Dashboard telemetry API. + View traces from the Dashboard telemetry API. + + + + Dashboard API returned unexpected content type. Expected JSON response. + Dashboard API returned unexpected content type. Expected JSON response. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf new file mode 100644 index 00000000000..da3f4f29b3d --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf @@ -0,0 +1,117 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + + + + View telemetry data (logs, spans, traces) from a running Aspire application. + View telemetry data (logs, spans, traces) from a running Aspire application. + + + + Failed to fetch telemetry: {0} + Failed to fetch telemetry: {0} + + + + Stream telemetry in real-time as it arrives. + Stream telemetry in real-time as it arrives. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Filter by error status (true to show only errors, false to exclude errors). + Filter by error status (true to show only errors, false to exclude errors). + + + + The --limit value must be a positive number. + The --limit value must be a positive number. + + + + Maximum number of items to return. + Maximum number of items to return. + + + + View structured logs from the Dashboard telemetry API. + View structured logs from the Dashboard telemetry API. + + + + No AppHosts found in current directory. Showing all running AppHosts. + No AppHosts found in current directory. Showing all running AppHosts. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Filter by resource name. + Filter by resource name. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + + + + View spans from the Dashboard telemetry API. + View spans from the Dashboard telemetry API. + + + + The trace ID to view. If not specified, lists all traces. + The trace ID to view. If not specified, lists all traces. + + + + Filter by trace ID. + Filter by trace ID. + + + + Trace with ID '{0}' was not found. + Trace with ID '{0}' was not found. + + + + View traces from the Dashboard telemetry API. + View traces from the Dashboard telemetry API. + + + + Dashboard API returned unexpected content type. Expected JSON response. + Dashboard API returned unexpected content type. Expected JSON response. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf new file mode 100644 index 00000000000..e181e404459 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf @@ -0,0 +1,117 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + + + + View telemetry data (logs, spans, traces) from a running Aspire application. + View telemetry data (logs, spans, traces) from a running Aspire application. + + + + Failed to fetch telemetry: {0} + Failed to fetch telemetry: {0} + + + + Stream telemetry in real-time as it arrives. + Stream telemetry in real-time as it arrives. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Filter by error status (true to show only errors, false to exclude errors). + Filter by error status (true to show only errors, false to exclude errors). + + + + The --limit value must be a positive number. + The --limit value must be a positive number. + + + + Maximum number of items to return. + Maximum number of items to return. + + + + View structured logs from the Dashboard telemetry API. + View structured logs from the Dashboard telemetry API. + + + + No AppHosts found in current directory. Showing all running AppHosts. + No AppHosts found in current directory. Showing all running AppHosts. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Filter by resource name. + Filter by resource name. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + + + + View spans from the Dashboard telemetry API. + View spans from the Dashboard telemetry API. + + + + The trace ID to view. If not specified, lists all traces. + The trace ID to view. If not specified, lists all traces. + + + + Filter by trace ID. + Filter by trace ID. + + + + Trace with ID '{0}' was not found. + Trace with ID '{0}' was not found. + + + + View traces from the Dashboard telemetry API. + View traces from the Dashboard telemetry API. + + + + Dashboard API returned unexpected content type. Expected JSON response. + Dashboard API returned unexpected content type. Expected JSON response. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf new file mode 100644 index 00000000000..805e844279a --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf @@ -0,0 +1,117 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + + + + View telemetry data (logs, spans, traces) from a running Aspire application. + View telemetry data (logs, spans, traces) from a running Aspire application. + + + + Failed to fetch telemetry: {0} + Failed to fetch telemetry: {0} + + + + Stream telemetry in real-time as it arrives. + Stream telemetry in real-time as it arrives. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Filter by error status (true to show only errors, false to exclude errors). + Filter by error status (true to show only errors, false to exclude errors). + + + + The --limit value must be a positive number. + The --limit value must be a positive number. + + + + Maximum number of items to return. + Maximum number of items to return. + + + + View structured logs from the Dashboard telemetry API. + View structured logs from the Dashboard telemetry API. + + + + No AppHosts found in current directory. Showing all running AppHosts. + No AppHosts found in current directory. Showing all running AppHosts. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Filter by resource name. + Filter by resource name. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + + + + View spans from the Dashboard telemetry API. + View spans from the Dashboard telemetry API. + + + + The trace ID to view. If not specified, lists all traces. + The trace ID to view. If not specified, lists all traces. + + + + Filter by trace ID. + Filter by trace ID. + + + + Trace with ID '{0}' was not found. + Trace with ID '{0}' was not found. + + + + View traces from the Dashboard telemetry API. + View traces from the Dashboard telemetry API. + + + + Dashboard API returned unexpected content type. Expected JSON response. + Dashboard API returned unexpected content type. Expected JSON response. + + + + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs index 404b6e6d2bd..38fd9574f01 100644 --- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs +++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs @@ -25,29 +25,36 @@ internal sealed class TelemetryApiService( /// /// Gets spans in OTLP JSON format. /// Returns null if resource filter is specified but not found. + /// Supports multiple resource names. /// - public TelemetryApiResponse? GetSpans(string? resource, string? traceId, bool? hasError, int? limit) + public TelemetryApiResponse? GetSpans(string[]? resourceNames, string? traceId, bool? hasError, int? limit) { - // Validate resource exists if specified + // Resolve resource keys for all specified resources var resources = telemetryRepository.GetResources(); - if (!AIHelpers.TryResolveResourceForTelemetry(resources, resource, out _, out var resourceKey)) + var resourceKeys = ResolveResourceKeys(resources, resourceNames); + if (resourceKeys is null) { return null; } var effectiveLimit = limit ?? DefaultLimit; - var result = telemetryRepository.GetTraces(new GetTracesRequest + // Get spans for all resource keys + var allSpans = new List(); + foreach (var resourceKey in resourceKeys) { - ResourceKey = resourceKey, - StartIndex = 0, - Count = MaxQueryCount, - Filters = [], - FilterText = string.Empty - }); + var result = telemetryRepository.GetTraces(new GetTracesRequest + { + ResourceKey = resourceKey, + StartIndex = 0, + Count = MaxQueryCount, + Filters = [], + FilterText = string.Empty + }); + allSpans.AddRange(result.PagedResult.Items.SelectMany(t => t.Spans)); + } - // Extract all spans from traces - var spans = result.PagedResult.Items.SelectMany(t => t.Spans).ToList(); + var spans = allSpans; // TODO: Consider adding an ExcludeFromApi property on resources in the future. // Currently the API returns all telemetry data for all resources. @@ -89,28 +96,36 @@ internal sealed class TelemetryApiService( /// /// Gets traces in OTLP JSON format (grouped by trace). /// Returns null if resource filter is specified but not found. + /// Supports multiple resource names. /// - public TelemetryApiResponse? GetTraces(string? resource, bool? hasError, int? limit) + public TelemetryApiResponse? GetTraces(string[]? resourceNames, bool? hasError, int? limit) { - // Validate resource exists if specified + // Resolve resource keys for all specified resources var resources = telemetryRepository.GetResources(); - if (!AIHelpers.TryResolveResourceForTelemetry(resources, resource, out _, out var resourceKey)) + var resourceKeys = ResolveResourceKeys(resources, resourceNames); + if (resourceKeys is null) { return null; } var effectiveLimit = limit ?? DefaultTraceLimit; - var result = telemetryRepository.GetTraces(new GetTracesRequest + // Get traces for all resource keys + var allTraces = new List(); + foreach (var resourceKey in resourceKeys) { - ResourceKey = resourceKey, - StartIndex = 0, - Count = MaxQueryCount, - Filters = [], - FilterText = string.Empty - }); + var result = telemetryRepository.GetTraces(new GetTracesRequest + { + ResourceKey = resourceKey, + StartIndex = 0, + Count = MaxQueryCount, + Filters = [], + FilterText = string.Empty + }); + allTraces.AddRange(result.PagedResult.Items); + } - var traces = result.PagedResult.Items.ToList(); + var traces = allTraces; // Filter traces by hasError if (hasError == true) @@ -179,12 +194,14 @@ internal sealed class TelemetryApiService( /// /// Gets logs in OTLP JSON format. /// Returns null if resource filter is specified but not found. + /// Supports multiple resource names. /// - public TelemetryApiResponse? GetLogs(string? resource, string? traceId, string? severity, int? limit) + public TelemetryApiResponse? GetLogs(string[]? resourceNames, string? traceId, string? severity, int? limit) { - // Validate resource exists if specified + // Resolve resource keys for all specified resources var resources = telemetryRepository.GetResources(); - if (!AIHelpers.TryResolveResourceForTelemetry(resources, resource, out _, out var resourceKey)) + var resourceKeys = ResolveResourceKeys(resources, resourceNames); + if (resourceKeys is null) { return null; } @@ -218,16 +235,21 @@ internal sealed class TelemetryApiService( } } - var result = telemetryRepository.GetLogs(new GetLogsContext + // Get logs for all resource keys + var allLogs = new List(); + foreach (var resourceKey in resourceKeys) { - ResourceKey = resourceKey, - StartIndex = 0, - Count = MaxQueryCount, - Filters = filters - }); - - var logs = result.Items; + var result = telemetryRepository.GetLogs(new GetLogsContext + { + ResourceKey = resourceKey, + StartIndex = 0, + Count = MaxQueryCount, + Filters = filters + }); + allLogs.AddRange(result.Items); + } + var logs = allLogs; var totalCount = logs.Count; // Apply limit (take from end for most recent) @@ -248,19 +270,38 @@ internal sealed class TelemetryApiService( /// /// Streams span updates as they arrive in OTLP JSON format. + /// Supports multiple resource names. /// public async IAsyncEnumerable FollowSpansAsync( - string? resource, + string[]? resourceNames, string? traceId, bool? hasError, [EnumeratorCancellation] CancellationToken cancellationToken) { - // For streaming, we don't fail on unknown resource - just filter to nothing + // Resolve resource keys var resources = telemetryRepository.GetResources(); - AIHelpers.TryResolveResourceForTelemetry(resources, resource, out _, out var resourceKey); + var resourceKeys = ResolveResourceKeys(resources, resourceNames); + + // For streaming, if resources were specified but can't be resolved, filter everything out + var hasResourceFilter = resourceNames is { Length: > 0 }; + var invalidResourceFilter = hasResourceFilter && resourceKeys is null; - await foreach (var span in telemetryRepository.WatchSpansAsync(resourceKey, cancellationToken).ConfigureAwait(false)) + // Watch all spans and filter + await foreach (var span in telemetryRepository.WatchSpansAsync(null, cancellationToken).ConfigureAwait(false)) { + // If resource filter is invalid (resources specified but not found), skip all + if (invalidResourceFilter) + { + continue; + } + + // Filter by resource if specified + if (resourceKeys is { Count: > 0 } && !resourceKeys.Any(k => k is null) && + !resourceKeys.Any(k => k?.EqualsCompositeName(span.Source.ResourceKey.GetCompositeName()) == true)) + { + continue; + } + // Apply traceId filter if (!string.IsNullOrEmpty(traceId) && !OtlpHelpers.MatchTelemetryId(span.TraceId, traceId)) { @@ -280,16 +321,21 @@ public async IAsyncEnumerable FollowSpansAsync( /// /// Streams log updates as they arrive in OTLP JSON format. + /// Supports multiple resource names. /// public async IAsyncEnumerable FollowLogsAsync( - string? resource, + string[]? resourceNames, string? traceId, string? severity, [EnumeratorCancellation] CancellationToken cancellationToken) { - // For streaming, we don't fail on unknown resource - just filter to nothing + // Resolve resource keys var resources = telemetryRepository.GetResources(); - AIHelpers.TryResolveResourceForTelemetry(resources, resource, out _, out var resourceKey); + var resourceKeys = ResolveResourceKeys(resources, resourceNames); + + // For streaming, if resources were specified but can't be resolved, filter everything out + var hasResourceFilter = resourceNames is { Length: > 0 }; + var invalidResourceFilter = hasResourceFilter && resourceKeys is null; // Build filters var filters = new List(); @@ -318,12 +364,71 @@ public async IAsyncEnumerable FollowLogsAsync( } } - await foreach (var log in telemetryRepository.WatchLogsAsync(resourceKey, filters, cancellationToken).ConfigureAwait(false)) + // Watch all logs and filter by resource + await foreach (var log in telemetryRepository.WatchLogsAsync(null, filters, cancellationToken).ConfigureAwait(false)) { + // If resource filter is invalid (resources specified but not found), skip all + if (invalidResourceFilter) + { + continue; + } + + // Filter by resource if specified + if (resourceKeys is { Count: > 0 } && !resourceKeys.Any(k => k is null) && + !resourceKeys.Any(k => k?.EqualsCompositeName(log.ResourceView.ResourceKey.GetCompositeName()) == true)) + { + continue; + } + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson([log]); yield return JsonSerializer.Serialize(otlpData, OtlpJsonSerializerContext.DefaultOptions); } } + + /// + /// Gets the list of available resources that have telemetry data. + /// + public ResourceInfo[] GetResources() + { + var resources = telemetryRepository.GetResources(); + return resources + .Where(r => !r.UninstrumentedPeer) // Exclude uninstrumented peers + .Select(r => new ResourceInfo + { + Name = r.ResourceName, + InstanceId = r.InstanceId, + DisplayName = r.ResourceKey.GetCompositeName(), + HasLogs = r.HasLogs, + HasTraces = r.HasTraces, + HasMetrics = r.HasMetrics + }) + .ToArray(); + } + + /// + /// Resolves resource names to ResourceKeys. + /// Returns null if any specified resource is not found. + /// If no resources are specified, returns a list with a single null key (no filter). + /// + private static List? ResolveResourceKeys(IReadOnlyList resources, string[]? resourceNames) + { + if (resourceNames is null || resourceNames.Length == 0) + { + // No filter - return a list with null to indicate "all resources" + return [null]; + } + + var keys = new List(); + foreach (var resourceName in resourceNames) + { + if (!AIHelpers.TryResolveResourceForTelemetry(resources, resourceName, out _, out var resourceKey)) + { + return null; + } + keys.Add(resourceKey); + } + return keys; + } } /// @@ -335,3 +440,40 @@ public sealed class TelemetryApiResponse public required int TotalCount { get; init; } public required int ReturnedCount { get; init; } } + +/// +/// Information about a resource that has telemetry data. +/// +public sealed class ResourceInfo +{ + /// + /// The base resource name (e.g., "catalogservice"). + /// + public required string Name { get; init; } + + /// + /// The instance ID if this is a replica (e.g., "abc123"), or null if single instance. + /// + public string? InstanceId { get; init; } + + /// + /// The full display name including instance ID (e.g., "catalogservice-abc123" or "catalogservice"). + /// Use this when querying the telemetry API. + /// + public required string DisplayName { get; init; } + + /// + /// Whether this resource has structured logs. + /// + public bool HasLogs { get; init; } + + /// + /// Whether this resource has traces/spans. + /// + public bool HasTraces { get; init; } + + /// + /// Whether this resource has metrics. + /// + public bool HasMetrics { get; init; } +} diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 13a9dd237d5..fcf85d0989b 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -304,6 +304,11 @@ + + + + + diff --git a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs index c15b4b9bc2a..b166dfda5b4 100644 --- a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs @@ -87,28 +87,18 @@ public void PostConfigure(string? name, DashboardOptions options) options.AI.Disabled = _configuration.GetBool(DashboardConfigNames.DashboardAIDisabledName.ConfigKey); - // Normalize API keys between Mcp and Api options for backward compatibility. - // If Api has keys but Mcp doesn't, copy to Mcp (Api is the canonical location). - // If Mcp has keys but Api doesn't, copy to Api (for backward compat with existing Mcp configs). + // Normalize API keys: Api is canonical, falls back to Mcp if not set. + // Api -> Mcp fallback only (not bidirectional). if (string.IsNullOrEmpty(options.Mcp.PrimaryApiKey) && !string.IsNullOrEmpty(options.Api.PrimaryApiKey)) { _logger.LogDebug("Defaulting Mcp.PrimaryApiKey from Api.PrimaryApiKey."); options.Mcp.PrimaryApiKey = options.Api.PrimaryApiKey; } - else if (string.IsNullOrEmpty(options.Api.PrimaryApiKey) && !string.IsNullOrEmpty(options.Mcp.PrimaryApiKey)) - { - _logger.LogDebug("Defaulting Api.PrimaryApiKey from Mcp.PrimaryApiKey."); - options.Api.PrimaryApiKey = options.Mcp.PrimaryApiKey; - } if (string.IsNullOrEmpty(options.Mcp.SecondaryApiKey) && !string.IsNullOrEmpty(options.Api.SecondaryApiKey)) { options.Mcp.SecondaryApiKey = options.Api.SecondaryApiKey; } - else if (string.IsNullOrEmpty(options.Api.SecondaryApiKey) && !string.IsNullOrEmpty(options.Mcp.SecondaryApiKey)) - { - options.Api.SecondaryApiKey = options.Mcp.SecondaryApiKey; - } if (_configuration.GetBool(DashboardConfigNames.Legacy.DashboardOtlpSuppressUnsecuredTelemetryMessageName.ConfigKey) is { } suppressUnsecuredTelemetryMessage) { diff --git a/src/Aspire.Dashboard/DashboardEndpointsBuilder.cs b/src/Aspire.Dashboard/DashboardEndpointsBuilder.cs index aec7d0c8b21..1eb92d59a76 100644 --- a/src/Aspire.Dashboard/DashboardEndpointsBuilder.cs +++ b/src/Aspire.Dashboard/DashboardEndpointsBuilder.cs @@ -118,11 +118,19 @@ public static void MapTelemetryApi(this IEndpointRouteBuilder endpoints, Dashboa .RequireAuthorization(ApiAuthenticationHandler.PolicyName) .SkipStatusCodePages(); + // GET /api/telemetry/resources - List resources that have telemetry data + group.MapGet("/resources", (TelemetryApiService service) => + { + var resources = service.GetResources(); + return Results.Json(resources, OtlpJsonSerializerContext.Default.ResourceInfoArray); + }); + // GET /api/telemetry/spans - List spans in OTLP JSON format (with optional streaming via ?follow=true) + // Supports multiple resource names: ?resource=app1&resource=app2 group.MapGet("/spans", async ( TelemetryApiService service, HttpContext httpContext, - [FromQuery] string? resource, + [FromQuery] string[]? resource, [FromQuery] string? traceId, [FromQuery] bool? hasError, [FromQuery] int? limit, @@ -141,7 +149,7 @@ public static void MapTelemetryApi(this IEndpointRouteBuilder endpoints, Dashboa return Results.NotFound(new ProblemDetails { Title = "Resource not found", - Detail = $"No resource with name '{resource}' was found.", + Detail = $"No resource with specified name(s) was found.", Status = StatusCodes.Status404NotFound }); } @@ -149,10 +157,11 @@ public static void MapTelemetryApi(this IEndpointRouteBuilder endpoints, Dashboa }); // GET /api/telemetry/logs - List logs in OTLP JSON format (with optional streaming via ?follow=true) + // Supports multiple resource names: ?resource=app1&resource=app2 group.MapGet("/logs", async ( TelemetryApiService service, HttpContext httpContext, - [FromQuery] string? resource, + [FromQuery] string[]? resource, [FromQuery] string? traceId, [FromQuery] string? severity, [FromQuery] int? limit, @@ -171,7 +180,7 @@ public static void MapTelemetryApi(this IEndpointRouteBuilder endpoints, Dashboa return Results.NotFound(new ProblemDetails { Title = "Resource not found", - Detail = $"No resource with name '{resource}' was found.", + Detail = $"No resource with specified name(s) was found.", Status = StatusCodes.Status404NotFound }); } @@ -179,9 +188,10 @@ public static void MapTelemetryApi(this IEndpointRouteBuilder endpoints, Dashboa }); // GET /api/telemetry/traces - List traces in OTLP JSON format (snapshot only, no streaming) + // Supports multiple resource names: ?resource=app1&resource=app2 group.MapGet("/traces", ( TelemetryApiService service, - [FromQuery] string? resource, + [FromQuery] string[]? resource, [FromQuery] bool? hasError, [FromQuery] int? limit) => { @@ -191,7 +201,7 @@ public static void MapTelemetryApi(this IEndpointRouteBuilder endpoints, Dashboa return Results.NotFound(new ProblemDetails { Title = "Resource not found", - Detail = $"No resource with name '{resource}' was found.", + Detail = $"No resource with specified name(s) was found.", Status = StatusCodes.Status404NotFound }); } diff --git a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs index 38f88b00a48..d0f9ce0c8e1 100644 --- a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs +++ b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs @@ -446,6 +446,7 @@ public static bool TryGetResource(IReadOnlyList resources, st /// /// Tries to resolve a resource name for telemetry queries. /// Returns true if no resource was specified or if the resource was found. + /// Requires exact match - use the CLI to resolve base names to specific instances. /// public static bool TryResolveResourceForTelemetry( IReadOnlyList resources, @@ -460,16 +461,18 @@ public static bool TryResolveResourceForTelemetry( return true; } - if (!TryGetResource(resources, resourceName, out var resource)) + // Exact match only - the resource name must match either the full composite name + // (e.g., "myapp-abc123") or a resource without an instance ID (e.g., "myapp") + if (TryGetResource(resources, resourceName, out var resource)) { - errorMessage = $"Resource '{resourceName}' doesn't have any telemetry. The resource may not exist, may have failed to start or the resource might not support sending telemetry."; - resourceKey = null; - return false; + errorMessage = null; + resourceKey = resource.ResourceKey; + return true; } - errorMessage = null; - resourceKey = resource.ResourceKey; - return true; + errorMessage = $"Resource '{resourceName}' doesn't have any telemetry. The resource may not exist, may have failed to start or the resource might not support sending telemetry."; + resourceKey = null; + return false; } internal static async Task ExecuteStreamingCallAsync( diff --git a/src/Aspire.Dashboard/Model/TelemetryExportService.cs b/src/Aspire.Dashboard/Model/TelemetryExportService.cs index f8f3e396a0e..a3598f6f103 100644 --- a/src/Aspire.Dashboard/Model/TelemetryExportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryExportService.cs @@ -8,6 +8,7 @@ using Aspire.Dashboard.ConsoleLogs; using Aspire.Dashboard.Model.Serialization; using Aspire.Dashboard.Otlp.Model; +using Aspire.Otlp.Serialization; using Aspire.Dashboard.Otlp.Model.MetricValues; using Aspire.Dashboard.Otlp.Model.Serialization; using Aspire.Dashboard.Otlp.Storage; diff --git a/src/Aspire.Dashboard/Model/TelemetryImportService.cs b/src/Aspire.Dashboard/Model/TelemetryImportService.cs index 2923a3ea734..8d3bea3600f 100644 --- a/src/Aspire.Dashboard/Model/TelemetryImportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryImportService.cs @@ -7,6 +7,7 @@ using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Model.Serialization; using Aspire.Dashboard.Otlp.Storage; +using Aspire.Otlp.Serialization; using Microsoft.Extensions.Options; namespace Aspire.Dashboard.Model; diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs index 8f1daa80096..2e060587d66 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs @@ -16,7 +16,7 @@ namespace Aspire.Dashboard.Otlp.Model; -public static class OtlpHelpers +public static partial class OtlpHelpers { // Reduce size of JSON data by not indenting. Dashboard UI supports formatting JSON values when they're displayed. private static readonly JsonSerializerOptions s_jsonSerializerOptions = new JsonSerializerOptions @@ -24,7 +24,7 @@ public static class OtlpHelpers WriteIndented = false }; - public const int ShortenedIdLength = 7; + // Note: ShortenedIdLength is defined in the shared OtlpHelpers.cs public static ResourceKey GetResourceKey(this Resource resource) { @@ -64,7 +64,8 @@ public static ResourceKey GetResourceKey(this Resource resource) return new ResourceKey(serviceName, serviceInstanceId); } - public static string ToShortenedId(string id) => TruncateString(id, maxLength: ShortenedIdLength); + // ToShortenedId is defined in the shared OtlpHelpers.cs + // TruncateString is defined in the shared OtlpHelpers.cs public static string ToHexString(ReadOnlyMemory bytes) { @@ -85,11 +86,6 @@ public static string ToHexString(ReadOnlyMemory bytes) }); } - public static string TruncateString(string value, int maxLength) - { - return value.Length > maxLength ? value[..maxLength] : value; - } - public static string ToHexString(this ByteString bytes) { ArgumentNullException.ThrowIfNull(bytes); @@ -168,26 +164,9 @@ private static void ToCharsBuffer(byte value, Span buffer, int startingInd buffer[startingIndex] = (char)(packedResult >> 8); } - public static DateTime UnixNanoSecondsToDateTime(ulong unixTimeNanoseconds) - { - var ticks = NanosecondsToTicks(unixTimeNanoseconds); - - return DateTime.UnixEpoch.AddTicks(ticks); - } - - /// - /// Converts a DateTime to Unix nanoseconds. - /// - public static ulong DateTimeToUnixNanoseconds(DateTime dateTime) - { - var timeSinceEpoch = dateTime.ToUniversalTime() - DateTime.UnixEpoch; - return (ulong)timeSinceEpoch.Ticks * TimeSpan.NanosecondsPerTick; - } - - private static long NanosecondsToTicks(ulong nanoseconds) - { - return (long)(nanoseconds / TimeSpan.NanosecondsPerTick); - } + // UnixNanoSecondsToDateTime is defined in the shared OtlpHelpers.cs + // DateTimeToUnixNanoseconds is defined in the shared OtlpHelpers.cs + // NanosecondsToTicks is defined in the shared OtlpHelpers.cs public static KeyValuePair[] ToKeyValuePairs(this RepeatedField attributes, OtlpContext context) { diff --git a/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpCommonJson.cs b/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpCommonJson.cs index 36ba578119c..bc054f27a67 100644 --- a/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpCommonJson.cs +++ b/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpCommonJson.cs @@ -2,183 +2,95 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json.Serialization; +using Aspire.Otlp.Serialization; namespace Aspire.Dashboard.Otlp.Model.Serialization; /// -/// Represents any type of attribute value following the OTLP protojson format. -/// Only one value property should be set at a time (oneof semantics). +/// Represents the combined telemetry data in OTLP JSON format. +/// This type can contain logs, traces, and/or metrics data. /// -internal sealed class OtlpAnyValueJson +internal sealed class OtlpTelemetryDataJson { /// - /// String value. - /// - [JsonPropertyName("stringValue")] - public string? StringValue { get; set; } - - /// - /// Boolean value. - /// - [JsonPropertyName("boolValue")] - public bool? BoolValue { get; set; } - - /// - /// Integer value. Serialized as string per protojson spec for int64. - /// - [JsonPropertyName("intValue")] - [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] - public long? IntValue { get; set; } - - /// - /// Double value. - /// - [JsonPropertyName("doubleValue")] - public double? DoubleValue { get; set; } - - /// - /// Array value. - /// - [JsonPropertyName("arrayValue")] - public OtlpArrayValueJson? ArrayValue { get; set; } - - /// - /// Key-value list value. + /// An array of ResourceSpans. /// - [JsonPropertyName("kvlistValue")] - public OtlpKeyValueListJson? KvlistValue { get; set; } + [JsonPropertyName("resourceSpans")] + public OtlpResourceSpansJson[]? ResourceSpans { get; set; } /// - /// Bytes value. Serialized as base64 per protojson spec. + /// An array of ResourceLogs. /// - [JsonPropertyName("bytesValue")] - public string? BytesValue { get; set; } -} + [JsonPropertyName("resourceLogs")] + public OtlpResourceLogsJson[]? ResourceLogs { get; set; } -/// -/// Represents an array of AnyValue messages. -/// -internal sealed class OtlpArrayValueJson -{ /// - /// Array of values. + /// An array of ResourceMetrics. /// - [JsonPropertyName("values")] - public OtlpAnyValueJson[]? Values { get; set; } + [JsonPropertyName("resourceMetrics")] + public OtlpResourceMetricsJson[]? ResourceMetrics { get; set; } } /// -/// Represents a list of KeyValue messages. +/// Represents the export trace service response. /// -internal sealed class OtlpKeyValueListJson +internal sealed class OtlpExportTraceServiceResponseJson { /// - /// Collection of key/value pairs. + /// The details of a partially successful export request. /// - [JsonPropertyName("values")] - public OtlpKeyValueJson[]? Values { get; set; } + [JsonPropertyName("partialSuccess")] + public OtlpExportTracePartialSuccessJson? PartialSuccess { get; set; } } /// -/// Represents a key-value pair used to store attributes. +/// Represents partial success information for trace export. /// -internal sealed class OtlpKeyValueJson +internal sealed class OtlpExportTracePartialSuccessJson { /// - /// The key name of the pair. - /// - [JsonPropertyName("key")] - public string? Key { get; set; } - - /// - /// The value of the pair. + /// The number of rejected spans. Serialized as string per protojson spec for int64. /// - [JsonPropertyName("value")] - public OtlpAnyValueJson? Value { get; set; } -} - -/// -/// Represents instrumentation scope information. -/// -internal sealed class OtlpInstrumentationScopeJson -{ - /// - /// A name denoting the instrumentation scope. - /// - [JsonPropertyName("name")] - public string? Name { get; set; } - - /// - /// The version of the instrumentation scope. - /// - [JsonPropertyName("version")] - public string? Version { get; set; } - - /// - /// Additional attributes that describe the scope. - /// - [JsonPropertyName("attributes")] - public OtlpKeyValueJson[]? Attributes { get; set; } + [JsonPropertyName("rejectedSpans")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] + public long RejectedSpans { get; set; } /// - /// The number of attributes that were discarded. + /// A developer-facing human-readable error message. /// - [JsonPropertyName("droppedAttributesCount")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public uint DroppedAttributesCount { get; set; } + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } } /// -/// Represents a reference to an entity. +/// Represents the export logs service response. /// -internal sealed class OtlpEntityRefJson +internal sealed class OtlpExportLogsServiceResponseJson { /// - /// The Schema URL, if known. - /// - [JsonPropertyName("schemaUrl")] - public string? SchemaUrl { get; set; } - - /// - /// Defines the type of the entity. - /// - [JsonPropertyName("type")] - public string? Type { get; set; } - - /// - /// Attribute keys that identify the entity. - /// - [JsonPropertyName("idKeys")] - public string[]? IdKeys { get; set; } - - /// - /// Descriptive (non-identifying) attribute keys of the entity. + /// The details of a partially successful export request. /// - [JsonPropertyName("descriptionKeys")] - public string[]? DescriptionKeys { get; set; } + [JsonPropertyName("partialSuccess")] + public OtlpExportLogsPartialSuccessJson? PartialSuccess { get; set; } } /// -/// Represents the combined telemetry data in OTLP JSON format. -/// This type can contain logs, traces, and/or metrics data. +/// Represents partial success information for logs export. /// -internal sealed class OtlpTelemetryDataJson +internal sealed class OtlpExportLogsPartialSuccessJson { /// - /// An array of ResourceSpans. + /// The number of rejected log records. Serialized as string per protojson spec for int64. /// - [JsonPropertyName("resourceSpans")] - public OtlpResourceSpansJson[]? ResourceSpans { get; set; } - - /// - /// An array of ResourceLogs. - /// - [JsonPropertyName("resourceLogs")] - public OtlpResourceLogsJson[]? ResourceLogs { get; set; } + [JsonPropertyName("rejectedLogRecords")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] + public long RejectedLogRecords { get; set; } /// - /// An array of ResourceMetrics. + /// A developer-facing human-readable error message. /// - [JsonPropertyName("resourceMetrics")] - public OtlpResourceMetricsJson[]? ResourceMetrics { get; set; } + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } } diff --git a/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpJsonProtobufConverter.cs b/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpJsonProtobufConverter.cs index 3aabc7941d5..8f28211052d 100644 --- a/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpJsonProtobufConverter.cs +++ b/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpJsonProtobufConverter.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.Otlp.Serialization; using Google.Protobuf; using OpenTelemetry.Proto.Collector.Logs.V1; using OpenTelemetry.Proto.Collector.Metrics.V1; diff --git a/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpJsonSerializerContext.cs b/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpJsonSerializerContext.cs index 0c566e1480a..41921fe10a4 100644 --- a/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpJsonSerializerContext.cs +++ b/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpJsonSerializerContext.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Aspire.Dashboard.Api; +using Aspire.Otlp.Serialization; namespace Aspire.Dashboard.Otlp.Model.Serialization; @@ -60,6 +61,8 @@ namespace Aspire.Dashboard.Otlp.Model.Serialization; [JsonSerializable(typeof(OtlpExportMetricsServiceResponseJson))] [JsonSerializable(typeof(OtlpExportMetricsPartialSuccessJson))] [JsonSerializable(typeof(TelemetryApiResponse))] +[JsonSerializable(typeof(ResourceInfo))] +[JsonSerializable(typeof(ResourceInfo[]))] internal sealed partial class OtlpJsonSerializerContext : JsonSerializerContext { /// diff --git a/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpMetricsJson.cs b/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpMetricsJson.cs index 643913fce36..10d9a10e48b 100644 --- a/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpMetricsJson.cs +++ b/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpMetricsJson.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json.Serialization; +using Aspire.Otlp.Serialization; namespace Aspire.Dashboard.Otlp.Model.Serialization; diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 267731d0777..7099483db5c 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -79,25 +79,26 @@ public async Task GetDashboardInfoAsync(GetDashboardIn { _ = request; - var mcpInfo = await GetDashboardMcpConnectionInfoAsync(cancellationToken).ConfigureAwait(false); - var urlsState = await GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false); + var info = await DashboardUrlsHelper.GetDashboardConnectionInfoAsync(serviceProvider, logger, cancellationToken).ConfigureAwait(false); - var urls = new List(); - if (!string.IsNullOrEmpty(urlsState.BaseUrlWithLoginToken)) + var urls = new List(2); + if (!string.IsNullOrEmpty(info.BaseUrlWithLoginToken)) { - urls.Add(urlsState.BaseUrlWithLoginToken); + urls.Add(info.BaseUrlWithLoginToken); } - if (!string.IsNullOrEmpty(urlsState.CodespacesUrlWithLoginToken)) + if (!string.IsNullOrEmpty(info.CodespacesUrlWithLoginToken)) { - urls.Add(urlsState.CodespacesUrlWithLoginToken); + urls.Add(info.CodespacesUrlWithLoginToken); } return new GetDashboardInfoResponse { - McpBaseUrl = mcpInfo?.EndpointUrl, - McpApiToken = mcpInfo?.ApiToken, + McpBaseUrl = info.McpBaseUrl, + McpApiToken = info.McpApiToken, + ApiBaseUrl = info.ApiBaseUrl, + ApiToken = info.ApiToken, DashboardUrls = urls.ToArray(), - IsHealthy = urlsState.DashboardHealthy + IsHealthy = info.IsHealthy }; } diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index bf89a5ee320..247638fdd5e 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -120,6 +120,17 @@ internal sealed class GetDashboardInfoResponse /// public string? McpApiToken { get; init; } + /// + /// Gets the base URL of the Dashboard API (without login token). + /// Use this for API calls like /api/telemetry/*. + /// + public string? ApiBaseUrl { get; init; } + + /// + /// Gets the Dashboard API token for authenticated API calls. + /// + public string? ApiToken { get; init; } + /// /// Gets the Dashboard URLs with login tokens. /// diff --git a/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs b/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs index f4a389581ef..0e4cf6d7b47 100644 --- a/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs +++ b/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs @@ -12,28 +12,28 @@ namespace Aspire.Hosting.Backchannel; /// -/// Helper class for retrieving dashboard URLs with login tokens. +/// Helper class for retrieving dashboard connection information. /// internal static class DashboardUrlsHelper { + private const string McpEndpointName = "mcp"; + /// - /// Gets the dashboard URLs including the login token. + /// Gets all dashboard connection information in a single call. /// Waits for the dashboard to become healthy before returning. /// /// The service provider. /// The logger for diagnostic output. /// A cancellation token. - /// The Dashboard URLs state including health and login URLs. - public static async Task GetDashboardUrlsAsync( + /// Complete dashboard connection information. + public static async Task GetDashboardConnectionInfoAsync( IServiceProvider serviceProvider, ILogger logger, CancellationToken cancellationToken = default) { var resourceNotificationService = serviceProvider.GetRequiredService(); - // Wait for the dashboard to be healthy before returning the URL. This is to ensure that the - // endpoint for the resource is available and the dashboard is ready to be used. This helps - // avoid some issues with port forwarding in devcontainer/codespaces scenarios. + // Wait for the dashboard to be healthy try { await resourceNotificationService.WaitForResourceHealthyAsync( @@ -44,73 +44,118 @@ await resourceNotificationService.WaitForResourceHealthyAsync( catch (DistributedApplicationException ex) { logger.LogWarning(ex, "An error occurred while waiting for the Aspire Dashboard to become healthy."); - - return new DashboardUrlsState - { - DashboardHealthy = false, - BaseUrlWithLoginToken = null, - CodespacesUrlWithLoginToken = null - }; + return DashboardConnectionInfo.Unhealthy; } - var dashboardOptions = serviceProvider.GetService>(); - + var dashboardOptions = serviceProvider.GetService>()?.Value; if (dashboardOptions is null) { logger.LogWarning("Dashboard options not found."); - throw new InvalidOperationException("Dashboard options not found."); + return DashboardConnectionInfo.Unhealthy; } - // Get the actual allocated URL from the dashboard resource endpoint + // Find the dashboard resource and get all endpoints var appModel = serviceProvider.GetService(); - string? dashboardUrl = null; + var dashboardResource = appModel?.Resources.SingleOrDefault( + r => StringComparers.ResourceName.Equals(r.Name, KnownResourceNames.AspireDashboard)) as IResourceWithEndpoints; + + string? apiBaseUrl = null; + string? mcpBaseUrl = null; - if (appModel?.Resources.SingleOrDefault(r => StringComparers.ResourceName.Equals(r.Name, KnownResourceNames.AspireDashboard)) is IResourceWithEndpoints dashboardResource) + if (dashboardResource is not null) { - // Try HTTPS first, then HTTP + // API endpoint (https or http) - used for Dashboard UI and Telemetry API var httpsEndpoint = dashboardResource.GetEndpoint("https"); var httpEndpoint = dashboardResource.GetEndpoint("http"); + var apiEndpoint = httpsEndpoint.Exists ? httpsEndpoint : httpEndpoint; + if (apiEndpoint.Exists) + { + apiBaseUrl = await apiEndpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); + } - var endpoint = httpsEndpoint.Exists ? httpsEndpoint : httpEndpoint; - if (endpoint.Exists) + // MCP endpoint + var mcpEndpoint = dashboardResource.GetEndpoint(McpEndpointName); + if (mcpEndpoint.Exists) { - dashboardUrl = await endpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); + var mcpUrl = await mcpEndpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(mcpUrl)) + { + mcpBaseUrl = $"{mcpUrl}/mcp"; + } } } // Fall back to configured URL if we couldn't get it from the resource - if (string.IsNullOrEmpty(dashboardUrl)) + if (string.IsNullOrEmpty(apiBaseUrl)) { - if (!StringUtils.TryGetUriFromDelimitedString(dashboardOptions.Value.DashboardUrl, ";", out var dashboardUri)) + if (StringUtils.TryGetUriFromDelimitedString(dashboardOptions.DashboardUrl, ";", out var dashboardUri)) { - logger.LogWarning("Dashboard URL could not be parsed from dashboard options."); - throw new InvalidOperationException("Dashboard URL could not be parsed from dashboard options."); + apiBaseUrl = dashboardUri.GetLeftPart(UriPartial.Authority); } - dashboardUrl = dashboardUri.GetLeftPart(UriPartial.Authority); } + // Build login URLs var codespacesUrlRewriter = serviceProvider.GetService(); + string? baseUrlWithLoginToken = null; + string? codespacesUrlWithLoginToken = null; - var baseUrlWithLoginToken = $"{dashboardUrl.TrimEnd('/')}/login?t={dashboardOptions.Value.DashboardToken}"; - var codespacesUrlWithLoginToken = codespacesUrlRewriter?.RewriteUrl(baseUrlWithLoginToken); - - if (baseUrlWithLoginToken == codespacesUrlWithLoginToken) + if (!string.IsNullOrEmpty(apiBaseUrl) && !string.IsNullOrEmpty(dashboardOptions.DashboardToken)) { - return new DashboardUrlsState + baseUrlWithLoginToken = $"{apiBaseUrl.TrimEnd('/')}/login?t={dashboardOptions.DashboardToken}"; + var rewrittenUrl = codespacesUrlRewriter?.RewriteUrl(baseUrlWithLoginToken); + if (rewrittenUrl != baseUrlWithLoginToken) { - DashboardHealthy = true, - BaseUrlWithLoginToken = baseUrlWithLoginToken, - CodespacesUrlWithLoginToken = null - }; + codespacesUrlWithLoginToken = rewrittenUrl; + } } - else + + return new DashboardConnectionInfo { - return new DashboardUrlsState - { - DashboardHealthy = true, - BaseUrlWithLoginToken = baseUrlWithLoginToken, - CodespacesUrlWithLoginToken = codespacesUrlWithLoginToken - }; - } + IsHealthy = true, + ApiBaseUrl = apiBaseUrl, + ApiToken = dashboardOptions.ApiKey, + McpBaseUrl = mcpBaseUrl, + McpApiToken = dashboardOptions.McpApiKey, + BaseUrlWithLoginToken = baseUrlWithLoginToken, + CodespacesUrlWithLoginToken = codespacesUrlWithLoginToken + }; } + + /// + /// Gets the dashboard URLs including the login token. + /// Waits for the dashboard to become healthy before returning. + /// + /// The service provider. + /// The logger for diagnostic output. + /// A cancellation token. + /// The Dashboard URLs state including health and login URLs. + public static async Task GetDashboardUrlsAsync( + IServiceProvider serviceProvider, + ILogger logger, + CancellationToken cancellationToken = default) + { + var info = await GetDashboardConnectionInfoAsync(serviceProvider, logger, cancellationToken).ConfigureAwait(false); + return new DashboardUrlsState + { + DashboardHealthy = info.IsHealthy, + BaseUrlWithLoginToken = info.BaseUrlWithLoginToken, + CodespacesUrlWithLoginToken = info.CodespacesUrlWithLoginToken + }; + } +} + +/// +/// Contains all dashboard connection information. +/// +internal sealed class DashboardConnectionInfo +{ + public static readonly DashboardConnectionInfo Unhealthy = new() { IsHealthy = false }; + + public bool IsHealthy { get; init; } + public string? ApiBaseUrl { get; init; } + public string? ApiToken { get; init; } + public string? McpBaseUrl { get; init; } + public string? McpApiToken { get; init; } + public string? BaseUrlWithLoginToken { get; init; } + public string? CodespacesUrlWithLoginToken { get; init; } } diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 04d35c2621d..b7bd546b84a 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -525,6 +525,7 @@ internal async Task ConfigureEnvironmentVariables(EnvironmentCallbackContext con var browserToken = options.DashboardToken; var otlpApiKey = options.OtlpApiKey; var mcpApiKey = options.McpApiKey; + var apiKey = options.ApiKey; var resourceServiceUrl = await dashboardEndpointProvider.GetResourceServiceUriAsync(context.CancellationToken).ConfigureAwait(false); @@ -586,17 +587,29 @@ internal async Task ConfigureEnvironmentVariables(EnvironmentCallbackContext con context.EnvironmentVariables[DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName] = "Unsecured"; } - // Configure MCP API key - if (!string.IsNullOrEmpty(mcpApiKey)) + // Configure MCP API key. Falls back to ApiKey if McpApiKey not set. + var effectiveMcpApiKey = mcpApiKey ?? apiKey; + if (!string.IsNullOrEmpty(effectiveMcpApiKey)) { context.EnvironmentVariables[DashboardConfigNames.DashboardMcpAuthModeName.EnvVarName] = "ApiKey"; - context.EnvironmentVariables[DashboardConfigNames.DashboardMcpPrimaryApiKeyName.EnvVarName] = mcpApiKey; + context.EnvironmentVariables[DashboardConfigNames.DashboardMcpPrimaryApiKeyName.EnvVarName] = effectiveMcpApiKey; } else { context.EnvironmentVariables[DashboardConfigNames.DashboardMcpAuthModeName.EnvVarName] = "Unsecured"; } + // Configure API key (for Telemetry API). ApiKey is canonical, no fallback from McpApiKey. + if (!string.IsNullOrEmpty(apiKey)) + { + context.EnvironmentVariables[DashboardConfigNames.DashboardApiAuthModeName.EnvVarName] = "ApiKey"; + context.EnvironmentVariables[DashboardConfigNames.DashboardApiPrimaryApiKeyName.EnvVarName] = apiKey; + } + else + { + context.EnvironmentVariables[DashboardConfigNames.DashboardApiAuthModeName.EnvVarName] = "Unsecured"; + } + // Configure dashboard to show CLI MCP instructions when running with an AppHost (not in standalone mode) context.EnvironmentVariables[DashboardConfigNames.DashboardMcpUseCliMcpName.EnvVarName] = "true"; diff --git a/src/Aspire.Hosting/Dashboard/DashboardOptions.cs b/src/Aspire.Hosting/Dashboard/DashboardOptions.cs index 05e1f2df178..9b89d30fd9f 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardOptions.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardOptions.cs @@ -17,6 +17,7 @@ internal class DashboardOptions public string? OtlpApiKey { get; set; } public string? McpEndpointUrl { get; set; } public string? McpApiKey { get; set; } + public string? ApiKey { get; set; } public string AspNetCoreEnvironment { get; set; } = "Production"; public bool? TelemetryOptOut { get; set; } } @@ -34,6 +35,7 @@ public void Configure(DashboardOptions options) options.McpEndpointUrl = configuration[KnownConfigNames.DashboardMcpEndpointUrl]; options.OtlpApiKey = configuration["AppHost:OtlpApiKey"]; options.McpApiKey = configuration["AppHost:McpApiKey"]; + options.ApiKey = configuration["AppHost:DashboardApiKey"]; options.AspNetCoreEnvironment = configuration["ASPNETCORE_ENVIRONMENT"] ?? "Production"; diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 10f5234d0a3..b6a9bd5210a 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -401,6 +401,10 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) // of MCP clients. _userSecretsManager.GetOrSetSecret(_innerBuilder.Configuration, "AppHost:McpApiKey", TokenGenerator.GenerateToken); + // Set a random API key for the Dashboard Telemetry API if one isn't already present in configuration. + // This is the canonical API key; it also falls back to McpApiKey for MCP if not set. + _userSecretsManager.GetOrSetSecret(_innerBuilder.Configuration, "AppHost:DashboardApiKey", TokenGenerator.GenerateToken); + // Determine the frontend browser token. if (_innerBuilder.Configuration.GetString(KnownConfigNames.DashboardFrontendBrowserToken, KnownConfigNames.Legacy.DashboardFrontendBrowserToken, fallbackOnEmpty: true) is not { } browserToken) diff --git a/src/Shared/DashboardUrls.cs b/src/Shared/DashboardUrls.cs index 91c02259f14..597f9f25b3b 100644 --- a/src/Shared/DashboardUrls.cs +++ b/src/Shared/DashboardUrls.cs @@ -188,6 +188,104 @@ public static string CombineUrl(string baseUrl, string path) return $"{trimmedBase}/{trimmedPath}"; } + #region Telemetry API URLs + + private const string TelemetryApiBasePath = "api/telemetry"; + + /// + /// Builds the URL for the telemetry logs API with resource filtering. + /// + /// The dashboard base URL. + /// Optional list of resource names to filter by. + /// Additional query parameters. + /// The full API URL. + public static string TelemetryLogsApiUrl(string baseUrl, List? resources = null, params (string key, string? value)[] additionalParams) + { + var queryString = BuildResourceQueryString(resources, additionalParams); + return CombineUrl(baseUrl, $"{TelemetryApiBasePath}/logs{queryString}"); + } + + /// + /// Builds the URL for the telemetry spans API with resource filtering. + /// + /// The dashboard base URL. + /// Optional list of resource names to filter by. + /// Additional query parameters. + /// The full API URL. + public static string TelemetrySpansApiUrl(string baseUrl, List? resources = null, params (string key, string? value)[] additionalParams) + { + var queryString = BuildResourceQueryString(resources, additionalParams); + return CombineUrl(baseUrl, $"{TelemetryApiBasePath}/spans{queryString}"); + } + + /// + /// Builds the URL for the telemetry traces API with resource filtering. + /// + /// The dashboard base URL. + /// Optional list of resource names to filter by. + /// Additional query parameters. + /// The full API URL. + public static string TelemetryTracesApiUrl(string baseUrl, List? resources = null, params (string key, string? value)[] additionalParams) + { + var queryString = BuildResourceQueryString(resources, additionalParams); + return CombineUrl(baseUrl, $"{TelemetryApiBasePath}/traces{queryString}"); + } + + /// + /// Builds the URL for a specific trace in the telemetry API. + /// + /// The dashboard base URL. + /// The trace ID. + /// The full API URL. + public static string TelemetryTraceDetailApiUrl(string baseUrl, string traceId) + { + var path = $"/{TelemetryApiBasePath}/traces/{Uri.EscapeDataString(traceId)}"; + return CombineUrl(baseUrl, path); + } + + /// + /// Builds the URL for the telemetry resources API endpoint. + /// + /// The dashboard base URL. + /// The full API URL. + public static string TelemetryResourcesApiUrl(string baseUrl) + { + var path = $"/{TelemetryApiBasePath}/resources"; + return CombineUrl(baseUrl, path); + } + + /// + /// Builds a query string with multiple resource parameters and optional additional parameters. + /// + internal static string BuildResourceQueryString( + List? resources, + params (string key, string? value)[] additionalParams) + { + var parts = new List(); + + // Add each resource as a separate query parameter + if (resources is not null) + { + foreach (var resource in resources) + { + parts.Add($"resource={Uri.EscapeDataString(resource)}"); + } + } + + // Add additional parameters + foreach (var (key, value) in additionalParams) + { + if (!string.IsNullOrEmpty(value)) + { + parts.Add($"{key}={Uri.EscapeDataString(value)}"); + } + } + + return parts.Count > 0 ? "?" + string.Join("&", parts) : ""; + } + + #endregion + /// /// Adds a query string parameter to a URL. /// This implementation matches the behavior of QueryHelpers.AddQueryString from ASP.NET Core, diff --git a/src/Shared/Otlp/OtlpHelpers.cs b/src/Shared/Otlp/OtlpHelpers.cs new file mode 100644 index 00000000000..0f7803e1384 --- /dev/null +++ b/src/Shared/Otlp/OtlpHelpers.cs @@ -0,0 +1,91 @@ +// 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; + +namespace Aspire.Dashboard.Otlp.Model; + +/// +/// Shared helper methods for working with OTLP data. +/// Used by both Dashboard and CLI. +/// +public static partial class OtlpHelpers +{ + /// + /// The standard length for shortened trace/span IDs. + /// + public const int ShortenedIdLength = 7; + + /// + /// Shortens a trace or span ID to the standard display length. + /// + public static string ToShortenedId(string id) => TruncateString(id, maxLength: ShortenedIdLength); + + /// + /// Truncates a string to the specified maximum length. + /// + public static string TruncateString(string value, int maxLength) + { + return value.Length > maxLength ? value[..maxLength] : value; + } + + /// + /// Converts Unix nanoseconds to a DateTime (UTC). + /// + public static DateTime UnixNanoSecondsToDateTime(ulong unixTimeNanoseconds) + { + var ticks = NanosecondsToTicks(unixTimeNanoseconds); + return DateTime.UnixEpoch.AddTicks(ticks); + } + + /// + /// Converts a DateTime to Unix nanoseconds. + /// + public static ulong DateTimeToUnixNanoseconds(DateTime dateTime) + { + var timeSinceEpoch = dateTime.ToUniversalTime() - DateTime.UnixEpoch; + return (ulong)timeSinceEpoch.Ticks * TimeSpan.NanosecondsPerTick; + } + + /// + /// Converts nanoseconds to ticks. + /// + public static long NanosecondsToTicks(ulong nanoseconds) + { + return (long)(nanoseconds / TimeSpan.NanosecondsPerTick); + } + + /// + /// Converts nanoseconds to a TimeSpan. + /// + public static TimeSpan NanosecondsToTimeSpan(ulong nanoseconds) + { + return TimeSpan.FromTicks(NanosecondsToTicks(nanoseconds)); + } + + /// + /// Calculates duration as a TimeSpan from start and end nanosecond timestamps. + /// + public static TimeSpan CalculateDuration(ulong? startNano, ulong? endNano) + { + if (startNano.HasValue && endNano.HasValue && endNano.Value >= startNano.Value) + { + return NanosecondsToTimeSpan(endNano.Value - startNano.Value); + } + return TimeSpan.Zero; + } + + /// + /// Formats a Unix nanosecond timestamp to a time string (HH:mm:ss.fff). + /// + /// Formatted time string or empty string if null. + public static string FormatNanoTimestamp(ulong? nanos) + { + if (nanos.HasValue) + { + return UnixNanoSecondsToDateTime(nanos.Value) + .ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); + } + return ""; + } +} diff --git a/src/Shared/Otlp/Serialization/OtlpCommonJson.cs b/src/Shared/Otlp/Serialization/OtlpCommonJson.cs new file mode 100644 index 00000000000..dd07625bf9e --- /dev/null +++ b/src/Shared/Otlp/Serialization/OtlpCommonJson.cs @@ -0,0 +1,159 @@ +// 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.Serialization; + +namespace Aspire.Otlp.Serialization; + +/// +/// Represents any type of attribute value following the OTLP protojson format. +/// Only one value property should be set at a time (oneof semantics). +/// +internal sealed class OtlpAnyValueJson +{ + /// + /// String value. + /// + [JsonPropertyName("stringValue")] + public string? StringValue { get; set; } + + /// + /// Boolean value. + /// + [JsonPropertyName("boolValue")] + public bool? BoolValue { get; set; } + + /// + /// Integer value. Serialized as string per protojson spec for int64. + /// + [JsonPropertyName("intValue")] + [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] + public long? IntValue { get; set; } + + /// + /// Double value. + /// + [JsonPropertyName("doubleValue")] + public double? DoubleValue { get; set; } + + /// + /// Array value. + /// + [JsonPropertyName("arrayValue")] + public OtlpArrayValueJson? ArrayValue { get; set; } + + /// + /// Key-value list value. + /// + [JsonPropertyName("kvlistValue")] + public OtlpKeyValueListJson? KvlistValue { get; set; } + + /// + /// Bytes value. Serialized as base64 per protojson spec. + /// + [JsonPropertyName("bytesValue")] + public string? BytesValue { get; set; } +} + +/// +/// Represents an array of AnyValue messages. +/// +internal sealed class OtlpArrayValueJson +{ + /// + /// Array of values. + /// + [JsonPropertyName("values")] + public OtlpAnyValueJson[]? Values { get; set; } +} + +/// +/// Represents a list of KeyValue messages. +/// +internal sealed class OtlpKeyValueListJson +{ + /// + /// Collection of key/value pairs. + /// + [JsonPropertyName("values")] + public OtlpKeyValueJson[]? Values { get; set; } +} + +/// +/// Represents a key-value pair used to store attributes. +/// +internal sealed class OtlpKeyValueJson +{ + /// + /// The key name of the pair. + /// + [JsonPropertyName("key")] + public string? Key { get; set; } + + /// + /// The value of the pair. + /// + [JsonPropertyName("value")] + public OtlpAnyValueJson? Value { get; set; } +} + +/// +/// Represents instrumentation scope information. +/// +internal sealed class OtlpInstrumentationScopeJson +{ + /// + /// A name denoting the instrumentation scope. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// The version of the instrumentation scope. + /// + [JsonPropertyName("version")] + public string? Version { get; set; } + + /// + /// Additional attributes that describe the scope. + /// + [JsonPropertyName("attributes")] + public OtlpKeyValueJson[]? Attributes { get; set; } + + /// + /// The number of attributes that were discarded. + /// + [JsonPropertyName("droppedAttributesCount")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public uint DroppedAttributesCount { get; set; } +} + +/// +/// Represents a reference to an entity. +/// +internal sealed class OtlpEntityRefJson +{ + /// + /// The Schema URL, if known. + /// + [JsonPropertyName("schemaUrl")] + public string? SchemaUrl { get; set; } + + /// + /// Defines the type of the entity. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// Attribute keys that identify the entity. + /// + [JsonPropertyName("idKeys")] + public string[]? IdKeys { get; set; } + + /// + /// Descriptive (non-identifying) attribute keys of the entity. + /// + [JsonPropertyName("descriptionKeys")] + public string[]? DescriptionKeys { get; set; } +} diff --git a/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpLogsJson.cs b/src/Shared/Otlp/Serialization/OtlpLogsJson.cs similarity index 79% rename from src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpLogsJson.cs rename to src/Shared/Otlp/Serialization/OtlpLogsJson.cs index cec9c68ee98..5d816465d62 100644 --- a/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpLogsJson.cs +++ b/src/Shared/Otlp/Serialization/OtlpLogsJson.cs @@ -3,7 +3,7 @@ using System.Text.Json.Serialization; -namespace Aspire.Dashboard.Otlp.Model.Serialization; +namespace Aspire.Otlp.Serialization; /// /// Represents a collection of ScopeLogs from a Resource. @@ -140,35 +140,3 @@ internal sealed class OtlpExportLogsServiceRequestJson [JsonPropertyName("resourceLogs")] public OtlpResourceLogsJson[]? ResourceLogs { get; set; } } - -/// -/// Represents the export logs service response. -/// -internal sealed class OtlpExportLogsServiceResponseJson -{ - /// - /// The details of a partially successful export request. - /// - [JsonPropertyName("partialSuccess")] - public OtlpExportLogsPartialSuccessJson? PartialSuccess { get; set; } -} - -/// -/// Represents partial success information for logs export. -/// -internal sealed class OtlpExportLogsPartialSuccessJson -{ - /// - /// The number of rejected log records. Serialized as string per protojson spec for int64. - /// - [JsonPropertyName("rejectedLogRecords")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] - public long RejectedLogRecords { get; set; } - - /// - /// A developer-facing human-readable error message. - /// - [JsonPropertyName("errorMessage")] - public string? ErrorMessage { get; set; } -} diff --git a/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpResourceJson.cs b/src/Shared/Otlp/Serialization/OtlpResourceJson.cs similarity index 63% rename from src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpResourceJson.cs rename to src/Shared/Otlp/Serialization/OtlpResourceJson.cs index 8c77ca22f97..293a4bc271f 100644 --- a/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpResourceJson.cs +++ b/src/Shared/Otlp/Serialization/OtlpResourceJson.cs @@ -3,7 +3,7 @@ using System.Text.Json.Serialization; -namespace Aspire.Dashboard.Otlp.Model.Serialization; +namespace Aspire.Otlp.Serialization; /// /// Represents resource information. @@ -28,4 +28,25 @@ internal sealed class OtlpResourceJson /// [JsonPropertyName("entityRefs")] public OtlpEntityRefJson[]? EntityRefs { get; set; } + + /// + /// Gets the service.name attribute value from the resource. + /// + public string GetServiceName() + { + if (Attributes is null) + { + return "unknown"; + } + + foreach (var attr in Attributes) + { + if (attr.Key == "service.name" && attr.Value?.StringValue is not null) + { + return attr.Value.StringValue; + } + } + + return "unknown"; + } } diff --git a/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpTraceJson.cs b/src/Shared/Otlp/Serialization/OtlpTraceJson.cs similarity index 87% rename from src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpTraceJson.cs rename to src/Shared/Otlp/Serialization/OtlpTraceJson.cs index 8c6b63b52cd..1dac8f7cbb6 100644 --- a/src/Aspire.Dashboard/Otlp/Model/Serialization/OtlpTraceJson.cs +++ b/src/Shared/Otlp/Serialization/OtlpTraceJson.cs @@ -3,7 +3,7 @@ using System.Text.Json.Serialization; -namespace Aspire.Dashboard.Otlp.Model.Serialization; +namespace Aspire.Otlp.Serialization; /// /// Represents a collection of ScopeSpans from a Resource. @@ -265,35 +265,3 @@ internal sealed class OtlpExportTraceServiceRequestJson [JsonPropertyName("resourceSpans")] public OtlpResourceSpansJson[]? ResourceSpans { get; set; } } - -/// -/// Represents the export trace service response. -/// -internal sealed class OtlpExportTraceServiceResponseJson -{ - /// - /// The details of a partially successful export request. - /// - [JsonPropertyName("partialSuccess")] - public OtlpExportTracePartialSuccessJson? PartialSuccess { get; set; } -} - -/// -/// Represents partial success information for trace export. -/// -internal sealed class OtlpExportTracePartialSuccessJson -{ - /// - /// The number of rejected spans. Serialized as string per protojson spec for int64. - /// - [JsonPropertyName("rejectedSpans")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - [JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)] - public long RejectedSpans { get; set; } - - /// - /// A developer-facing human-readable error message. - /// - [JsonPropertyName("errorMessage")] - public string? ErrorMessage { get; set; } -} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ResourcesCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ResourcesCommandTests.cs index 2b2f1202f1a..13d92f98780 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ResourcesCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ResourcesCommandTests.cs @@ -62,7 +62,7 @@ public async Task ResourcesCommandShowsRunningResources() // Pattern for aspire resources output - table header var waitForResourcesTableHeader = new CellPatternSearcher() - .Find("NAME"); + .Find("Name"); // Pattern for resources - should show the webfrontend and apiservice var waitForWebfrontendResource = new CellPatternSearcher() diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs new file mode 100644 index 00000000000..00c4ad77013 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs @@ -0,0 +1,243 @@ +// 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.Commands; +using Aspire.Cli.Tests.Utils; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class TelemetryCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task TelemetryCommand_WithoutSubcommand_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("telemetry"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public async Task TelemetryLogsCommand_WhenNoAppHostRunning_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("telemetry logs"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task TelemetrySpansCommand_WhenNoAppHostRunning_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("telemetry spans"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task TelemetryTracesCommand_WhenNoAppHostRunning_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("telemetry traces"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(-100)] + public async Task TelemetryLogsCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"telemetry logs --limit {limitValue}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(-100)] + public async Task TelemetrySpansCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"telemetry spans --limit {limitValue}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(-100)] + public async Task TelemetryTracesCommand_WithInvalidLimitValue_ReturnsInvalidCommand(int limitValue) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"telemetry traces --limit {limitValue}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public void BuildResourceQueryString_WithNoResources_ReturnsEmptyString() + { + var result = DashboardUrls.BuildResourceQueryString(null); + Assert.Equal("", result); + } + + [Fact] + public void BuildResourceQueryString_WithSingleResource_ReturnsCorrectQueryString() + { + var result = DashboardUrls.BuildResourceQueryString(["frontend"]); + Assert.Equal("?resource=frontend", result); + } + + [Fact] + public void BuildResourceQueryString_WithMultipleResources_ReturnsAllResourceParams() + { + var result = DashboardUrls.BuildResourceQueryString(["frontend-abc123", "frontend-xyz789"]); + Assert.Equal("?resource=frontend-abc123&resource=frontend-xyz789", result); + } + + [Fact] + public void BuildResourceQueryString_WithResourcesAndAdditionalParams_CombinesCorrectly() + { + var result = DashboardUrls.BuildResourceQueryString( + ["frontend"], + ("traceId", "abc123"), + ("limit", "10")); + Assert.Equal("?resource=frontend&traceId=abc123&limit=10", result); + } + + [Fact] + public void BuildResourceQueryString_WithNullAdditionalParams_SkipsNullValues() + { + var result = DashboardUrls.BuildResourceQueryString( + ["frontend"], + ("traceId", null), + ("limit", "10")); + Assert.Equal("?resource=frontend&limit=10", result); + } + + [Fact] + public void BuildResourceQueryString_WithSpecialCharacters_EncodesCorrectly() + { + var result = DashboardUrls.BuildResourceQueryString(["service with spaces"]); + Assert.Equal("?resource=service%20with%20spaces", result); + } + + [Fact] + public void ToShortenedId_WithLongId_ReturnsShortenedVersion() + { + var result = OtlpHelpers.ToShortenedId("abc1234567890"); + Assert.Equal("abc1234", result); + Assert.Equal(7, result.Length); + } + + [Fact] + public void ToShortenedId_WithShortId_ReturnsOriginal() + { + var result = OtlpHelpers.ToShortenedId("abc"); + Assert.Equal("abc", result); + } + + [Fact] + public void FormatNanoTimestamp_WithValidTimestamp_ReturnsFormattedTime() + { + // 2026-01-31 12:00:00.123 UTC + var nanoTimestamp = 1769860800123000000UL; + var result = OtlpHelpers.FormatNanoTimestamp(nanoTimestamp); + + // Result should contain time component (HH:mm:ss.fff) + Assert.Matches(@"\d{2}:\d{2}:\d{2}\.\d{3}", result); + } + + [Fact] + public void GetSeverityColor_ReturnsCorrectColors() + { + Assert.Equal(Spectre.Console.Color.Grey, TelemetryCommandHelpers.GetSeverityColor(1)); // Trace + Assert.Equal(Spectre.Console.Color.Grey, TelemetryCommandHelpers.GetSeverityColor(5)); // Debug + Assert.Equal(Spectre.Console.Color.Blue, TelemetryCommandHelpers.GetSeverityColor(9)); // Information + Assert.Equal(Spectre.Console.Color.Yellow, TelemetryCommandHelpers.GetSeverityColor(13)); // Warning + Assert.Equal(Spectre.Console.Color.Red, TelemetryCommandHelpers.GetSeverityColor(17)); // Error + Assert.Equal(Spectre.Console.Color.Red, TelemetryCommandHelpers.GetSeverityColor(21)); // Critical/Fatal + } + + [Fact] + public void CalculateDuration_WithValidTimestamps_ReturnsCorrectDuration() + { + ulong start = 1000000000UL; // 1 second in nanos + ulong end = 2500000000UL; // 2.5 seconds in nanos + + var result = OtlpHelpers.CalculateDuration(start, end); + + Assert.Equal(TimeSpan.FromMilliseconds(1500), result); + } + + [Fact] + public void FormatTraceLink_WithDashboardUrl_ReturnsHyperlink() + { + var result = TelemetryCommandHelpers.FormatTraceLink("http://localhost:18888", "abc123456789"); + + Assert.Contains("[link=", result); + Assert.Contains("/traces/detail/abc123456789", result); + Assert.Contains("abc1234", result); // Shortened ID + } + + [Fact] + public void FormatTraceLink_WithNullDashboardUrl_ReturnsPlainText() + { + var result = TelemetryCommandHelpers.FormatTraceLink(null, "abc123456789"); + + Assert.DoesNotContain("[link=", result); + Assert.Equal("abc1234", result); // Just the shortened ID + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDocsFetcher.cs b/tests/Aspire.Cli.Tests/TestServices/TestDocsFetcher.cs new file mode 100644 index 00000000000..08a0742ef34 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TestDocsFetcher.cs @@ -0,0 +1,17 @@ +// 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.Mcp.Docs; + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// A test implementation of IDocsFetcher that returns empty content. +/// +internal sealed class TestDocsFetcher : IDocsFetcher +{ + public Task FetchDocsAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(null); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestHttpClientFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestHttpClientFactory.cs new file mode 100644 index 00000000000..9a7b01f0d27 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TestHttpClientFactory.cs @@ -0,0 +1,15 @@ +// 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.Tests.TestServices; + +/// +/// A test implementation of IHttpClientFactory that creates basic HttpClient instances. +/// +internal sealed class TestHttpClientFactory : System.Net.Http.IHttpClientFactory +{ + public HttpClient CreateClient(string name) + { + return new HttpClient(); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 63c6e2964cf..1e742c38151 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -132,9 +132,10 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(); services.AddSingleton(); - // MCP docs services + // MCP docs services - use test doubles services.AddSingleton(); - services.AddHttpClient(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -159,6 +160,10 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs b/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs index 2d6d0895c70..822559e11ff 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs @@ -43,22 +43,22 @@ public async Task Configuration_ApiAuthModeDefaults_WhenNotConfigured() } [Fact] - public async Task Configuration_ApiKeyFromMcp_CopiedToApi() + public async Task Configuration_ApiKeyFromApi_CopiedToMcp() { - // Arrange - only set MCP key (legacy config) - var apiKey = "LegacyMcpKey123!"; + // Arrange - only set API key (canonical config) + var apiKey = "ApiKey123!"; await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => { config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.Unsecured.ToString(); - config[DashboardConfigNames.DashboardMcpAuthModeName.ConfigKey] = McpAuthMode.ApiKey.ToString(); - config[DashboardConfigNames.DashboardMcpPrimaryApiKeyName.ConfigKey] = apiKey; + config[DashboardConfigNames.DashboardApiAuthModeName.ConfigKey] = ApiAuthMode.ApiKey.ToString(); + config[DashboardConfigNames.DashboardApiPrimaryApiKeyName.ConfigKey] = apiKey; }); await app.StartAsync().DefaultTimeout(); - // Assert - verify Api gets MCP key + // Assert - verify Mcp gets Api key (Api -> Mcp fallback) var options = app.Services.GetRequiredService>().CurrentValue; - Assert.NotNull(options.Api.GetPrimaryApiKeyBytesOrNull()); - Assert.Equal(apiKey.Length, options.Api.GetPrimaryApiKeyBytesOrNull()!.Length); + Assert.NotNull(options.Mcp.GetPrimaryApiKeyBytesOrNull()); + Assert.Equal(apiKey.Length, options.Mcp.GetPrimaryApiKeyBytesOrNull()!.Length); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs index 7ab9b3b9315..eb26ae7ebdb 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs @@ -9,6 +9,7 @@ using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Model.Serialization; using Aspire.Dashboard.Otlp.Storage; +using Aspire.Otlp.Serialization; using Google.Protobuf.Collections; using Microsoft.Extensions.Logging.Abstractions; using OpenTelemetry.Proto.Logs.V1; diff --git a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs index 1b1b5e1586e..69e505bb5ce 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs @@ -139,7 +139,7 @@ public void GetSpans_HasErrorFalse_ExcludesErrorSpans() var service = new TelemetryApiService(repository); // Act - get spans with hasError=false - var result = service.GetSpans(resource: null, traceId: null, hasError: false, limit: null); + var result = service.GetSpans(resourceNames: null, traceId: null, hasError: false, limit: null); // Assert - should only return the non-error span Assert.NotNull(result); @@ -181,7 +181,7 @@ public void GetSpans_HasErrorTrue_OnlyReturnsErrorSpans() var service = new TelemetryApiService(repository); // Act - get spans with hasError=true - var result = service.GetSpans(resource: null, traceId: null, hasError: true, limit: null); + var result = service.GetSpans(resourceNames: null, traceId: null, hasError: true, limit: null); // Assert - should only return the error span Assert.NotNull(result); @@ -240,14 +240,14 @@ public void GetTraces_HasErrorFalse_ExcludesTracesWithErrors() var service = new TelemetryApiService(repository); // Act - get traces with hasError=false (no error, should exclude the error trace) - var result = service.GetTraces(resource: null, hasError: false, limit: null); + var result = service.GetTraces(resourceNames: null, hasError: false, limit: null); // Assert - should only return 1 trace (the one without errors) Assert.NotNull(result); Assert.Equal(1, result.ReturnedCount); // Verify with null filter returns both - var allResult = service.GetTraces(resource: null, hasError: null, limit: null); + var allResult = service.GetTraces(resourceNames: null, hasError: null, limit: null); Assert.NotNull(allResult); Assert.Equal(2, allResult.ReturnedCount); } @@ -300,15 +300,109 @@ public void GetTraces_HasErrorTrue_OnlyReturnsTracesWithErrors() var service = new TelemetryApiService(repository); // Act - get traces with hasError=true (error only) - var result = service.GetTraces(resource: null, hasError: true, limit: null); + var result = service.GetTraces(resourceNames: null, hasError: true, limit: null); // Assert - should only return 1 trace (the one with errors) Assert.NotNull(result); Assert.Equal(1, result.ReturnedCount); // Verify with null filter returns both - var allResult = service.GetTraces(resource: null, hasError: null, limit: null); + var allResult = service.GetTraces(resourceNames: null, hasError: null, limit: null); Assert.NotNull(allResult); Assert.Equal(2, allResult.ReturnedCount); } + + [Fact] + public async Task FollowSpansAsync_WithInvalidResourceName_ReturnsNoSpans() + { + // Arrange + var repository = CreateRepository(); + + // Add spans for service1 + repository.AddTraces(new AddContext(), new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "trace1", spanId: "span1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1)) + } + } + } + } + }); + + var service = new TelemetryApiService(repository); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + + // Act - stream spans for a non-existent resource + var receivedItems = new List(); + try + { + await foreach (var item in service.FollowSpansAsync(["nonexistent-service"], null, null, cts.Token)) + { + receivedItems.Add(item); + } + } + catch (OperationCanceledException) + { + // Expected - timeout + } + + // Assert - should receive NO items because the resource doesn't exist + Assert.Empty(receivedItems); + } + + [Fact] + public async Task FollowLogsAsync_WithInvalidResourceName_ReturnsNoLogs() + { + // Arrange + var repository = CreateRepository(); + + // Add logs for service1 + repository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(name: "service1", instanceId: "inst1"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = + { + CreateLogRecord(time: s_testTime, message: "log1", severity: SeverityNumber.Info) + } + } + } + } + }); + + var service = new TelemetryApiService(repository); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + + // Act - stream logs for a non-existent resource + var receivedItems = new List(); + try + { + await foreach (var item in service.FollowLogsAsync(["nonexistent-service"], null, null, cts.Token)) + { + receivedItems.Add(item); + } + } + catch (OperationCanceledException) + { + // Expected - timeout + } + + // Assert - should receive NO items because the resource doesn't exist + Assert.Empty(receivedItems); + } } diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs index 60f431318ea..f04e285721a 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs @@ -143,6 +143,11 @@ public async Task DashboardDoesNotAddResource_ConfiguresExistingDashboard(string Assert.Equal("http://localhost:5003", e.Value); }, e => + { + Assert.Equal("DASHBOARD__API__AUTHMODE", e.Key); + Assert.Equal("Unsecured", e.Value); + }, + e => { Assert.Equal("DASHBOARD__FRONTEND__AUTHMODE", e.Key); Assert.Equal("Unsecured", e.Value); From bbc9a8ede3bc46c00f198258d924b1021aca0eb7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:07:40 -0600 Subject: [PATCH 017/256] Make required command validation generic via resource annotations for orchestrator pre-checks (#11944) This pull request introduces a new, unified system for validating required commands/executables for resources using the RequiredCommandAnnotation and WithRequiredCommand extension methods. It removes legacy, resource-specific command validation classes and migrates all relevant code to use the new declarative approach. This results in a more flexible, reusable, and maintainable validation mechanism across the codebase. Additionally, documentation has been added to explain the new system, and JavaScript and Azure Functions resource extensions have been updated to utilize it. Key changes: Removal of Legacy Validators Removed the FuncCoreToolsInstallationManager and DevTunnelCliInstallationManager classes, along with their registrations and usages, in favor of the new annotation-based system. Updated project files to stop including RequiredCommandValidator.cs in both Azure Functions and DevTunnels projects. Migration to RequiredCommandAnnotation Updated Azure Functions resource extension to use WithRequiredCommand("func", ...) instead of the previous installation manager for validating Azure Functions Core Tools. Updated DevTunnel resource extension to use a custom RequiredCommandAnnotation with a version validation callback for the devtunnel CLI, replacing the previous installation manager logic. JavaScript Resource Improvements Updated JavaScript resource extensions to declare required commands (node, npm, bun, yarn) using WithRequiredCommand, providing appropriate installation help links for each. These changes make command validation more consistent, extensible, and easier to maintain across all resource types. Fix #11943 * Initial plan * Add RequiredCommandAnnotation and generic validation infrastructure Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Add documentation for RequiredCommandAnnotation feature Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Use IInteractionService to show notification with help link Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Apply suggestions from code review * Update tests/Aspire.Hosting.Tests/CommandResolverTests.cs * Fix build failures: remove multiple blank lines Co-authored-by: DamianEdwards <249088+DamianEdwards@users.noreply.github.com> * Add using statement for CommandResolver namespace Co-authored-by: DamianEdwards <249088+DamianEdwards@users.noreply.github.com> * Fix build after merge * Add a cache so the commands are only checked once per run. Convert Python to work against the new feature. * Don't throw an exception during before start if the command isn't there. Update Azure Functions to use the new feature. * Add JavaScript required commands * Change ValidationCallback to use context and result classes. * Refactor core logic into a reusable DI service. * Update DevTunnels to use the new validator. Refactor devtunnel CLI validation to use generic callback Replaces DevTunnelCliInstallationManager with a callback-based version validation using RequiredCommandValidator. Updates resource startup logic to use the new approach, improves RequiredCommandValidationResult, and removes obsolete tests. Adds new targeted tests for CLI version validation. Cleans up project file references. This makes CLI validation more modular, testable, and consistent. * PR feedback Add more tests. Adjust namespaces. Revert unnecessary changes. * Use resources for messages. * More fix ups * Make APIs experimental * Respond to PR feedback --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> Co-authored-by: David Fowler Co-authored-by: DamianEdwards <249088+DamianEdwards@users.noreply.github.com> Co-authored-by: Eric Erhardt --- .../Aspire.Hosting.Azure.Functions.csproj | 3 - ...AzureFunctionsProjectResourceExtensions.cs | 12 +- .../FuncCoreToolsInstallationManager.cs | 38 -- .../Aspire.Hosting.DevTunnels.csproj | 2 - .../DevTunnelCliInstallationManager.cs | 82 ---- .../DevTunnelResourceBuilderExtensions.cs | 44 +- .../Resources/MessageStrings.Designer.cs | 36 -- .../Resources/MessageStrings.resx | 12 - .../Resources/xlf/MessageStrings.cs.xlf | 20 - .../Resources/xlf/MessageStrings.de.xlf | 20 - .../Resources/xlf/MessageStrings.es.xlf | 20 - .../Resources/xlf/MessageStrings.fr.xlf | 20 - .../Resources/xlf/MessageStrings.it.xlf | 20 - .../Resources/xlf/MessageStrings.ja.xlf | 20 - .../Resources/xlf/MessageStrings.ko.xlf | 20 - .../Resources/xlf/MessageStrings.pl.xlf | 20 - .../Resources/xlf/MessageStrings.pt-BR.xlf | 20 - .../Resources/xlf/MessageStrings.ru.xlf | 20 - .../Resources/xlf/MessageStrings.tr.xlf | 20 - .../Resources/xlf/MessageStrings.zh-Hans.xlf | 20 - .../Resources/xlf/MessageStrings.zh-Hant.xlf | 20 - .../JavaScriptHostingExtensions.cs | 13 +- .../Aspire.Hosting.Python.csproj | 3 - .../PythonAppResourceBuilderExtensions.cs | 34 +- .../PythonInstallationManager.cs | 71 --- .../UvInstallationManager.cs | 46 -- .../IRequiredCommandValidator.cs | 31 ++ .../RequiredCommandAnnotation.cs | 35 ++ .../RequiredCommandValidationContext.cs | 31 ++ .../RequiredCommandValidationResult.cs | 48 ++ .../RequiredCommandValidator.cs | 201 ++++++++ src/Aspire.Hosting/Aspire.Hosting.csproj | 1 + .../DistributedApplicationBuilder.cs | 6 + .../RequiredCommandValidationLifecycleHook.cs | 48 ++ .../RequiredCommandResourceExtensions.cs | 80 ++++ .../Resources/MessageStrings.Designer.cs | 63 +++ .../Resources/MessageStrings.resx | 21 + .../Resources/xlf/MessageStrings.cs.xlf | 35 ++ .../Resources/xlf/MessageStrings.de.xlf | 35 ++ .../Resources/xlf/MessageStrings.es.xlf | 35 ++ .../Resources/xlf/MessageStrings.fr.xlf | 35 ++ .../Resources/xlf/MessageStrings.it.xlf | 35 ++ .../Resources/xlf/MessageStrings.ja.xlf | 35 ++ .../Resources/xlf/MessageStrings.ko.xlf | 35 ++ .../Resources/xlf/MessageStrings.pl.xlf | 35 ++ .../Resources/xlf/MessageStrings.pt-BR.xlf | 35 ++ .../Resources/xlf/MessageStrings.ru.xlf | 35 ++ .../Resources/xlf/MessageStrings.tr.xlf | 35 ++ .../Resources/xlf/MessageStrings.zh-Hans.xlf | 35 ++ .../Resources/xlf/MessageStrings.zh-Hant.xlf | 35 ++ src/Shared/RequiredCommandValidator.cs | 191 -------- .../AzureFunctionsTests.cs | 15 +- .../DevTunnelCliInstallationManagerTests.cs | 127 ----- .../DevTunnelCliVersionValidationTests.cs | 93 ++++ .../DistributedApplicationBuilderTests.cs | 3 +- .../PathLookupHelperTests.cs | 0 .../RequiredCommandAnnotationTests.cs | 452 ++++++++++++++++++ .../Aspire.TestUtilities.csproj | 2 +- 58 files changed, 1635 insertions(+), 924 deletions(-) delete mode 100644 src/Aspire.Hosting.Azure.Functions/FuncCoreToolsInstallationManager.cs delete mode 100644 src/Aspire.Hosting.DevTunnels/DevTunnelCliInstallationManager.cs delete mode 100644 src/Aspire.Hosting.Python/PythonInstallationManager.cs delete mode 100644 src/Aspire.Hosting.Python/UvInstallationManager.cs create mode 100644 src/Aspire.Hosting/ApplicationModel/IRequiredCommandValidator.cs create mode 100644 src/Aspire.Hosting/ApplicationModel/RequiredCommandAnnotation.cs create mode 100644 src/Aspire.Hosting/ApplicationModel/RequiredCommandValidationContext.cs create mode 100644 src/Aspire.Hosting/ApplicationModel/RequiredCommandValidationResult.cs create mode 100644 src/Aspire.Hosting/ApplicationModel/RequiredCommandValidator.cs create mode 100644 src/Aspire.Hosting/Lifecycle/RequiredCommandValidationLifecycleHook.cs create mode 100644 src/Aspire.Hosting/RequiredCommandResourceExtensions.cs delete mode 100644 src/Shared/RequiredCommandValidator.cs delete mode 100644 tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelCliInstallationManagerTests.cs create mode 100644 tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelCliVersionValidationTests.cs rename tests/{Aspire.Cli.Tests/Utils => Aspire.Hosting.Tests}/PathLookupHelperTests.cs (100%) create mode 100644 tests/Aspire.Hosting.Tests/RequiredCommandAnnotationTests.cs diff --git a/src/Aspire.Hosting.Azure.Functions/Aspire.Hosting.Azure.Functions.csproj b/src/Aspire.Hosting.Azure.Functions/Aspire.Hosting.Azure.Functions.csproj index 72421012a40..fde1b6c6485 100644 --- a/src/Aspire.Hosting.Azure.Functions/Aspire.Hosting.Azure.Functions.csproj +++ b/src/Aspire.Hosting.Azure.Functions/Aspire.Hosting.Azure.Functions.csproj @@ -18,9 +18,6 @@ - - - diff --git a/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs index 961165d1a30..2dcf8c1e75c 100644 --- a/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/AzureFunctionsProjectResourceExtensions.cs @@ -6,8 +6,6 @@ using Aspire.Hosting.Azure; using Aspire.Hosting.Utils; using Azure.Provisioning.Storage; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; namespace Aspire.Hosting; @@ -150,9 +148,6 @@ private static IResourceBuilder AddAzureFunctions .Resource; } - // Register the FuncCoreToolsInstallationManager service for validating Azure Functions Core Tools - builder.Services.TryAddSingleton(); - builder.Eventing.Subscribe((data, token) => { var removeStorage = true; @@ -189,12 +184,7 @@ private static IResourceBuilder AddAzureFunctions // Only validate Azure Functions Core Tools in run mode (not during publish) if (builder.ExecutionContext.IsRunMode) { - functionsBuilder.OnBeforeResourceStarted(static async (functionsResource, e, ct) => - { - // Validate that Azure Functions Core Tools (func) is installed - var funcToolsManager = e.Services.GetRequiredService(); - await funcToolsManager.EnsureInstalledAsync(throwOnFailure: false, ct).ConfigureAwait(false); - }); + functionsBuilder.WithRequiredCommand("func", "https://learn.microsoft.com/azure/azure-functions/functions-run-local#install-the-azure-functions-core-tools"); } // Add launch profile annotations like regular projects do. diff --git a/src/Aspire.Hosting.Azure.Functions/FuncCoreToolsInstallationManager.cs b/src/Aspire.Hosting.Azure.Functions/FuncCoreToolsInstallationManager.cs deleted file mode 100644 index 9521b9382de..00000000000 --- a/src/Aspire.Hosting.Azure.Functions/FuncCoreToolsInstallationManager.cs +++ /dev/null @@ -1,38 +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 Aspire.Hosting.Utils; -using Microsoft.Extensions.Logging; - -namespace Aspire.Hosting.Azure; - -/// -/// Validates that the Azure Functions Core Tools (func) command is available on the system. -/// -#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -internal sealed class FuncCoreToolsInstallationManager : RequiredCommandValidator -{ - public FuncCoreToolsInstallationManager( - IInteractionService interactionService, - ILogger logger) - : base(interactionService, logger) - { - } - - /// - /// Ensures Azure Functions Core Tools are installed/available. This method is safe for concurrent callers; - /// only one validation will run at a time. - /// - /// Whether to throw an exception if func is not found. Default is true. - /// Cancellation token. - public Task EnsureInstalledAsync(bool throwOnFailure = true, CancellationToken cancellationToken = default) - { - SetThrowOnFailure(throwOnFailure); - return RunAsync(cancellationToken); - } - - protected override string GetCommandPath() => "func"; - - protected override string? GetHelpLink() => "https://learn.microsoft.com/azure/azure-functions/functions-run-local#install-the-azure-functions-core-tools"; -} -#pragma warning restore ASPIREINTERACTION001 diff --git a/src/Aspire.Hosting.DevTunnels/Aspire.Hosting.DevTunnels.csproj b/src/Aspire.Hosting.DevTunnels/Aspire.Hosting.DevTunnels.csproj index 1ed5e0ff765..6fc56a4e7e9 100644 --- a/src/Aspire.Hosting.DevTunnels/Aspire.Hosting.DevTunnels.csproj +++ b/src/Aspire.Hosting.DevTunnels/Aspire.Hosting.DevTunnels.csproj @@ -11,8 +11,6 @@ - - diff --git a/src/Aspire.Hosting.DevTunnels/DevTunnelCliInstallationManager.cs b/src/Aspire.Hosting.DevTunnels/DevTunnelCliInstallationManager.cs deleted file mode 100644 index d9aa6fc66cd..00000000000 --- a/src/Aspire.Hosting.DevTunnels/DevTunnelCliInstallationManager.cs +++ /dev/null @@ -1,82 +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.Globalization; -using Aspire.Hosting.Utils; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace Aspire.Hosting.DevTunnels; - -internal sealed class DevTunnelCliInstallationManager : RequiredCommandValidator -{ - private readonly IDevTunnelClient _devTunnelClient; - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - private readonly Version _minSupportedVersion; - private string? _resolvedCommandPath; - -#pragma warning disable ASPIREINTERACTION001 // Interaction service is experimental. - public DevTunnelCliInstallationManager( - IDevTunnelClient devTunnelClient, - IConfiguration configuration, - IInteractionService interactionService, - ILogger logger) - : this(devTunnelClient, configuration, interactionService, logger, DevTunnelCli.MinimumSupportedVersion) - { - - } - - public DevTunnelCliInstallationManager( - IDevTunnelClient devTunnelClient, - IConfiguration configuration, - IInteractionService interactionService, - ILogger logger, - Version minSupportedVersion) - : base(interactionService, logger) -#pragma warning restore ASPIREINTERACTION001 - { - _devTunnelClient = devTunnelClient ?? throw new ArgumentNullException(nameof(devTunnelClient)); - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _minSupportedVersion = minSupportedVersion ?? throw new ArgumentNullException(nameof(minSupportedVersion)); - } - - /// - /// Gets the resolved full path to the devtunnel CLI after a successful validation, otherwise null. - /// - public string? ResolvedCommandPath => _resolvedCommandPath; - - /// - /// Gets a value indicating whether the CLI was found (after calling ). - /// - public bool IsInstalled => _resolvedCommandPath is not null; - - /// - /// Ensures the devtunnel CLI is installed/available. This method is safe for concurrent callers; - /// only one validation will run at a time. - /// - /// Thrown if the devtunnel CLI is not found. - public Task EnsureInstalledAsync(CancellationToken cancellationToken = default) => RunAsync(cancellationToken); - - protected override string GetCommandPath() => DevTunnelCli.GetCliPath(_configuration); - - protected internal override async Task<(bool IsValid, string? ValidationMessage)> OnResolvedAsync(string resolvedCommandPath, CancellationToken cancellationToken) - { - // Verify the version is supported - var version = await _devTunnelClient.GetVersionAsync(_logger, cancellationToken).ConfigureAwait(false); - if (version < _minSupportedVersion) - { - return (false, string.Format(CultureInfo.CurrentCulture, Resources.MessageStrings.DevtunnelCliVersionNotSupported, version, _minSupportedVersion)); - } - return (true, null); - } - - protected override Task OnValidatedAsync(string resolvedCommandPath, CancellationToken cancellationToken) - { - _resolvedCommandPath = resolvedCommandPath; - return Task.CompletedTask; - } - - protected override string? GetHelpLink() => "https://learn.microsoft.com/azure/developer/dev-tunnels/get-started#install"; -} diff --git a/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs b/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs index 9a0c56a81a9..b2386597920 100644 --- a/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs @@ -1,12 +1,16 @@ // 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 ASPIRECOMMAND001 + using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Net.Http.Headers; using System.Reflection; using System.Text.RegularExpressions; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.DevTunnels; +using Aspire.Hosting.DevTunnels.Resources; using Aspire.Hosting.Eventing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -74,7 +78,6 @@ public static IResourceBuilder AddDevTunnel( } // Add services - builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); @@ -117,12 +120,23 @@ public static IResourceBuilder AddDevTunnel( { var logger = e.Services.GetRequiredService().GetLogger(tunnelResource); var eventing = e.Services.GetRequiredService(); - var devTunnelCliInstallationManager = e.Services.GetRequiredService(); + var commandValidator = e.Services.GetRequiredService(); var devTunnelEnvironmentManager = e.Services.GetRequiredService(); var devTunnelClient = e.Services.GetRequiredService(); - // Ensure CLI is available - await devTunnelCliInstallationManager.EnsureInstalledAsync(ct).ConfigureAwait(false); + // Validate the CLI is available and version is supported. + // We use manual validation here instead of WithRequiredCommand call because our + // OnBeforeResourceStarted handler runs before the global RequiredCommandValidationLifecycleHook runs. + var cliAnnotation = new RequiredCommandAnnotation(tunnelResource.Command) + { + HelpLink = "https://learn.microsoft.com/azure/developer/dev-tunnels/get-started#install", + ValidationCallback = ValidateDevTunnelCliVersionAsync + }; + var result = await commandValidator.ValidateAsync(tunnelResource, cliAnnotation, ct).ConfigureAwait(false); + if (!result.IsValid) + { + throw new DistributedApplicationException(result.ValidationMessage); + } // Login to the dev tunnels service if needed logger.LogInformation("Ensuring user is logged in to dev tunnel service"); @@ -703,6 +717,28 @@ private static string GetUserAgent() return new ProductInfoHeaderValue("Aspire.DevTunnels", version).ToString(); } + internal static async Task ValidateDevTunnelCliVersionAsync(RequiredCommandValidationContext context) + { + var devTunnelClient = context.Services.GetRequiredService(); + + try + { + var version = await devTunnelClient.GetVersionAsync(logger: null, context.CancellationToken).ConfigureAwait(false); + + if (version < DevTunnelCli.MinimumSupportedVersion) + { + return RequiredCommandValidationResult.Failure( + string.Format(CultureInfo.CurrentCulture, MessageStrings.DevtunnelCliVersionNotSupported, version, DevTunnelCli.MinimumSupportedVersion)); + } + + return RequiredCommandValidationResult.Success(); + } + catch (Exception ex) + { + return RequiredCommandValidationResult.Failure(ex.Message); + } + } + private static bool TryValidateLabels(List? labels, [NotNullWhen(false)] out string? errorMessage) { if (labels is null || labels.Count == 0) diff --git a/src/Aspire.Hosting.DevTunnels/Resources/MessageStrings.Designer.cs b/src/Aspire.Hosting.DevTunnels/Resources/MessageStrings.Designer.cs index 9dd2e7b8baf..d49cc7a8060 100644 --- a/src/Aspire.Hosting.DevTunnels/Resources/MessageStrings.Designer.cs +++ b/src/Aspire.Hosting.DevTunnels/Resources/MessageStrings.Designer.cs @@ -150,15 +150,6 @@ internal static string DevTunnelUnhealthy_PortInactive { } } - /// - /// Looks up a localized string similar to Installation instructions. - /// - internal static string InstallationInstructions { - get { - return ResourceManager.GetString("InstallationInstructions", resourceCulture); - } - } - /// /// Looks up a localized string similar to Login with GitHub. /// @@ -176,32 +167,5 @@ internal static string LoginWithMicrosoft { return ResourceManager.GetString("LoginWithMicrosoft", resourceCulture); } } - - /// - /// Looks up a localized string similar to Required command '{0}' was not found on PATH or at a specified location.. - /// - internal static string RequiredCommandNotification { - get { - return ResourceManager.GetString("RequiredCommandNotification", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Required command '{0}' was not found. See installation instructions for more details.. - /// - internal static string RequiredCommandNotificationWithLink { - get { - return ResourceManager.GetString("RequiredCommandNotificationWithLink", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to {0} See installation instructions for more details.. - /// - internal static string RequiredCommandNotificationWithValidation { - get { - return ResourceManager.GetString("RequiredCommandNotificationWithValidation", resourceCulture); - } - } } } diff --git a/src/Aspire.Hosting.DevTunnels/Resources/MessageStrings.resx b/src/Aspire.Hosting.DevTunnels/Resources/MessageStrings.resx index fa616e51546..8d79dfbf8b8 100644 --- a/src/Aspire.Hosting.DevTunnels/Resources/MessageStrings.resx +++ b/src/Aspire.Hosting.DevTunnels/Resources/MessageStrings.resx @@ -147,22 +147,10 @@ Dev tunnel '{0}' port '{1}' is not active. - - Installation instructions - Login with GitHub Login with Microsoft - - Required command '{0}' was not found on PATH or at a specified location. - - - Required command '{0}' was not found. See installation instructions for more details. - - - {0} See installation instructions for more details. - \ No newline at end of file diff --git a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.cs.xlf b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.cs.xlf index 0f5840d3c81..7dec81057d8 100644 --- a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.cs.xlf +++ b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.cs.xlf @@ -52,11 +52,6 @@ Nainstalovaná verze příkazového řádku devtunnel {0} není podporovaná. Vyžaduje se verze {1} nebo vyšší. - - Installation instructions - Pokyny k instalaci - - Login with GitHub Přihlásit se pomocí GitHubu @@ -67,21 +62,6 @@ Přihlásit se pomocí účtu Microsoft - - Required command '{0}' was not found on PATH or at a specified location. - Požadovaný příkaz {0} nebyl nalezen v cestě PATH nebo v zadaném umístění. - - - - Required command '{0}' was not found. See installation instructions for more details. - Požadovaný příkaz {0} nebyl nalezen. Další podrobnosti najdete v pokynech k instalaci. - - - - {0} See installation instructions for more details. - {0} Další podrobnosti najdete v pokynech k instalaci. - - \ No newline at end of file diff --git a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.de.xlf b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.de.xlf index c00f0e43b40..8bc52eb84c3 100644 --- a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.de.xlf +++ b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.de.xlf @@ -52,11 +52,6 @@ Die installierte devtunnel-CLI-Version {0} wird nicht unterstützt. Version {1} oder höher ist erforderlich. - - Installation instructions - Installationsanleitung - - Login with GitHub Mit GitHub anmelden @@ -67,21 +62,6 @@ Bei Microsoft anmelden - - Required command '{0}' was not found on PATH or at a specified location. - Der erforderliche Befehl „{0}“ wurde weder im PATH noch an einem angegebenen Speicherort gefunden. - - - - Required command '{0}' was not found. See installation instructions for more details. - Der erforderliche Befehl „{0}“ wurde nicht gefunden. Weitere Informationen finden Sie in den Installationsanweisungen. - - - - {0} See installation instructions for more details. - {0} Weitere Informationen finden Sie in den Installationsanweisungen. - - \ No newline at end of file diff --git a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.es.xlf b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.es.xlf index 6354d4e5dfc..4d024b2f787 100644 --- a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.es.xlf +++ b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.es.xlf @@ -52,11 +52,6 @@ La versión instalada de la CLI de devtunnel {0} no es compatible. Se requiere la versión {1} o superior. - - Installation instructions - Instrucciones de instalación - - Login with GitHub Iniciar sesión con GitHub @@ -67,21 +62,6 @@ Iniciar sesión con Microsoft - - Required command '{0}' was not found on PATH or at a specified location. - No se encontró el comando requerido '{0}' en la ruta de acceso ni en la ubicación especificada. - - - - Required command '{0}' was not found. See installation instructions for more details. - No se encontró el comando necesario '{0}'. Consulte las instrucciones de instalación para obtener más detalles. - - - - {0} See installation instructions for more details. - {0} Consulte las instrucciones de instalación para obtener más detalles. - - \ No newline at end of file diff --git a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.fr.xlf b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.fr.xlf index bbeb0b42f9e..fbc7c8472c4 100644 --- a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.fr.xlf +++ b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.fr.xlf @@ -52,11 +52,6 @@ La version installée de l’interface CLI devtunnel {0} n’est pas prise en charge. La version {1} ou supérieure est requise. - - Installation instructions - Instructions d’installation - - Login with GitHub Connexion avec GitHub @@ -67,21 +62,6 @@ Se connecter avec Microsoft - - Required command '{0}' was not found on PATH or at a specified location. - La commande requise « {0} » est introuvable dans le CHEMIN ou à l’emplacement spécifié. - - - - Required command '{0}' was not found. See installation instructions for more details. - La commande nécessaire « {0} » n’a pas été fournie. Consultez les instructions d’installation pour obtenir plus d’informations. - - - - {0} See installation instructions for more details. - {0} Consultez les instructions d’installation pour obtenir plus de détails. - - \ No newline at end of file diff --git a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.it.xlf b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.it.xlf index 19f3876941b..25093b43bec 100644 --- a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.it.xlf +++ b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.it.xlf @@ -52,11 +52,6 @@ La versione installata dell'interfaccia della riga di comando del tunnel dev {0} non è supportata. È necessaria la versione {1} o successiva. - - Installation instructions - Istruzioni per l'installazione - - Login with GitHub Accedi con GitHub @@ -67,21 +62,6 @@ Accedi con Microsoft - - Required command '{0}' was not found on PATH or at a specified location. - Il comando richiesto '{0}' non è stato trovato in PATH o nel percorso specificato. - - - - Required command '{0}' was not found. See installation instructions for more details. - Il comando obbligatorio '{0}' non è stato trovato. Per altri dettagli, vedere le istruzioni di installazione. - - - - {0} See installation instructions for more details. - {0} Per altri dettagli, vedere le istruzioni di installazione. - - \ No newline at end of file diff --git a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.ja.xlf b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.ja.xlf index ab1144e2255..5432444984b 100644 --- a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.ja.xlf +++ b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.ja.xlf @@ -52,11 +52,6 @@ インストールされている devtunnel CLI バージョン {0} はサポートされていません。バージョン {1} 以上が必要です。 - - Installation instructions - インストール手順 - - Login with GitHub GitHub でログイン @@ -67,21 +62,6 @@ Microsoft でログインする - - Required command '{0}' was not found on PATH or at a specified location. - 必要なコマンド '{0}' が PATH または指定された場所に見つかりませんでした。 - - - - Required command '{0}' was not found. See installation instructions for more details. - 必要なコマンド '{0}' が見つかりませんでした。詳細については、インストール手順を参照してください。 - - - - {0} See installation instructions for more details. - {0} 詳細については、インストール手順を参照してください。 - - \ No newline at end of file diff --git a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.ko.xlf b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.ko.xlf index fa3e163f400..90728a4f61d 100644 --- a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.ko.xlf +++ b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.ko.xlf @@ -52,11 +52,6 @@ 설치된 devtunnel CLI 버전 {0}은(는) 지원되지 않습니다. 버전 {1} 이상이 필요합니다. - - Installation instructions - 설치 지침 - - Login with GitHub GitHub로 로그인 @@ -67,21 +62,6 @@ Microsoft로 로그인 - - Required command '{0}' was not found on PATH or at a specified location. - PATH 또는 지정된 위치에서 필수 명령 '{0}'을(를) 찾을 수 없습니다. - - - - Required command '{0}' was not found. See installation instructions for more details. - 필수 명령 '{0}'을(를) 찾을 수 없습니다. 자세한 내용은 설치 지침을 참조하세요. - - - - {0} See installation instructions for more details. - {0} 자세한 내용은 설치 지침을 참조하세요. - - \ No newline at end of file diff --git a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.pl.xlf b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.pl.xlf index be794aa1a6e..fb268982cd3 100644 --- a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.pl.xlf +++ b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.pl.xlf @@ -52,11 +52,6 @@ Zainstalowana wersja {0} interfejsu wiersza polecenia tunelu deweloperskiego nie jest obsługiwana. Wymagana jest wersja {1} lub nowsza. - - Installation instructions - Instrukcje instalacji - - Login with GitHub Zaloguj się za pomocą usługi GitHub @@ -67,21 +62,6 @@ Zaloguj się za pomocą konta Microsoft - - Required command '{0}' was not found on PATH or at a specified location. - Nie znaleziono wymaganego polecenia „{0}” w ścieżce PATH lub w określonej lokalizacji. - - - - Required command '{0}' was not found. See installation instructions for more details. - Nie znaleziono wymaganego polecenia „{0}”. Zobacz instrukcje instalacji, aby uzyskać więcej szczegółów. - - - - {0} See installation instructions for more details. - {0} Zobacz instrukcje instalacji, aby uzyskać więcej szczegółów. - - \ No newline at end of file diff --git a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.pt-BR.xlf b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.pt-BR.xlf index 6a78af2f3f2..59763df2495 100644 --- a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.pt-BR.xlf +++ b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.pt-BR.xlf @@ -52,11 +52,6 @@ Não há suporte para a versão da CLI {0} do túnel do desenvolvedor instalada. A versão {1} ou superior é necessária. - - Installation instructions - Instruções de instalação - - Login with GitHub Fazer logon com o GitHub @@ -67,21 +62,6 @@ Fazer logon com a Microsoft - - Required command '{0}' was not found on PATH or at a specified location. - O comando necessário "{0}" não foi encontrado em PATH ou em um local especificado. - - - - Required command '{0}' was not found. See installation instructions for more details. - O comando necessário "{0}" não foi encontrado. Consulte as instruções de instalação para obter mais detalhes. - - - - {0} See installation instructions for more details. - {0} Consulte as instruções de instalação para obter mais detalhes. - - \ No newline at end of file diff --git a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.ru.xlf b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.ru.xlf index 7d52d3b4c90..53e5ebb9765 100644 --- a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.ru.xlf +++ b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.ru.xlf @@ -52,11 +52,6 @@ Установленная версия CLI devtunnel {0} не поддерживается. Требуется версия {1} или более поздняя. - - Installation instructions - Инструкции по установке - - Login with GitHub Войти с помощью GitHub @@ -67,21 +62,6 @@ Войти с помощью учетной записи Майкрософт - - Required command '{0}' was not found on PATH or at a specified location. - Требуемая команда "{0}" не найдена в PATH или в указанном расположении. - - - - Required command '{0}' was not found. See installation instructions for more details. - Обязательная команда "{0}" не найдена. Дополнительные сведения см. в инструкциях по установке. - - - - {0} See installation instructions for more details. - {0} Дополнительные сведения см. в инструкциях по установке. - - \ No newline at end of file diff --git a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.tr.xlf b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.tr.xlf index 3bc2b4293ed..89a761a9405 100644 --- a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.tr.xlf +++ b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.tr.xlf @@ -52,11 +52,6 @@ Yüklü devtunnel CLI sürümü {0} desteklenmiyor. Sürüm {1} veya üstü gereklidir. - - Installation instructions - Yükleme yönergeleri - - Login with GitHub GitHub ile oturum açma @@ -67,21 +62,6 @@ Microsoft ile oturum açın - - Required command '{0}' was not found on PATH or at a specified location. - Gerekli komut '{0}' PATH'de veya belirtilen konumda bulunamadı. - - - - Required command '{0}' was not found. See installation instructions for more details. - Gerekli komut '{0}' bulunamadı. Daha fazla bilgi için yükleme yönergelerine bakın. - - - - {0} See installation instructions for more details. - {0} Daha fazla bilgi için yükleme yönergelerine bakın. - - \ No newline at end of file diff --git a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.zh-Hans.xlf b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.zh-Hans.xlf index 1f381aa1a92..08c7915f49b 100644 --- a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.zh-Hans.xlf +++ b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.zh-Hans.xlf @@ -52,11 +52,6 @@ 安装的 devtunnel CLI 版本 {0} 不受支持。需要版本 {1} 或更高版本。 - - Installation instructions - 安装说明 - - Login with GitHub 使用 GitHub 登录 @@ -67,21 +62,6 @@ 使用 Microsoft 登录 - - Required command '{0}' was not found on PATH or at a specified location. - 在 PATH 或指定位置找不到所需命令 "{0}"。 - - - - Required command '{0}' was not found. See installation instructions for more details. - 找不到所需命令 "{0}"。有关详细信息,请参阅安装说明。 - - - - {0} See installation instructions for more details. - {0} 有关详细信息,请参阅安装说明。 - - \ No newline at end of file diff --git a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.zh-Hant.xlf b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.zh-Hant.xlf index 5e1a4f5eb99..1597635658d 100644 --- a/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.zh-Hant.xlf +++ b/src/Aspire.Hosting.DevTunnels/Resources/xlf/MessageStrings.zh-Hant.xlf @@ -52,11 +52,6 @@ 不支援已安裝的 devtunnel CLI 版本 {0}。需要版本 {1} 或更高版本。 - - Installation instructions - 安裝指示 - - Login with GitHub 使用 GitHub 登入 @@ -67,21 +62,6 @@ 使用 Microsoft 登入 - - Required command '{0}' was not found on PATH or at a specified location. - 在 PATH 或指定位置找不到必要的指令碼 '{0}'。 - - - - Required command '{0}' was not found. See installation instructions for more details. - 找不到所需的命令 '{0}'。如需詳細資料,請參閱安裝指示。 - - - - {0} See installation instructions for more details. - {0} 如需詳細資料,請參閱安裝指示。 - - \ No newline at end of file diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index 93841dc3feb..4d1bd7d52a6 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -278,6 +278,7 @@ public static IResourceBuilder AddNodeApp(this IDistributedAppl private static IResourceBuilder WithNodeDefaults(this IResourceBuilder builder) where TResource : JavaScriptAppResource => builder.WithOtlpExporter() + .WithRequiredCommand("node", "https://nodejs.org/en/download/") .WithEnvironment("NODE_ENV", builder.ApplicationBuilder.Environment.IsDevelopment() ? "development" : "production") .WithCertificateTrustConfiguration((ctx) => { @@ -710,7 +711,8 @@ public static IResourceBuilder WithNpm(this IResourceBuild { PackageFilesPatterns = { new CopyFilePattern("package*.json", "./") } }) - .WithAnnotation(new JavaScriptInstallCommandAnnotation([installCommand, .. installArgs ?? []])); + .WithAnnotation(new JavaScriptInstallCommandAnnotation([installCommand, .. installArgs ?? []])) + .WithRequiredCommand("npm", "https://docs.npmjs.com/downloading-and-installing-node-js-and-npm"); AddInstaller(resource, install); return resource; @@ -768,7 +770,8 @@ public static IResourceBuilder WithBun(this IResourceBuild // bun supports passing script flags without the `--` separator. CommandSeparator = null, }) - .WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])); + .WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])) + .WithRequiredCommand("bun", "https://bun.sh/docs/installation"); if (!resource.Resource.TryGetLastAnnotation(out _)) { @@ -841,7 +844,8 @@ public static IResourceBuilder WithYarn(this IResourceBuil resource .WithAnnotation(packageManager) - .WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])); + .WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])) + .WithRequiredCommand("yarn", "https://yarnpkg.com/getting-started/install"); AddInstaller(resource, install); return resource; @@ -900,7 +904,8 @@ public static IResourceBuilder WithPnpm(this IResourceBuil // pnpm is not included in the Node.js Docker image by default, so we need to enable it via corepack InitializeDockerBuildStage = stage => stage.Run("corepack enable pnpm") }) - .WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])); + .WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])) + .WithRequiredCommand("pnpm", "https://pnpm.io/installation"); AddInstaller(resource, install); return resource; diff --git a/src/Aspire.Hosting.Python/Aspire.Hosting.Python.csproj b/src/Aspire.Hosting.Python/Aspire.Hosting.Python.csproj index 25d41fd09b6..25d5b07fa13 100644 --- a/src/Aspire.Hosting.Python/Aspire.Hosting.Python.csproj +++ b/src/Aspire.Hosting.Python/Aspire.Hosting.Python.csproj @@ -8,10 +8,7 @@ - - - diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index 8fd3a7527c3..005dd123309 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -8,7 +8,6 @@ using Aspire.Hosting.Pipelines; using Aspire.Hosting.Python; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; #pragma warning disable ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -365,8 +364,6 @@ private static IResourceBuilder AddPythonAppCore( ArgumentException.ThrowIfNullOrEmpty(entrypoint); ArgumentNullException.ThrowIfNull(virtualEnvironmentPath); - // Register Python environment validation services (once per builder) - builder.Services.TryAddSingleton(); // When using the default virtual environment path, look for existing virtual environments // in multiple locations: app directory first, then AppHost directory as fallback var resolvedVenvPath = virtualEnvironmentPath; @@ -1258,9 +1255,6 @@ public static IResourceBuilder WithUv(this IResourceBuilder builder, bo { ArgumentNullException.ThrowIfNull(builder); - // Register UV validation service - builder.ApplicationBuilder.Services.TryAddSingleton(); - // Default args: sync only (uv will auto-detect Python and dependencies from pyproject.toml) args ??= ["sync"]; @@ -1352,20 +1346,6 @@ private static void AddInstaller(IResourceBuilder builder, bool install) w installerBuilder.WithExplicitStart(); } - // Add validation for the installer command (uv or python) - installerBuilder.OnBeforeResourceStarted(static async (installerResource, e, ct) => - { - // Check which command this installer is using (set by BeforeStartEvent) - if (installerResource.TryGetLastAnnotation(out var executable) && - executable.Command == "uv") - { - // Validate that uv is installed - don't throw so the app fails as it normally would - var uvInstallationManager = e.Services.GetRequiredService(); - await uvInstallationManager.EnsureInstalledAsync(throwOnFailure: false, ct).ConfigureAwait(false); - } - // For other package managers (pip, etc.), Python validation happens via PythonVenvCreatorResource - }); - builder.ApplicationBuilder.Eventing.Subscribe((_, _) => { // Set the installer's working directory to match the resource's working directory @@ -1383,6 +1363,13 @@ private static void AddInstaller(IResourceBuilder builder, bool install) w .WithWorkingDirectory(builder.Resource.WorkingDirectory) .WithArgs(installCommand.Args); + // Add required command validation based on the package manager + if (packageManager.ExecutableName == "uv") + { + installerBuilder.WithRequiredCommand("uv", "https://docs.astral.sh/uv/getting-started/installation/"); + } + // For other package managers (pip, etc.), Python validation happens via PythonVenvCreatorResource + return Task.CompletedTask; }); @@ -1440,12 +1427,7 @@ private static void CreateVenvCreatorIfNeeded(IResourceBuilder builder) wh .WithWorkingDirectory(builder.Resource.WorkingDirectory) .WithParentRelationship(builder.Resource) .ExcludeFromManifest() - .OnBeforeResourceStarted(static async (venvCreatorResource, e, ct) => - { - // Validate that Python is installed before creating venv - don't throw so the app fails as it normally would - var pythonInstallationManager = e.Services.GetRequiredService(); - await pythonInstallationManager.EnsureInstalledAsync(throwOnFailure: false, ct).ConfigureAwait(false); - }); + .WithRequiredCommand(pythonCommand, "https://www.python.org/downloads/"); // Wait relationships will be set up dynamically in SetupDependencies } diff --git a/src/Aspire.Hosting.Python/PythonInstallationManager.cs b/src/Aspire.Hosting.Python/PythonInstallationManager.cs deleted file mode 100644 index accb0e3f9e8..00000000000 --- a/src/Aspire.Hosting.Python/PythonInstallationManager.cs +++ /dev/null @@ -1,71 +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 Aspire.Hosting.Utils; -using Microsoft.Extensions.Logging; - -namespace Aspire.Hosting.Python; - -/// -/// Validates that the Python executable is available on the system. -/// -#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -internal sealed class PythonInstallationManager : RequiredCommandValidator -{ - private string? _resolvedCommandPath; - - public PythonInstallationManager( - IInteractionService interactionService, - ILogger logger) - : base(interactionService, logger) - { - } - - /// - /// Ensures Python is installed/available. This method is safe for concurrent callers; - /// only one validation will run at a time. - /// - /// Whether to throw an exception if Python is not found. Default is true. - /// Cancellation token. - public Task EnsureInstalledAsync(bool throwOnFailure = true, CancellationToken cancellationToken = default) - { - SetThrowOnFailure(throwOnFailure); - return RunAsync(cancellationToken); - } - - protected override string GetCommandPath() - { - // Try common Python command names based on platform - // On Windows: python, py - // On Linux/macOS: python3, python - if (OperatingSystem.IsWindows()) - { - // Try 'python' first, then 'py' (Python launcher) - var pythonPath = ResolveCommand("python"); - if (pythonPath is not null) - { - return "python"; - } - return "py"; - } - else - { - // Try 'python3' first (more specific), then 'python' - var python3Path = ResolveCommand("python3"); - if (python3Path is not null) - { - return "python3"; - } - return "python"; - } - } - - protected override Task OnValidatedAsync(string resolvedCommandPath, CancellationToken cancellationToken) - { - _resolvedCommandPath = resolvedCommandPath; - return Task.CompletedTask; - } - - protected override string? GetHelpLink() => "https://www.python.org/downloads/"; -} -#pragma warning restore ASPIREINTERACTION001 diff --git a/src/Aspire.Hosting.Python/UvInstallationManager.cs b/src/Aspire.Hosting.Python/UvInstallationManager.cs deleted file mode 100644 index 322a966b516..00000000000 --- a/src/Aspire.Hosting.Python/UvInstallationManager.cs +++ /dev/null @@ -1,46 +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 Aspire.Hosting.Utils; -using Microsoft.Extensions.Logging; - -namespace Aspire.Hosting.Python; - -/// -/// Validates that the uv command is available on the system. -/// -#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -internal sealed class UvInstallationManager : RequiredCommandValidator -{ - private string? _resolvedCommandPath; - - public UvInstallationManager( - IInteractionService interactionService, - ILogger logger) - : base(interactionService, logger) - { - } - - /// - /// Ensures uv is installed/available. This method is safe for concurrent callers; - /// only one validation will run at a time. - /// - /// Whether to throw an exception if uv is not found. Default is true. - /// Cancellation token. - public Task EnsureInstalledAsync(bool throwOnFailure = true, CancellationToken cancellationToken = default) - { - SetThrowOnFailure(throwOnFailure); - return RunAsync(cancellationToken); - } - - protected override string GetCommandPath() => "uv"; - - protected override Task OnValidatedAsync(string resolvedCommandPath, CancellationToken cancellationToken) - { - _resolvedCommandPath = resolvedCommandPath; - return Task.CompletedTask; - } - - protected override string? GetHelpLink() => "https://docs.astral.sh/uv/getting-started/installation/"; -} -#pragma warning restore ASPIREINTERACTION001 diff --git a/src/Aspire.Hosting/ApplicationModel/IRequiredCommandValidator.cs b/src/Aspire.Hosting/ApplicationModel/IRequiredCommandValidator.cs new file mode 100644 index 00000000000..2f31f9f9b57 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/IRequiredCommandValidator.cs @@ -0,0 +1,31 @@ +// 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; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A service that validates required commands/executables are available on the local machine. +/// +/// +/// This service coalesces validations so that the same command is only validated once, +/// even if multiple resources require it. +/// +[Experimental("ASPIRECOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public interface IRequiredCommandValidator +{ + /// + /// Validates that a required command is available and meets any custom validation requirements. + /// + /// The resource that requires the command. + /// The annotation describing the required command. + /// A cancellation token. + /// A indicating whether validation succeeded. + /// + /// Validations are coalesced per command. If the same command has already been validated, + /// the cached result is used. If validation fails, a warning is logged but the resource + /// is allowed to attempt to start. + /// + Task ValidateAsync(IResource resource, RequiredCommandAnnotation annotation, CancellationToken cancellationToken); +} diff --git a/src/Aspire.Hosting/ApplicationModel/RequiredCommandAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/RequiredCommandAnnotation.cs new file mode 100644 index 00000000000..27ca8f8104f --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/RequiredCommandAnnotation.cs @@ -0,0 +1,35 @@ +// 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.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// An annotation which declares that a resource requires a specific command/executable to be available on the local machine PATH before it can start. +/// +/// The command string (file name or path) that should be validated. +[DebuggerDisplay("Type = {GetType().Name,nq}, Command = {Command}")] +[Experimental("ASPIRECOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public class RequiredCommandAnnotation(string command) : IResourceAnnotation +{ + /// + /// Gets the command string (file name or path) that should be validated. + /// + public string Command { get; } = command ?? throw new ArgumentNullException(nameof(command)); + + /// + /// Gets or sets an optional help link URL to guide users when the command is missing. + /// + public string? HelpLink { get; init; } + + /// + /// Gets or sets an optional custom validation callback that will be invoked after the command has been resolved. + /// + /// + /// The callback receives a containing the resolved path and service provider. + /// It should return a indicating whether the command is valid. + /// + public Func>? ValidationCallback { get; init; } +} diff --git a/src/Aspire.Hosting/ApplicationModel/RequiredCommandValidationContext.cs b/src/Aspire.Hosting/ApplicationModel/RequiredCommandValidationContext.cs new file mode 100644 index 00000000000..7896004d79a --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/RequiredCommandValidationContext.cs @@ -0,0 +1,31 @@ +// 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; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Provides context for validating a required command. +/// +/// The resolved full path to the command executable. +/// The service provider for accessing application services. +/// A cancellation token that can be used to cancel the validation. +[Experimental("ASPIRECOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class RequiredCommandValidationContext(string resolvedPath, IServiceProvider services, CancellationToken cancellationToken) +{ + /// + /// Gets the resolved full path to the command executable. + /// + public string ResolvedPath { get; } = resolvedPath ?? throw new ArgumentNullException(nameof(resolvedPath)); + + /// + /// Gets the service provider for accessing application services. + /// + public IServiceProvider Services { get; } = services ?? throw new ArgumentNullException(nameof(services)); + + /// + /// Gets a cancellation token that can be used to cancel the validation. + /// + public CancellationToken CancellationToken { get; } = cancellationToken; +} diff --git a/src/Aspire.Hosting/ApplicationModel/RequiredCommandValidationResult.cs b/src/Aspire.Hosting/ApplicationModel/RequiredCommandValidationResult.cs new file mode 100644 index 00000000000..4943dd8f7cd --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/RequiredCommandValidationResult.cs @@ -0,0 +1,48 @@ +// 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; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents the result of validating a required command. +/// +[Experimental("ASPIRECOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public sealed class RequiredCommandValidationResult +{ + private RequiredCommandValidationResult(bool isValid, string? validationMessage) + { + if (!isValid && validationMessage is null) + { + throw new ArgumentException("A validation message must be provided for a failed validation.", nameof(validationMessage)); + } + + IsValid = isValid; + ValidationMessage = validationMessage; + } + + /// + /// Gets a value indicating whether the command validation succeeded. + /// + [MemberNotNullWhen(false, nameof(ValidationMessage))] + public bool IsValid { get; } + + /// + /// Gets an optional validation message describing why validation failed. + /// + public string? ValidationMessage { get; } + + /// + /// Creates a successful validation result. + /// + /// A successful validation result. + public static RequiredCommandValidationResult Success() => new(true, null); + + /// + /// Creates a failed validation result with the specified message. + /// + /// A message describing why validation failed. + /// A failed validation result. + public static RequiredCommandValidationResult Failure(string message) => new(false, message); +} diff --git a/src/Aspire.Hosting/ApplicationModel/RequiredCommandValidator.cs b/src/Aspire.Hosting/ApplicationModel/RequiredCommandValidator.cs new file mode 100644 index 00000000000..f0e53e69b38 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/RequiredCommandValidator.cs @@ -0,0 +1,201 @@ +// 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 ASPIREINTERACTION001 +#pragma warning disable ASPIRECOMMAND001 + +using System.Collections.Concurrent; +using System.Globalization; +using Aspire.Hosting.Resources; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Default implementation of that validates commands +/// are available on the local machine PATH and coalesces validations per command. +/// +internal sealed class RequiredCommandValidator : IRequiredCommandValidator, IDisposable +{ + private readonly IServiceProvider _serviceProvider; + private readonly IInteractionService _interactionService; + private readonly ILogger _logger; + + // Track validation state per command to coalesce notifications + private readonly ConcurrentDictionary _commandStates = new(StringComparer.OrdinalIgnoreCase); + + public RequiredCommandValidator( + IServiceProvider serviceProvider, + IInteractionService interactionService, + ILogger logger) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _interactionService = interactionService ?? throw new ArgumentNullException(nameof(interactionService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Disposes the command validation states, releasing their semaphores. + /// + public void Dispose() + { + foreach (var state in _commandStates.Values) + { + state.Dispose(); + } + _commandStates.Clear(); + } + + /// + public async Task ValidateAsync( + IResource resource, + RequiredCommandAnnotation annotation, + CancellationToken cancellationToken) + { + var command = annotation.Command; + + if (string.IsNullOrWhiteSpace(command)) + { + throw new InvalidOperationException($"Required command on resource '{resource.Name}' cannot be null or empty."); + } + + // Get or create state for this command + var state = _commandStates.GetOrAdd(command, _ => new CommandValidationState()); + + await state.Gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // If validation already failed for this command, just log and return the cached failure + if (state.ErrorMessage is not null) + { + _logger.LogWarning("Resource '{ResourceName}' may fail to start: {Message}", resource.Name, state.ErrorMessage); + return RequiredCommandValidationResult.Failure(state.ErrorMessage); + } + + // Check if already validated successfully + if (state.ResolvedPath is not null) + { + _logger.LogDebug("Required command '{Command}' for resource '{ResourceName}' already validated, resolved to '{ResolvedPath}'.", command, resource.Name, state.ResolvedPath); + return RequiredCommandValidationResult.Success(); + } + + // Perform validation + var resolved = ResolveCommand(command); + var isValid = true; + string? validationMessage = null; + + if (resolved is not null && annotation.ValidationCallback is not null) + { + var context = new RequiredCommandValidationContext(resolved, _serviceProvider, cancellationToken); + var result = await annotation.ValidationCallback(context).ConfigureAwait(false); + isValid = result.IsValid; + validationMessage = result.ValidationMessage; + } + + if (resolved is null || !isValid) + { + var link = annotation.HelpLink; + + // Build the message for logging and exceptions (includes inline link if available) + var message = (link, validationMessage) switch + { + (null, not null) => validationMessage, + (not null, not null) => string.Format(CultureInfo.CurrentCulture, MessageStrings.RequiredCommandValidationFailedWithLink, command, validationMessage, link), + (not null, null) => string.Format(CultureInfo.CurrentCulture, MessageStrings.RequiredCommandNotFoundWithLink, command, link), + _ => string.Format(CultureInfo.CurrentCulture, MessageStrings.RequiredCommandNotFound, command) + }; + + // Build a simpler message for notifications (link is provided separately via options) + var notificationMessage = (link, validationMessage) switch + { + (null, not null) => validationMessage, + (not null, not null) => string.Format(CultureInfo.CurrentCulture, MessageStrings.RequiredCommandValidationFailed, command, validationMessage), + (not null, null) => string.Format(CultureInfo.CurrentCulture, MessageStrings.RequiredCommandNotFound, command), + _ => string.Format(CultureInfo.CurrentCulture, MessageStrings.RequiredCommandNotFound, command) + }; + + state.ErrorMessage = message; + _logger.LogWarning("{Message}", string.Format(CultureInfo.CurrentCulture, MessageStrings.ResourceMayFailToStart, resource.Name, message)); + + // Show notification using interaction service if available (only once per command) + // Fire-and-forget is intentional - we don't want to block resource startup on notification display + if (_interactionService.IsAvailable) + { + var options = new NotificationInteractionOptions + { + Intent = MessageIntent.Warning, + // Provide a link only if we have one. + LinkText = link is null ? null : MessageStrings.InstallationInstructions, + LinkUrl = link, + ShowDismiss = true, + ShowSecondaryButton = false + }; + + _ = _interactionService.PromptNotificationAsync( + title: MessageStrings.MissingCommandNotificationTitle, + message: notificationMessage, + options, + cancellationToken); + } + + // Return failure but don't throw - allow the resource to attempt to start + return RequiredCommandValidationResult.Failure(message); + } + + // Cache successful resolution + state.ResolvedPath = resolved; + _logger.LogDebug("Required command '{Command}' for resource '{ResourceName}' resolved to '{ResolvedPath}'.", command, resource.Name, resolved); + return RequiredCommandValidationResult.Success(); + } + finally + { + state.Gate.Release(); + } + } + + /// + /// Tracks validation state for a single command to enable coalescing of notifications. + /// + private sealed class CommandValidationState : IDisposable + { + /// + /// Synchronization gate to ensure only one validation runs at a time per command. + /// + public SemaphoreSlim Gate { get; } = new(1, 1); + + /// + /// The error message if validation failed. + /// + public string? ErrorMessage { get; set; } + + /// + /// The resolved path if validation succeeded. + /// + public string? ResolvedPath { get; set; } + + /// + /// Disposes the semaphore. + /// + public void Dispose() => Gate.Dispose(); + } + + /// + /// Attempts to resolve a command (file name or path) to a full path. + /// + /// The command string. + /// Full path if resolved; otherwise null. + private static string? ResolveCommand(string command) + { + // If the command includes any directory separator, treat it as a path (relative or absolute) + if (command.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]) >= 0) + { + var candidate = Path.GetFullPath(command); + return File.Exists(candidate) ? candidate : null; + } + + // Search PATH using the shared helper + return PathLookupHelper.FindFullPathFromPath(command); + } +} + +#pragma warning restore ASPIREINTERACTION001 diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index c23da5425a8..85c736e02de 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -43,6 +43,7 @@ + diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index b6a9bd5210a..6e1b56b6b90 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -470,6 +470,12 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, ConfigureSshRemoteOptions>()); _innerBuilder.Services.AddSingleton(); _innerBuilder.Services.TryAddEventingSubscriber(); + + // Required command validation for resources +#pragma warning disable ASPIRECOMMAND001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + _innerBuilder.Services.TryAddSingleton(); +#pragma warning restore ASPIRECOMMAND001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + _innerBuilder.Services.TryAddEventingSubscriber(); } if (ExecutionContext.IsRunMode) diff --git a/src/Aspire.Hosting/Lifecycle/RequiredCommandValidationLifecycleHook.cs b/src/Aspire.Hosting/Lifecycle/RequiredCommandValidationLifecycleHook.cs new file mode 100644 index 00000000000..936620bc313 --- /dev/null +++ b/src/Aspire.Hosting/Lifecycle/RequiredCommandValidationLifecycleHook.cs @@ -0,0 +1,48 @@ +// 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 ASPIRECOMMAND001 + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Eventing; + +namespace Aspire.Hosting.Lifecycle; + +/// +/// An eventing subscriber that validates required commands are installed before resources start. +/// +/// +/// This subscriber processes on resources and delegates +/// validation to . +/// +internal sealed class RequiredCommandValidationLifecycleHook( + IRequiredCommandValidator validator) : IDistributedApplicationEventingSubscriber +{ + private readonly IRequiredCommandValidator _validator = validator ?? throw new ArgumentNullException(nameof(validator)); + + /// + public Task SubscribeAsync(IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) + { + // Subscribe to BeforeResourceStartedEvent to validate commands before each resource starts + eventing.Subscribe(ValidateRequiredCommandsAsync); + return Task.CompletedTask; + } + + private async Task ValidateRequiredCommandsAsync(BeforeResourceStartedEvent @event, CancellationToken cancellationToken) + { + var resource = @event.Resource; + + // Get all RequiredCommandAnnotation instances on the resource + var requiredCommands = resource.Annotations.OfType().ToList(); + + if (requiredCommands.Count == 0) + { + return; + } + + foreach (var annotation in requiredCommands) + { + await _validator.ValidateAsync(resource, annotation, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/Aspire.Hosting/RequiredCommandResourceExtensions.cs b/src/Aspire.Hosting/RequiredCommandResourceExtensions.cs new file mode 100644 index 00000000000..6b47814ad68 --- /dev/null +++ b/src/Aspire.Hosting/RequiredCommandResourceExtensions.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.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding required command annotations to resources. +/// +public static class RequiredCommandResourceExtensions +{ + /// + /// Declares that a resource requires a specific command/executable to be available on the local machine PATH before it can start. + /// + /// The resource type. + /// The resource builder. + /// The command string (file name or path) that should be validated. + /// An optional help link URL to guide users when the command is missing. + /// The resource builder. + /// + /// The command is considered valid if either: + /// 1. It is an absolute or relative path (contains a directory separator) that points to an existing file, or + /// 2. It is discoverable on the current process PATH (respecting PATHEXT on Windows). + /// If the command is not found, a warning message will be logged but the resource will be allowed to attempt to start. + /// + public static IResourceBuilder WithRequiredCommand( + this IResourceBuilder builder, + string command, + string? helpLink = null) where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(command); + +#pragma warning disable ASPIRECOMMAND001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + builder.WithAnnotation(new RequiredCommandAnnotation(command) + { + HelpLink = helpLink + }); +#pragma warning restore ASPIRECOMMAND001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + return builder; + } + + /// + /// Declares that a resource requires a specific command/executable to be available on the local machine PATH before it can start, + /// with custom validation logic. + /// + /// The resource type. + /// The resource builder. + /// The command string (file name or path) that should be validated. + /// A callback that validates the resolved command path. Receives a and returns a . + /// An optional help link URL to guide users when the command is missing or fails validation. + /// The resource builder. + /// + /// The command is first resolved to a full path. If found, the validation callback is invoked with the context containing the resolved path and service provider. + /// The callback should return a indicating whether the command is valid. + /// If the command is not found or fails validation, a warning message will be logged but the resource will be allowed to attempt to start. + /// + [Experimental("ASPIRECOMMAND001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithRequiredCommand( + this IResourceBuilder builder, + string command, + Func> validationCallback, + string? helpLink = null) where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(validationCallback); + + builder.WithAnnotation(new RequiredCommandAnnotation(command) + { + ValidationCallback = validationCallback, + HelpLink = helpLink + }); + + return builder; + } +} diff --git a/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs b/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs index 52c7c83b1fc..3d907c91785 100644 --- a/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs +++ b/src/Aspire.Hosting/Resources/MessageStrings.Designer.cs @@ -104,5 +104,68 @@ internal static string DcpVersionCheckTooLowMessage { return ResourceManager.GetString("DcpVersionCheckTooLowMessage", resourceCulture); } } + + /// + /// Looks up a localized string similar to Installation instructions. + /// + internal static string InstallationInstructions { + get { + return ResourceManager.GetString("InstallationInstructions", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing command. + /// + internal static string MissingCommandNotificationTitle { + get { + return ResourceManager.GetString("MissingCommandNotificationTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Required command '{0}' was not found on PATH or at the specified location.. + /// + internal static string RequiredCommandNotFound { + get { + return ResourceManager.GetString("RequiredCommandNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1}. + /// + internal static string RequiredCommandNotFoundWithLink { + get { + return ResourceManager.GetString("RequiredCommandNotFoundWithLink", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Command '{0}' validation failed: {1}. + /// + internal static string RequiredCommandValidationFailed { + get { + return ResourceManager.GetString("RequiredCommandValidationFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Command '{0}' validation failed: {1}. For installation instructions, see: {2}. + /// + internal static string RequiredCommandValidationFailedWithLink { + get { + return ResourceManager.GetString("RequiredCommandValidationFailedWithLink", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Resource '{0}' may fail to start: {1}. + /// + internal static string ResourceMayFailToStart { + get { + return ResourceManager.GetString("ResourceMayFailToStart", resourceCulture); + } + } } } diff --git a/src/Aspire.Hosting/Resources/MessageStrings.resx b/src/Aspire.Hosting/Resources/MessageStrings.resx index af340e1bc33..a5789a05605 100644 --- a/src/Aspire.Hosting/Resources/MessageStrings.resx +++ b/src/Aspire.Hosting/Resources/MessageStrings.resx @@ -132,4 +132,25 @@ Newer version of the Aspire.Hosting.AppHost package is required to run the application. Ensure you are referencing at least version '{0}'. + + Installation instructions + + + Missing command + + + Required command '{0}' was not found on PATH or at the specified location. + + + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + + + Command '{0}' validation failed: {1} + + + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + + + Resource '{0}' may fail to start: {1} + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf index 61c047bbfa5..4e6e9517f23 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf @@ -27,6 +27,41 @@ Ke spuštění aplikace se vyžaduje novější verze balíčku Aspire.Hosting.AppHost. Ujistěte se, že odkazujete alespoň na verzi {0}. + + Installation instructions + Installation instructions + + + + Missing command + Missing command + + + + Required command '{0}' was not found on PATH or at the specified location. + Required command '{0}' was not found on PATH or at the specified location. + + + + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + + + + Command '{0}' validation failed: {1} + Command '{0}' validation failed: {1} + + + + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + + + + Resource '{0}' may fail to start: {1} + Resource '{0}' may fail to start: {1} + + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf index d62f51756ce..e81e5a146bf 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf @@ -27,6 +27,41 @@ Zum Ausführen der Anwendung ist eine neuere Version des Pakets „Aspire.Hosting.AppHost“ erforderlich. Stellen Sie sicher, dass Sie auf mindestens die Version „{0}“ verweisen. + + Installation instructions + Installation instructions + + + + Missing command + Missing command + + + + Required command '{0}' was not found on PATH or at the specified location. + Required command '{0}' was not found on PATH or at the specified location. + + + + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + + + + Command '{0}' validation failed: {1} + Command '{0}' validation failed: {1} + + + + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + + + + Resource '{0}' may fail to start: {1} + Resource '{0}' may fail to start: {1} + + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf index 1a1982b2b52..366bb04f918 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf @@ -27,6 +27,41 @@ Se requiere una versión más reciente del paquete Deshoja.Hosting.AppHost para ejecutar la aplicación. Asegúrese de que hace referencia al menos a la versión "{0}". + + Installation instructions + Installation instructions + + + + Missing command + Missing command + + + + Required command '{0}' was not found on PATH or at the specified location. + Required command '{0}' was not found on PATH or at the specified location. + + + + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + + + + Command '{0}' validation failed: {1} + Command '{0}' validation failed: {1} + + + + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + + + + Resource '{0}' may fail to start: {1} + Resource '{0}' may fail to start: {1} + + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf index 05e0d2c22af..c945e5dd9d3 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf @@ -27,6 +27,41 @@ Une version plus récente du package Aspire.Hosting.AppHost est nécessaire pour exécuter l’application. Vérifiez que vous référencez au moins la version « {0} ». + + Installation instructions + Installation instructions + + + + Missing command + Missing command + + + + Required command '{0}' was not found on PATH or at the specified location. + Required command '{0}' was not found on PATH or at the specified location. + + + + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + + + + Command '{0}' validation failed: {1} + Command '{0}' validation failed: {1} + + + + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + + + + Resource '{0}' may fail to start: {1} + Resource '{0}' may fail to start: {1} + + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf index 9c6b3cac400..3d1879888d9 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf @@ -27,6 +27,41 @@ Per eseguire l'applicazione, è necessaria una versione più recente del pacchetto Aspire.Hosting.AppHost. Assicurati di fare riferimento almeno alla versione '{0}'. + + Installation instructions + Installation instructions + + + + Missing command + Missing command + + + + Required command '{0}' was not found on PATH or at the specified location. + Required command '{0}' was not found on PATH or at the specified location. + + + + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + + + + Command '{0}' validation failed: {1} + Command '{0}' validation failed: {1} + + + + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + + + + Resource '{0}' may fail to start: {1} + Resource '{0}' may fail to start: {1} + + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf index 226b25fa3db..66d340fe621 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf @@ -27,6 +27,41 @@ アプリケーションを実行するには、Aspire.Hosting.AppHost パッケージの新しいバージョンが必要です。少なくともバージョン '{0}' を参照していることを確認してください。 + + Installation instructions + Installation instructions + + + + Missing command + Missing command + + + + Required command '{0}' was not found on PATH or at the specified location. + Required command '{0}' was not found on PATH or at the specified location. + + + + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + + + + Command '{0}' validation failed: {1} + Command '{0}' validation failed: {1} + + + + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + + + + Resource '{0}' may fail to start: {1} + Resource '{0}' may fail to start: {1} + + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf index 30da21531e7..2e846c68413 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf @@ -27,6 +27,41 @@ 애플리케이션을 실행하려면 최신 버전의 Aspire.Hosting.AppHost 패키지가 필요합니다. 최소한 '{0}' 이상의 버전을 참조하고 있는지 확인하세요. + + Installation instructions + Installation instructions + + + + Missing command + Missing command + + + + Required command '{0}' was not found on PATH or at the specified location. + Required command '{0}' was not found on PATH or at the specified location. + + + + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + + + + Command '{0}' validation failed: {1} + Command '{0}' validation failed: {1} + + + + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + + + + Resource '{0}' may fail to start: {1} + Resource '{0}' may fail to start: {1} + + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf index 776212dc463..b407df6cc83 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf @@ -27,6 +27,41 @@ Do uruchomienia aplikacji jest wymagana nowsza wersja pakietu Aspire.Hosting.AppHost. Upewnij się, że odwołujesz się do co najmniej wersji „{0}”. + + Installation instructions + Installation instructions + + + + Missing command + Missing command + + + + Required command '{0}' was not found on PATH or at the specified location. + Required command '{0}' was not found on PATH or at the specified location. + + + + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + + + + Command '{0}' validation failed: {1} + Command '{0}' validation failed: {1} + + + + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + + + + Resource '{0}' may fail to start: {1} + Resource '{0}' may fail to start: {1} + + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf index b0b9e89f76c..430559d22d4 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf @@ -27,6 +27,41 @@ É necessária uma versão mais recente do pacote Aspire.Hosting.AppHost para executar o aplicativo. Verifique se você está usando pelo menos a versão '{0}'. + + Installation instructions + Installation instructions + + + + Missing command + Missing command + + + + Required command '{0}' was not found on PATH or at the specified location. + Required command '{0}' was not found on PATH or at the specified location. + + + + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + + + + Command '{0}' validation failed: {1} + Command '{0}' validation failed: {1} + + + + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + + + + Resource '{0}' may fail to start: {1} + Resource '{0}' may fail to start: {1} + + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf index 4a3c02aa905..ea5f9581407 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf @@ -27,6 +27,41 @@ Для запуска приложения требуется более новая версия пакета Aspire.Hosting.AppHost. Убедитесь, что вы ссылаетесь по крайней мере на версию "{0}". + + Installation instructions + Installation instructions + + + + Missing command + Missing command + + + + Required command '{0}' was not found on PATH or at the specified location. + Required command '{0}' was not found on PATH or at the specified location. + + + + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + + + + Command '{0}' validation failed: {1} + Command '{0}' validation failed: {1} + + + + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + + + + Resource '{0}' may fail to start: {1} + Resource '{0}' may fail to start: {1} + + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf index 8059bdf4b00..81b9f43c7c6 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf @@ -27,6 +27,41 @@ Uygulamayı çalıştırmak için Aspire.Hosting.AppHost paketinin daha yeni bir sürümü gereklidir. En az '{0}' sürümüne referans verdiğinizden emin olun. + + Installation instructions + Installation instructions + + + + Missing command + Missing command + + + + Required command '{0}' was not found on PATH or at the specified location. + Required command '{0}' was not found on PATH or at the specified location. + + + + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + + + + Command '{0}' validation failed: {1} + Command '{0}' validation failed: {1} + + + + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + + + + Resource '{0}' may fail to start: {1} + Resource '{0}' may fail to start: {1} + + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf index d685a7460a5..6d73b8d7122 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf @@ -27,6 +27,41 @@ 运行应用程序需要更新版本的 Aspire.Hosting.AppHost 包。请确保至少引用版本“{0}”。 + + Installation instructions + Installation instructions + + + + Missing command + Missing command + + + + Required command '{0}' was not found on PATH or at the specified location. + Required command '{0}' was not found on PATH or at the specified location. + + + + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + + + + Command '{0}' validation failed: {1} + Command '{0}' validation failed: {1} + + + + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + + + + Resource '{0}' may fail to start: {1} + Resource '{0}' may fail to start: {1} + + \ No newline at end of file diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf index 95057d2b15f..6210d79b2fc 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf @@ -27,6 +27,41 @@ 執行應用程式需要較新版本的 Aspire.Hosting.AppHost 套件。請確保您參考的版本至少為 '{0}'。 + + Installation instructions + Installation instructions + + + + Missing command + Missing command + + + + Required command '{0}' was not found on PATH or at the specified location. + Required command '{0}' was not found on PATH or at the specified location. + + + + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + + + + Command '{0}' validation failed: {1} + Command '{0}' validation failed: {1} + + + + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Command '{0}' validation failed: {1}. For installation instructions, see: {2} + + + + Resource '{0}' may fail to start: {1} + Resource '{0}' may fail to start: {1} + + \ No newline at end of file diff --git a/src/Shared/RequiredCommandValidator.cs b/src/Shared/RequiredCommandValidator.cs deleted file mode 100644 index fd85ad608f5..00000000000 --- a/src/Shared/RequiredCommandValidator.cs +++ /dev/null @@ -1,191 +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.Globalization; -using Microsoft.Extensions.Logging; - -namespace Aspire.Hosting.Utils; - -/// -/// Base class that extends with validation logic -/// ensuring that a command (executable) path supplied by an implementation is valid -/// for launching a new process. The command is considered valid if either: -/// 1. It is an absolute or relative path (contains a directory separator) that points to an existing file, or -/// 2. It is discoverable on the current process PATH (respecting PATHEXT on Windows). -/// -/// Once validated, the resolved full path is passed to for -/// any additional (optional) work by derived classes. -/// -/// Use the inherited RunAsync method to coalesce concurrent validation requests. -/// -// Suppress experimental interaction API warnings locally. -#pragma warning disable ASPIREINTERACTION001 -internal abstract class RequiredCommandValidator(IInteractionService interactionService, ILogger logger) : CoalescingAsyncOperation -{ - private readonly IInteractionService _interactionService = interactionService; - private readonly ILogger _logger = logger; - - private Task? _notificationTask; - private string? _notificationMessage; - private bool _throwOnFailure = true; - - /// - /// Returns the command string (file name or path) that should be validated. - /// - protected abstract string GetCommandPath(); - - /// - /// Gets the message to display when the command is not found and there is no help link. - /// Default: "Required command '{0}' was not found on PATH or at a specified location." - /// - /// The command name. - /// The formatted message. - protected virtual string GetCommandNotFoundMessage(string command) => - string.Format(CultureInfo.CurrentCulture, "Required command '{0}' was not found on PATH or at a specified location.", command); - - /// - /// Gets the message to display when the command is not found and there is a help link. - /// Default: "Required command '{0}' was not found. See installation instructions for more details." - /// - /// The command name. - /// The formatted message. - protected virtual string GetCommandNotFoundWithLinkMessage(string command) => - string.Format(CultureInfo.CurrentCulture, "Required command '{0}' was not found. See installation instructions for more details.", command); - - /// - /// Gets the message to display when the command is found but validation failed. - /// Default: "{0} See installation instructions for more details." - /// - /// The validation failure message. - /// The formatted message. - protected virtual string GetValidationFailedMessage(string validationMessage) => - string.Format(CultureInfo.CurrentCulture, "{0} See installation instructions for more details.", validationMessage); - - /// - /// Called after the command has been successfully resolved to a full path. - /// Default implementation does nothing. - /// - /// - /// Overrides can perform additional validation to verify the command is usable. - /// - protected internal virtual Task<(bool IsValid, string? ValidationMessage)> OnResolvedAsync(string resolvedCommandPath, CancellationToken cancellationToken) => Task.FromResult((true, (string?)null)); - - /// - /// Called after the command has been successfully validated and resolved to a full path. - /// Default implementation does nothing. - /// - /// The resolved full filesystem path to the executable. - /// Cancellation token. - protected virtual Task OnValidatedAsync(string resolvedCommandPath, CancellationToken cancellationToken) => Task.CompletedTask; - - /// - protected sealed override async Task ExecuteCoreAsync(CancellationToken cancellationToken) - { - var command = GetCommandPath(); - - var notificationTask = _notificationTask; - if (notificationTask is { IsCompleted: false }) - { - // Failure notification is still being shown so just throw again if configured to throw. - if (_throwOnFailure) - { - throw new DistributedApplicationException(_notificationMessage ?? $"Required command '{command}' was not found on PATH, at the specified location, or failed validation."); - } - return; - } - - if (string.IsNullOrWhiteSpace(command)) - { - throw new InvalidOperationException("Command path cannot be null or empty."); - } - var resolved = ResolveCommand(command); - var isValid = true; - string? validationMessage = null; - if (resolved is not null) - { - (isValid, validationMessage) = await OnResolvedAsync(resolved, cancellationToken).ConfigureAwait(false); - } - if (resolved is null || !isValid) - { - var link = GetHelpLink(); - var message = (link, validationMessage) switch - { - (null, not null) => validationMessage, - (not null, not null) => GetValidationFailedMessage(validationMessage), - (not null, null) => GetCommandNotFoundWithLinkMessage(command), - _ => GetCommandNotFoundMessage(command) - }; - - _logger.LogWarning("{Message}", message); - - _notificationMessage = message; - if (_interactionService.IsAvailable == true) - { - try - { - var options = new NotificationInteractionOptions - { - Intent = MessageIntent.Warning, - // Provide a link only if we have one. - LinkText = link is null ? null : "Installation instructions", - LinkUrl = link, - ShowDismiss = true, - ShowSecondaryButton = false - }; - - _notificationTask = _interactionService.PromptNotificationAsync( - title: "Missing command", - message: message, - options, - cancellationToken); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to show missing command notification"); - } - } - - if (_throwOnFailure) - { - throw new DistributedApplicationException(message); - } - return; - } - - _notificationMessage = null; - await OnValidatedAsync(resolved, cancellationToken).ConfigureAwait(false); - } - - /// - /// Sets whether to throw an exception when validation fails. - /// - /// True to throw on failure, false to just show notification and log. - protected void SetThrowOnFailure(bool throwOnFailure) - { - _throwOnFailure = throwOnFailure; - } - - /// - /// Optional link returned to guide users when the command is missing. Return null for no link. - /// - protected virtual string? GetHelpLink() => null; - - /// - /// Attempts to resolve a command (file name or path) to a full path. - /// - /// The command string. - /// Full path if resolved; otherwise null. - protected internal static string? ResolveCommand(string command) - { - // If the command includes any directory separator, treat it as a path (relative or absolute) - if (command.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]) >= 0) - { - var candidate = Path.GetFullPath(command); - return File.Exists(candidate) ? candidate : null; - } - - // Search PATH using the shared helper - return PathLookupHelper.FindFullPathFromPath(command); - } -} -#pragma warning restore ASPIREINTERACTION001 diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureFunctionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureFunctionsTests.cs index f6edc6700cd..4c8a1298dd8 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureFunctionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureFunctionsTests.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 ASPIRECOMMAND001 + using System.Text.Json.Nodes; using Aspire.TestUtilities; using Aspire.Hosting.ApplicationModel; @@ -757,15 +759,16 @@ public void AddAzureFunctionsProject_WithProjectPath_AddsAzureFunctionsAnnotatio } [Fact] - public void AddAzureFunctionsProject_RegistersFuncCoreToolsInstallationManager() + public void AddAzureFunctionsProject_AddsRequiredCommandAnnotation() { using var builder = TestDistributedApplicationBuilder.Create(); - builder.AddAzureFunctionsProject("funcapp"); + var funcApp = builder.AddAzureFunctionsProject("funcapp"); - // Verify that FuncCoreToolsInstallationManager is registered as a singleton - var descriptor = builder.Services.FirstOrDefault(s => s.ServiceType == typeof(FuncCoreToolsInstallationManager)); - Assert.NotNull(descriptor); - Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime); + // Verify that RequiredCommandAnnotation for 'func' is added + var annotation = funcApp.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + Assert.Equal("func", annotation.Command); + Assert.Equal("https://learn.microsoft.com/azure/azure-functions/functions-run-local#install-the-azure-functions-core-tools", annotation.HelpLink); } } diff --git a/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelCliInstallationManagerTests.cs b/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelCliInstallationManagerTests.cs deleted file mode 100644 index b4cda4375be..00000000000 --- a/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelCliInstallationManagerTests.cs +++ /dev/null @@ -1,127 +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.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Aspire.Hosting.DevTunnels.Tests; - -public class DevTunnelCliInstallationManagerTests -{ - [Theory] - [InlineData("1.0.1435", "1.0.1435", true)] - [InlineData("1.0.1435", "1.0.1436", true)] - [InlineData("1.0.1435", "1.1.1234", true)] - [InlineData("1.0.1435", "2.0.0", true)] - [InlineData("1.0.1435", "10.0.0", true)] - [InlineData("1.9.0", "1.10.0", true)] - [InlineData("1.0.1435", "1.0.1434", false)] - [InlineData("1.0.1435", "1.0.0", false)] - [InlineData("1.0.1435", "0.0.1", false)] - [InlineData("1.2.0", "1.1.999", false)] - public async Task OnResolvedAsync_ReturnsInvalidForUnsupportedVersion(string minVersion, string testVersion, bool expectedIsValid) - { - var logger = NullLoggerFactory.Instance.CreateLogger(); - var configuration = new ConfigurationBuilder().Build(); - var testCliVersion = Version.Parse(testVersion); - var devTunnelClient = new TestDevTunnelClient(testCliVersion); - - var manager = new DevTunnelCliInstallationManager(devTunnelClient, configuration, new TestInteractionService(), logger, Version.Parse(minVersion)); - - var (isValid, validationMessage) = await manager.OnResolvedAsync("thepath", CancellationToken.None); - - Assert.Equal(expectedIsValid, isValid); - if (expectedIsValid) - { - Assert.Null(validationMessage); - } - else - { - Assert.Contains(minVersion, validationMessage); - Assert.Contains(testVersion, validationMessage); - } - } - -#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - private sealed class TestInteractionService : IInteractionService - { - public bool IsAvailable => true; - - public Task> PromptConfirmationAsync(string title, string message, MessageBoxInteractionOptions? options = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task> PromptInputAsync(string title, string? message, string inputLabel, string placeHolder, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task> PromptInputAsync(string title, string? message, InteractionInput input, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task> PromptInputsAsync(string title, string? message, IReadOnlyList inputs, InputsDialogInteractionOptions? options = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task> PromptMessageBoxAsync(string title, string message, MessageBoxInteractionOptions? options = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task> PromptNotificationAsync(string title, string message, NotificationInteractionOptions? options = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - } -#pragma warning restore ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - - private sealed class TestDevTunnelClient(Version cliVersion) : IDevTunnelClient - { - public Task GetVersionAsync(ILogger? logger = null, CancellationToken cancellationToken = default) => Task.FromResult(cliVersion); - - public Task CreatePortAsync(string tunnelId, int portNumber, DevTunnelPortOptions options, ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task CreateTunnelAsync(string tunnelId, DevTunnelOptions options, ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task GetAccessAsync(string tunnelId, int? portNumber = null, ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task GetTunnelAsync(string tunnelId, ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task GetUserLoginStatusAsync(ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task UserLoginAsync(LoginProvider provider, ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task GetPortListAsync(string tunnelId, ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - - public Task DeletePortAsync(string tunnelId, int portNumber, ILogger? logger = null, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } - } -} diff --git a/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelCliVersionValidationTests.cs b/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelCliVersionValidationTests.cs new file mode 100644 index 00000000000..a1271b04b2c --- /dev/null +++ b/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelCliVersionValidationTests.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. + +#pragma warning disable ASPIRECOMMAND001 + +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.DevTunnels.Tests; + +public class DevTunnelCliVersionValidationTests +{ + [Theory] + [InlineData("1.0.1435", true)] + [InlineData("1.0.1436", true)] + [InlineData("1.1.1234", true)] + [InlineData("2.0.0", true)] + [InlineData("10.0.0", true)] + [InlineData("1.10.0", true)] + [InlineData("1.0.1434", false)] + [InlineData("1.0.0", false)] + [InlineData("0.0.1", false)] + [InlineData("1.1.999", true)] + public async Task ValidateDevTunnelCliVersionAsync_ReturnsInvalidForUnsupportedVersion(string testVersion, bool expectedIsValid) + { + var testCliVersion = Version.Parse(testVersion); + var devTunnelClient = new TestDevTunnelClient(testCliVersion); + + var services = new ServiceCollection() + .AddSingleton(devTunnelClient) + .BuildServiceProvider(); + + var context = new RequiredCommandValidationContext("thepath", services, CancellationToken.None); + var result = await DevTunnelsResourceBuilderExtensions.ValidateDevTunnelCliVersionAsync(context); + + Assert.Equal(expectedIsValid, result.IsValid); + if (expectedIsValid) + { + Assert.Null(result.ValidationMessage); + } + else + { + Assert.Contains(DevTunnelCli.MinimumSupportedVersion.ToString(), result.ValidationMessage); + Assert.Contains(testVersion, result.ValidationMessage); + } + } + + private sealed class TestDevTunnelClient(Version cliVersion) : IDevTunnelClient + { + public Task GetVersionAsync(ILogger? logger = null, CancellationToken cancellationToken = default) => Task.FromResult(cliVersion); + + public Task CreatePortAsync(string tunnelId, int portNumber, DevTunnelPortOptions options, ILogger? logger = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task CreateTunnelAsync(string tunnelId, DevTunnelOptions options, ILogger? logger = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task GetAccessAsync(string tunnelId, int? portNumber = null, ILogger? logger = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task GetTunnelAsync(string tunnelId, ILogger? logger = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task GetUserLoginStatusAsync(ILogger? logger = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task UserLoginAsync(LoginProvider provider, ILogger? logger = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task GetPortListAsync(string tunnelId, ILogger? logger = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task DeletePortAsync(string tunnelId, int portNumber, ILogger? logger = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } +} diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs index 20bbffdd694..b8591546f46 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs @@ -45,7 +45,8 @@ public void BuilderAddsDefaultServices() Assert.Collection( eventingSubscribers, s => Assert.IsType(s), - s => Assert.IsType(s) + s => Assert.IsType(s), + s => Assert.IsType(s) ); var options = app.Services.GetRequiredService>(); diff --git a/tests/Aspire.Cli.Tests/Utils/PathLookupHelperTests.cs b/tests/Aspire.Hosting.Tests/PathLookupHelperTests.cs similarity index 100% rename from tests/Aspire.Cli.Tests/Utils/PathLookupHelperTests.cs rename to tests/Aspire.Hosting.Tests/PathLookupHelperTests.cs diff --git a/tests/Aspire.Hosting.Tests/RequiredCommandAnnotationTests.cs b/tests/Aspire.Hosting.Tests/RequiredCommandAnnotationTests.cs new file mode 100644 index 00000000000..6f36a9caaf1 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/RequiredCommandAnnotationTests.cs @@ -0,0 +1,452 @@ +// 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 ASPIREINTERACTION001 +#pragma warning disable ASPIRECOMMAND001 + +using Aspire.Hosting.Eventing; +using Aspire.Hosting.Lifecycle; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Tests; + +public class RequiredCommandAnnotationTests +{ + [Fact] + public void RequiredCommandAnnotation_StoresCommand() + { + var annotation = new RequiredCommandAnnotation("test-command"); + + Assert.Equal("test-command", annotation.Command); + } + + [Fact] + public void RequiredCommandAnnotation_ThrowsOnNullCommand() + { + Assert.Throws(() => new RequiredCommandAnnotation(null!)); + } + + [Fact] + public void RequiredCommandAnnotation_CanSetHelpLink() + { + var annotation = new RequiredCommandAnnotation("test-command") + { + HelpLink = "https://example.com/help" + }; + + Assert.Equal("https://example.com/help", annotation.HelpLink); + } + + [Fact] + public void RequiredCommandAnnotation_CanSetValidationCallback() + { + Func> callback = + ctx => Task.FromResult(RequiredCommandValidationResult.Success()); + + var annotation = new RequiredCommandAnnotation("test-command") + { + ValidationCallback = callback + }; + + Assert.NotNull(annotation.ValidationCallback); + Assert.Same(callback, annotation.ValidationCallback); + } + + [Fact] + public void WithRequiredCommand_AddsAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var resourceBuilder = builder.AddContainer("test", "image"); + + resourceBuilder.WithRequiredCommand("test-command"); + + var annotation = resourceBuilder.Resource.Annotations.OfType().Single(); + Assert.Equal("test-command", annotation.Command); + Assert.Null(annotation.HelpLink); + Assert.Null(annotation.ValidationCallback); + } + + [Fact] + public void WithRequiredCommand_AddsAnnotationWithHelpLink() + { + var builder = DistributedApplication.CreateBuilder(); + var resourceBuilder = builder.AddContainer("test", "image"); + + resourceBuilder.WithRequiredCommand("test-command", "https://example.com/help"); + + var annotation = resourceBuilder.Resource.Annotations.OfType().Single(); + Assert.Equal("test-command", annotation.Command); + Assert.Equal("https://example.com/help", annotation.HelpLink); + Assert.Null(annotation.ValidationCallback); + } + + [Fact] + public void WithRequiredCommand_AddsAnnotationWithValidationCallback() + { + var builder = DistributedApplication.CreateBuilder(); + var resourceBuilder = builder.AddContainer("test", "image"); + Func> callback = + ctx => Task.FromResult(RequiredCommandValidationResult.Success()); + + resourceBuilder.WithRequiredCommand("test-command", callback); + + var annotation = resourceBuilder.Resource.Annotations.OfType().Single(); + Assert.Equal("test-command", annotation.Command); + Assert.Null(annotation.HelpLink); + Assert.NotNull(annotation.ValidationCallback); + } + + [Fact] + public void WithRequiredCommand_CanAddMultipleAnnotations() + { + var builder = DistributedApplication.CreateBuilder(); + var resourceBuilder = builder.AddContainer("test", "image"); + + resourceBuilder + .WithRequiredCommand("command1") + .WithRequiredCommand("command2"); + + var annotations = resourceBuilder.Resource.Annotations.OfType().ToList(); + Assert.Equal(2, annotations.Count); + Assert.Equal("command1", annotations[0].Command); + Assert.Equal("command2", annotations[1].Command); + } + + [Fact] + public void WithRequiredCommand_ThrowsOnNullBuilder() + { + Assert.Throws(() => + RequiredCommandResourceExtensions.WithRequiredCommand(null!, "test")); + } + + [Fact] + public void WithRequiredCommand_ThrowsOnNullCommand() + { + var builder = DistributedApplication.CreateBuilder(); + var resourceBuilder = builder.AddContainer("test", "image"); + + Assert.Throws(() => resourceBuilder.WithRequiredCommand(null!)); + } + + [Fact] + public async Task RequiredCommandValidationLifecycleHook_IsRegistered() + { + var builder = DistributedApplication.CreateBuilder(); + + await using var app = builder.Build(); + + var subscribers = app.Services.GetServices(); + Assert.Contains(subscribers, s => s is RequiredCommandValidationLifecycleHook); + } + + [Fact] + public async Task RequiredCommandValidationLifecycleHook_ValidatesExistingCommand() + { + var builder = DistributedApplication.CreateBuilder(); + var command = OperatingSystem.IsWindows() ? "cmd" : "sh"; + builder.AddContainer("test", "image").WithRequiredCommand(command); + + await using var app = builder.Build(); + await SubscribeHooksAsync(app); + + var appModel = app.Services.GetRequiredService(); + var resource = appModel.Resources.Single(r => r.Name == "test"); + var eventing = app.Services.GetRequiredService(); + + await eventing.PublishAsync(new BeforeResourceStartedEvent(resource, app.Services)); + } + + [Fact] + public async Task RequiredCommandValidationLifecycleHook_LogsWarningForMissingCommand() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddContainer("test", "image").WithRequiredCommand("this-command-definitely-does-not-exist-12345"); + + await using var app = builder.Build(); + await SubscribeHooksAsync(app); + + var appModel = app.Services.GetRequiredService(); + var resource = appModel.Resources.Single(r => r.Name == "test"); + var eventing = app.Services.GetRequiredService(); + + // Should not throw - just logs a warning and allows the resource to attempt start + await eventing.PublishAsync(new BeforeResourceStartedEvent(resource, app.Services)); + } + + [Fact] + public async Task RequiredCommandValidationLifecycleHook_IncludesHelpLinkInWarning() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddContainer("test", "image") + .WithRequiredCommand("missing-command", "https://example.com/install"); + + await using var app = builder.Build(); + await SubscribeHooksAsync(app); + + var appModel = app.Services.GetRequiredService(); + var resource = appModel.Resources.Single(r => r.Name == "test"); + var eventing = app.Services.GetRequiredService(); + + // Should not throw - just logs a warning and allows the resource to attempt start + await eventing.PublishAsync(new BeforeResourceStartedEvent(resource, app.Services)); + } + + [Fact] + public async Task RequiredCommandValidationLifecycleHook_CallsValidationCallback() + { + var builder = DistributedApplication.CreateBuilder(); + var callbackInvoked = false; + var command = OperatingSystem.IsWindows() ? "cmd" : "sh"; + + builder.AddContainer("test", "image") + .WithRequiredCommand(command, ctx => + { + callbackInvoked = true; + return Task.FromResult(RequiredCommandValidationResult.Success()); + }); + + await using var app = builder.Build(); + await SubscribeHooksAsync(app); + + var appModel = app.Services.GetRequiredService(); + var resource = appModel.Resources.Single(r => r.Name == "test"); + var eventing = app.Services.GetRequiredService(); + await eventing.PublishAsync(new BeforeResourceStartedEvent(resource, app.Services)); + + Assert.True(callbackInvoked); + } + + [Fact] + public async Task RequiredCommandValidationLifecycleHook_CallsValidationCallbackWithContext() + { + var builder = DistributedApplication.CreateBuilder(); + var command = OperatingSystem.IsWindows() ? "cmd" : "sh"; + string? capturedPath = null; + IServiceProvider? capturedServices = null; + + builder.AddContainer("test", "image") + .WithRequiredCommand(command, ctx => + { + capturedPath = ctx.ResolvedPath; + capturedServices = ctx.Services; + return Task.FromResult(RequiredCommandValidationResult.Success()); + }); + + await using var app = builder.Build(); + await SubscribeHooksAsync(app); + + var appModel = app.Services.GetRequiredService(); + var resource = appModel.Resources.Single(r => r.Name == "test"); + var eventing = app.Services.GetRequiredService(); + await eventing.PublishAsync(new BeforeResourceStartedEvent(resource, app.Services)); + + Assert.NotNull(capturedPath); + Assert.NotNull(capturedServices); + } + + [Fact] + public async Task RequiredCommandValidationLifecycleHook_LogsWarningOnFailedValidationCallback() + { + var builder = DistributedApplication.CreateBuilder(); + var command = OperatingSystem.IsWindows() ? "cmd" : "sh"; + + builder.AddContainer("test", "image") + .WithRequiredCommand(command, ctx => + { + return Task.FromResult(RequiredCommandValidationResult.Failure("Custom validation failed")); + }); + + await using var app = builder.Build(); + await SubscribeHooksAsync(app); + + var appModel = app.Services.GetRequiredService(); + var resource = appModel.Resources.Single(r => r.Name == "test"); + var eventing = app.Services.GetRequiredService(); + + // Should not throw - just logs a warning and allows the resource to attempt start + await eventing.PublishAsync(new BeforeResourceStartedEvent(resource, app.Services)); + } + + [Fact] + public async Task RequiredCommandValidationLifecycleHook_ValidatesMultipleAnnotations() + { + var builder = DistributedApplication.CreateBuilder(); + var command = OperatingSystem.IsWindows() ? "cmd" : "sh"; + + builder.AddContainer("test", "image") + .WithRequiredCommand(command) + .WithRequiredCommand("missing-command-xyz"); + + await using var app = builder.Build(); + await SubscribeHooksAsync(app); + + var appModel = app.Services.GetRequiredService(); + var resource = appModel.Resources.Single(r => r.Name == "test"); + var eventing = app.Services.GetRequiredService(); + + // Should not throw - validates all annotations, logs warnings for missing ones + await eventing.PublishAsync(new BeforeResourceStartedEvent(resource, app.Services)); + } + + [Fact] + public async Task RequiredCommandValidationLifecycleHook_CoalescesNotificationsForSameCommand() + { + var builder = DistributedApplication.CreateBuilder(); + const string missingCommand = "this-command-definitely-does-not-exist-coalesce-test"; + + builder.AddContainer("test1", "image").WithRequiredCommand(missingCommand); + builder.AddContainer("test2", "image").WithRequiredCommand(missingCommand); + + await using var app = builder.Build(); + await SubscribeHooksAsync(app); + + var appModel = app.Services.GetRequiredService(); + var resource1 = appModel.Resources.Single(r => r.Name == "test1"); + var resource2 = appModel.Resources.Single(r => r.Name == "test2"); + var eventing = app.Services.GetRequiredService(); + + // Both should complete without throwing - warnings are logged and cached + await eventing.PublishAsync(new BeforeResourceStartedEvent(resource1, app.Services)); + await eventing.PublishAsync(new BeforeResourceStartedEvent(resource2, app.Services)); + } + + [Fact] + public async Task RequiredCommandValidationLifecycleHook_CachesSuccessfulValidation() + { + var builder = DistributedApplication.CreateBuilder(); + var command = OperatingSystem.IsWindows() ? "cmd" : "sh"; + var callbackCount = 0; + + builder.AddContainer("test1", "image") + .WithRequiredCommand(command, ctx => + { + Interlocked.Increment(ref callbackCount); + return Task.FromResult(RequiredCommandValidationResult.Success()); + }); + + builder.AddContainer("test2", "image") + .WithRequiredCommand(command, ctx => + { + Interlocked.Increment(ref callbackCount); + return Task.FromResult(RequiredCommandValidationResult.Success()); + }); + + await using var app = builder.Build(); + await SubscribeHooksAsync(app); + + var appModel = app.Services.GetRequiredService(); + var resource1 = appModel.Resources.Single(r => r.Name == "test1"); + var resource2 = appModel.Resources.Single(r => r.Name == "test2"); + var eventing = app.Services.GetRequiredService(); + + await eventing.PublishAsync(new BeforeResourceStartedEvent(resource1, app.Services)); + await eventing.PublishAsync(new BeforeResourceStartedEvent(resource2, app.Services)); + + Assert.Equal(1, callbackCount); + } + + [Fact] + public async Task RequiredCommandValidationLifecycleHook_CallsInteractionServiceForMissingCommand() + { + var builder = DistributedApplication.CreateBuilder(); + const string missingCommand = "this-command-does-not-exist-interaction-test"; + + var testInteractionService = new TestInteractionService { IsAvailable = true }; + builder.Services.AddSingleton(testInteractionService); + + builder.AddContainer("test", "image").WithRequiredCommand(missingCommand, "https://example.com/install"); + + await using var app = builder.Build(); + await SubscribeHooksAsync(app); + + var appModel = app.Services.GetRequiredService(); + var resource = appModel.Resources.Single(r => r.Name == "test"); + var eventing = app.Services.GetRequiredService(); + + // Start publishing in background - it will write to the channel + var publishTask = eventing.PublishAsync(new BeforeResourceStartedEvent(resource, app.Services)); + + // Read the notification from the channel + var interaction = await testInteractionService.Interactions.Reader.ReadAsync(); + + // Complete the notification so publish can finish + interaction.CompletionTcs.SetResult(InteractionResult.Ok(true)); + await publishTask; + + Assert.Equal("Missing command", interaction.Title); + Assert.Contains(missingCommand, interaction.Message); + } + + [Fact] + public async Task RequiredCommandValidationLifecycleHook_DoesNotCallInteractionServiceWhenUnavailable() + { + var builder = DistributedApplication.CreateBuilder(); + const string missingCommand = "this-command-does-not-exist-unavailable-test"; + + var testInteractionService = new TestInteractionService { IsAvailable = false }; + builder.Services.AddSingleton(testInteractionService); + + builder.AddContainer("test", "image").WithRequiredCommand(missingCommand); + + await using var app = builder.Build(); + await SubscribeHooksAsync(app); + + var appModel = app.Services.GetRequiredService(); + var resource = appModel.Resources.Single(r => r.Name == "test"); + var eventing = app.Services.GetRequiredService(); + + await eventing.PublishAsync(new BeforeResourceStartedEvent(resource, app.Services)); + + // Channel should be empty since IsAvailable is false + Assert.False(testInteractionService.Interactions.Reader.TryRead(out _)); + } + + [Fact] + public async Task RequiredCommandValidationLifecycleHook_CoalescesInteractionServiceCalls() + { + var builder = DistributedApplication.CreateBuilder(); + const string missingCommand = "this-command-does-not-exist-coalesce-interaction-test"; + + var testInteractionService = new TestInteractionService { IsAvailable = true }; + builder.Services.AddSingleton(testInteractionService); + + builder.AddContainer("test1", "image").WithRequiredCommand(missingCommand); + builder.AddContainer("test2", "image").WithRequiredCommand(missingCommand); + + await using var app = builder.Build(); + await SubscribeHooksAsync(app); + + var appModel = app.Services.GetRequiredService(); + var resource1 = appModel.Resources.Single(r => r.Name == "test1"); + var resource2 = appModel.Resources.Single(r => r.Name == "test2"); + var eventing = app.Services.GetRequiredService(); + + // First publish - will trigger notification + var publishTask1 = eventing.PublishAsync(new BeforeResourceStartedEvent(resource1, app.Services)); + var interaction = await testInteractionService.Interactions.Reader.ReadAsync(); + interaction.CompletionTcs.SetResult(InteractionResult.Ok(true)); + await publishTask1; + + // Second publish - should not trigger another notification due to coalescing + await eventing.PublishAsync(new BeforeResourceStartedEvent(resource2, app.Services)); + + // Channel should be empty since the second call was coalesced + Assert.False(testInteractionService.Interactions.Reader.TryRead(out _)); + } + + /// + /// Helper method to subscribe all eventing subscribers (including RequiredCommandValidationLifecycleHook) + /// to the eventing system. This simulates what happens during app.StartAsync(). + /// + private static async Task SubscribeHooksAsync(DistributedApplication app) + { + var eventSubscribers = app.Services.GetServices(); + var eventing = app.Services.GetRequiredService(); + var execContext = app.Services.GetRequiredService(); + + foreach (var subscriber in eventSubscribers) + { + await subscriber.SubscribeAsync(eventing, execContext, CancellationToken.None); + } + } +} diff --git a/tests/Aspire.TestUtilities/Aspire.TestUtilities.csproj b/tests/Aspire.TestUtilities/Aspire.TestUtilities.csproj index 9a66c035da5..6ae6aaa5c70 100644 --- a/tests/Aspire.TestUtilities/Aspire.TestUtilities.csproj +++ b/tests/Aspire.TestUtilities/Aspire.TestUtilities.csproj @@ -1,4 +1,4 @@ - + $(DefaultTargetFramework) From ed5eb4e8659b4681b9884ca87dd08562bb8eb07d Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 3 Feb 2026 01:45:02 +0800 Subject: [PATCH 018/256] Fix dashboard URL logging (#14287) * Fix dashboard URL logging * Fix dashboard URL * PR feedback --- .../ApplicationModel/EndpointHostHelpers.cs | 33 +++++++++++++++++++ .../Backchannel/DashboardUrlsHelper.cs | 2 +- .../Dashboard/DashboardEventHandlers.cs | 2 +- .../Dashboard/DashboardLifecycleHookTests.cs | 24 ++++++++------ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs b/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs index b69b54869d4..9a955056b39 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs @@ -107,4 +107,37 @@ public static bool IsLocalhostOrLocalhostTld([NotNullWhen(true)] Uri? uri) { return uri?.Host is not null && IsLocalhostOrLocalhostTld(uri.Host); } + + /// + /// Gets the URL of the endpoint, adjusting for localhost TLD if configured. + /// + /// + /// When the endpoint's is a localhost TLD + /// (e.g., aspire-dashboard.dev.localhost), the allocated endpoint address will be "localhost" + /// since that's what the service actually binds to. This method returns the URL with the + /// configured TLD hostname instead, which is what users expect to see and use in browsers. + /// + /// The endpoint reference. + /// A cancellation token. + /// The URL with the appropriate hostname. + internal static async ValueTask GetUrlWithTargetHostAsync(EndpointReference endpoint, CancellationToken cancellationToken = default) + { + var allocatedUrl = await endpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrEmpty(allocatedUrl)) + { + return allocatedUrl; + } + + // If the configured TargetHost is a localhost TLD (e.g., aspire-dashboard.dev.localhost), + // we need to use that instead of the allocated address (localhost) since the TLD hostname + // is what the user expects to see and use in the browser. + var targetHost = endpoint.EndpointAnnotation.TargetHost; + if (IsLocalhostTld(targetHost) && Uri.TryCreate(allocatedUrl, UriKind.Absolute, out var uri)) + { + return $"{uri.Scheme}://{targetHost}:{uri.Port}"; + } + + return allocatedUrl; + } } diff --git a/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs b/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs index 0e4cf6d7b47..e1fe711f47c 100644 --- a/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs +++ b/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs @@ -70,7 +70,7 @@ await resourceNotificationService.WaitForResourceHealthyAsync( var apiEndpoint = httpsEndpoint.Exists ? httpsEndpoint : httpEndpoint; if (apiEndpoint.Exists) { - apiBaseUrl = await apiEndpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); + apiBaseUrl = await EndpointHostHelpers.GetUrlWithTargetHostAsync(apiEndpoint, cancellationToken).ConfigureAwait(false); } // MCP endpoint diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index b7bd546b84a..0ea7ccd323c 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -393,7 +393,7 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) var endpoint = httpsEndpoint.Exists ? httpsEndpoint : httpEndpoint; if (endpoint.Exists) { - dashboardUrl = await endpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); + dashboardUrl = await EndpointHostHelpers.GetUrlWithTargetHostAsync(endpoint, cancellationToken).ConfigureAwait(false); } } diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs index 4e172108efd..459128d2415 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs @@ -190,8 +190,11 @@ public async Task ConfigureEnvironmentVariables_HasAspireDashboardEnvVars_Copied Assert.Equal("true", envVars.Single(e => e.Key == "ASPIRE_DASHBOARD_PURPLE_MONKEY_DISHWASHER").Value); } - [Fact] - public async Task ResourceReadyEvent_LogsDashboardUrlFromAllocatedEndpoint() + [Theory] + [InlineData("https://localhost:17131", "localhost", 9999, "https")] + [InlineData("https://aspire-dashboard.dev.localhost:17131", "aspire-dashboard.dev.localhost", 9999, "https")] + [InlineData("http://myapp.localhost:8080", "myapp.localhost", 5555, "http")] + public async Task ResourceReadyEvent_LogsDashboardUrlFromAllocatedEndpoint(string configuredUrl, string expectedHost, int allocatedPort, string expectedScheme) { // Arrange var testSink = new TestSink(); @@ -208,11 +211,11 @@ public async Task ResourceReadyEvent_LogsDashboardUrlFromAllocatedEndpoint() var configurationBuilder = new ConfigurationBuilder(); var configuration = configurationBuilder.Build(); - // Configure dashboard with a specific URL (e.g., port 17131) but we'll allocate a different port (e.g., 9999) + // Configure dashboard with a specific URL - we'll allocate a different port var dashboardOptions = Options.Create(new DashboardOptions { DashboardPath = "test.dll", - DashboardUrl = "https://localhost:17131", // Configured URL + DashboardUrl = configuredUrl, DashboardToken = "test-token", OtlpGrpcEndpointUrl = "http://localhost:4317", }); @@ -234,9 +237,9 @@ public async Task ResourceReadyEvent_LogsDashboardUrlFromAllocatedEndpoint() var dashboardResource = model.Resources.Single(r => string.Equals(r.Name, KnownResourceNames.AspireDashboard, StringComparisons.ResourceName)); - // Set up allocated endpoint with a different port (9999) than configured (17131) - var httpsEndpoint = dashboardResource.Annotations.OfType().Single(e => e.Name == "https"); - httpsEndpoint.AllocatedEndpoint = new(httpsEndpoint, "localhost", 9999, targetPortExpression: "9999"); + // Set up allocated endpoint - DCP allocates "localhost" as the address since localhost TLD binds to localhost + var endpointAnnotation = dashboardResource.Annotations.OfType().Single(e => e.Name == expectedScheme); + endpointAnnotation.AllocatedEndpoint = new(endpointAnnotation, "localhost", allocatedPort, targetPortExpression: allocatedPort.ToString()); // Fire the ResourceReadyEvent var readyEvent = new ResourceReadyEvent(dashboardResource, new TestServiceProvider()); @@ -250,12 +253,13 @@ public async Task ResourceReadyEvent_LogsDashboardUrlFromAllocatedEndpoint() // Extract the DashboardUrl from the structured log state var dashboardUrlValue = LogTestHelpers.GetValue(listeningLog, "DashboardUrl")?.ToString(); - Assert.NotNull(dashboardUrlValue); - // Parse the URL and verify the port is the allocated port (9999), not the configured port (17131) + // Parse the URL and verify it uses the expected host (configured TLD if applicable) and allocated port var uri = new Uri(dashboardUrlValue); - Assert.Equal(9999, uri.Port); + Assert.Equal(expectedHost, uri.Host); + Assert.Equal(allocatedPort, uri.Port); + Assert.Equal(expectedScheme, uri.Scheme); } [Fact] From d15f941e521a48ee3744599b2f1e05527df7b53a Mon Sep 17 00:00:00 2001 From: David Pine Date: Mon, 2 Feb 2026 13:25:16 -0600 Subject: [PATCH 019/256] Implement an animated Aspire CLI welcome banner (#14260) * feat: Add animated welcome banner to Aspire CLI - Introduced a new `BannerService` to handle the display of an animated welcome banner. - Created an `IBannerService` interface for banner functionality. - Updated localization files to include new banner-related strings in multiple languages. - Modified `RootCommandTests` to include tests for banner display logic. - Implemented a `TestBannerService` for testing purposes. - Refactored existing methods to support asynchronous banner display. * feat: Implement BannerOption for displaying animated banner in CLI * refactor: Remove BannerOption class and integrate banner handling directly in RootCommand * feat: Enhance banner display functionality with end-to-end tests for first run and explicit flag scenarios * feat: Add verification for first-time use sentinel deletion in banner tests * fix: Update command in first run test to use 'aspire --version' for better banner visibility --- src/Aspire.Cli/Commands/RootCommand.cs | 16 ++ src/Aspire.Cli/Interaction/BannerService.cs | 265 ++++++++++++++++++ src/Aspire.Cli/Interaction/IBannerService.cs | 17 ++ src/Aspire.Cli/Program.cs | 39 +-- .../Resources/RootCommandStrings.Designer.cs | 57 +++- .../Resources/RootCommandStrings.resx | 65 +++-- .../Resources/xlf/RootCommandStrings.cs.xlf | 15 + .../Resources/xlf/RootCommandStrings.de.xlf | 15 + .../Resources/xlf/RootCommandStrings.es.xlf | 15 + .../Resources/xlf/RootCommandStrings.fr.xlf | 15 + .../Resources/xlf/RootCommandStrings.it.xlf | 15 + .../Resources/xlf/RootCommandStrings.ja.xlf | 15 + .../Resources/xlf/RootCommandStrings.ko.xlf | 15 + .../Resources/xlf/RootCommandStrings.pl.xlf | 15 + .../xlf/RootCommandStrings.pt-BR.xlf | 15 + .../Resources/xlf/RootCommandStrings.ru.xlf | 15 + .../Resources/xlf/RootCommandStrings.tr.xlf | 15 + .../xlf/RootCommandStrings.zh-Hans.xlf | 15 + .../xlf/RootCommandStrings.zh-Hant.xlf | 15 + .../Aspire.Cli.EndToEnd.Tests/BannerTests.cs | 229 +++++++++++++++ .../Helpers/CliE2ETestHelpers.cs | 39 +++ .../Commands/RootCommandTests.cs | 163 +++++++++-- .../TestServices/TestBannerService.cs | 19 ++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 2 + 24 files changed, 1026 insertions(+), 80 deletions(-) create mode 100644 src/Aspire.Cli/Interaction/BannerService.cs create mode 100644 src/Aspire.Cli/Interaction/IBannerService.cs create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/TestBannerService.cs diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index cb201b4e4f1..3680df57341 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -36,6 +36,12 @@ internal sealed class RootCommand : BaseRootCommand Recursive = true }; + public static readonly Option BannerOption = new("--banner") + { + Description = RootCommandStrings.BannerArgumentDescription, + Recursive = true + }; + public static readonly Option WaitForDebuggerOption = new("--wait-for-debugger") { Description = RootCommandStrings.WaitForDebuggerArgumentDescription, @@ -107,9 +113,19 @@ public RootCommand( Options.Add(DebugOption); Options.Add(NonInteractiveOption); Options.Add(NoLogoOption); + Options.Add(BannerOption); Options.Add(WaitForDebuggerOption); Options.Add(CliWaitForDebuggerOption); + // Handle standalone 'aspire --banner' (no subcommand) + this.SetAction((context, cancellationToken) => + { + var bannerRequested = context.GetValue(BannerOption); + // If --banner was passed, we've already shown it in Main, just exit successfully + // Otherwise, show the standard "no command" error + return Task.FromResult(bannerRequested ? 0 : 1); + }); + Subcommands.Add(newCommand); Subcommands.Add(initCommand); Subcommands.Add(runCommand); diff --git a/src/Aspire.Cli/Interaction/BannerService.cs b/src/Aspire.Cli/Interaction/BannerService.cs new file mode 100644 index 00000000000..6183ceae371 --- /dev/null +++ b/src/Aspire.Cli/Interaction/BannerService.cs @@ -0,0 +1,265 @@ +// 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 Aspire.Cli.Resources; +using Aspire.Cli.Utils; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace Aspire.Cli.Interaction; + +/// +/// Provides functionality to display an animated Aspire CLI banner using Spectre.Console. +/// +internal sealed class BannerService : IBannerService +{ + // Aspire brand colors + private static readonly Color s_purpleAccent = new(81, 43, 212); // #512BD4 - Aspire brand purple + private static readonly Color s_purpleDark = new(116, 85, 221); // #7455DD + private static readonly Color s_purpleLight = new(203, 191, 242); // #CBBFF2 + private static readonly Color s_textColor = Color.White; + private static readonly Color s_borderColor = Color.Grey; + + // Custom thick block ASPIRE text + private static readonly string[] s_aspireLines = + [ + " █████ ███████ ██████ ██ ██████ ██████ ", + "██▀▀▀██ ██▀▀▀▀▀ ██▀▀▀██ ██ ██▀▀▀██ ██▀▀▀▀ ", + "███████ ███████ ██████ ██ ██████ █████ ", + "██ ██ ▀▀▀▀▀██ ██▀▀▀ ██ ██▀▀██ ██ ", + "██ ██ ███████ ██ ██ ██ ██ ██████ ", + "▀▀ ▀▀ ▀▀▀▀▀▀▀ ▀▀ ▀▀ ▀▀ ▀▀ ▀▀▀▀▀▀ ", + ]; + + // Letter start positions for animation (A, S, P, I, R, E columns) + private static readonly int[] s_letterPositions = [0, 8, 16, 24, 27, 34]; + + private readonly IAnsiConsole _console; + + /// + /// Initializes a new instance of the class. + /// + /// The console environment providing access to console output. + public BannerService(ConsoleEnvironment consoleEnvironment) + { + ArgumentNullException.ThrowIfNull(consoleEnvironment); + _console = consoleEnvironment.Error; // Use stderr to avoid interfering with command output + } + + /// + public async Task DisplayBannerAsync(CancellationToken cancellationToken = default) + { + var cliVersion = VersionHelper.GetDefaultTemplateVersion(); + var aspireWidth = s_aspireLines[0].TrimEnd().Length; + var welcomeText = RootCommandStrings.BannerWelcomeText; + var versionText = string.Format(CultureInfo.CurrentCulture, RootCommandStrings.BannerVersionFormat, cliVersion); + var versionPadding = Math.Max(0, aspireWidth - versionText.Length); + + await _console.Live(new Panel(new Text("")).Border(BoxBorder.Rounded).BorderColor(s_borderColor).Padding(2, 1)) + .AutoClear(false) + .StartAsync(async ctx => + { + // Frame 1: Empty panel + ctx.UpdateTarget(CreatePanel(CreateBanner(welcomeText, false, false, null))); + await DelayAsync(80, cancellationToken); + + // Frame 2: Welcome text types in + for (var i = 1; i <= welcomeText.Length; i += 3) + { + var partial = welcomeText[..Math.Min(i, welcomeText.Length)]; + ctx.UpdateTarget(CreatePanel(CreatePartialWelcome(partial))); + await DelayAsync(40, cancellationToken); + } + + // Frame 3: ASPIRE appears letter by letter + for (var letterIdx = 0; letterIdx <= s_letterPositions.Length; letterIdx++) + { + var visibleCols = letterIdx < s_letterPositions.Length ? s_letterPositions[letterIdx] : s_aspireLines[0].Length; + ctx.UpdateTarget(CreatePanel(CreatePartialAspire(welcomeText, visibleCols))); + await DelayAsync(70, cancellationToken); + } + + // Frame 4: Version slides in from right + for (var i = 1; i <= 8; i++) + { + var visibleChars = (int)Math.Ceiling((double)versionText.Length * i / 8); + var partialVer = versionText[(versionText.Length - visibleChars)..]; + ctx.UpdateTarget(CreatePanel(CreateBanner(welcomeText, true, true, partialVer))); + await DelayAsync(50, cancellationToken); + } + + // Frame 5: Shine sweeps across ASPIRE + for (var shineCol = 0; shineCol <= aspireWidth; shineCol += 3) + { + ctx.UpdateTarget(CreatePanel(CreateBannerWithShine(welcomeText, versionText, versionPadding, shineCol))); + await DelayAsync(35, cancellationToken); + } + + // Final frame + ctx.UpdateTarget(CreatePanel(CreateBanner(welcomeText, true, true, versionText))); + }); + + _console.WriteLine(); + } + + private static async Task DelayAsync(int milliseconds, CancellationToken cancellationToken) + { + try + { + await Task.Delay(milliseconds, cancellationToken); + } + catch (TaskCanceledException) + { + // Animation cancelled, just return + } + } + + private static Panel CreatePanel(IRenderable content) + { + return new Panel(content) + .Border(BoxBorder.Rounded) + .BorderColor(s_borderColor) + .Padding(2, 1); + } + + private static Rows CreateBanner(string welcomeText, bool showWelcome, bool showAspire, string? partialVersion) + { + var elements = new List(); + + if (showWelcome) + { + elements.Add(new Markup($"[rgb({s_textColor.R},{s_textColor.G},{s_textColor.B})]{welcomeText.EscapeMarkup()}[/]")); + } + else + { + elements.Add(new Text("")); + } + + if (showAspire) + { + elements.Add(CreateAspireText(-1)); + } + else + { + // Empty space for ASPIRE + foreach (var _ in s_aspireLines) + { + elements.Add(new Text("")); + } + } + + var aspireWidth = s_aspireLines[0].TrimEnd().Length; + if (partialVersion is not null) + { + var padding = Math.Max(0, aspireWidth - partialVersion.Length); + elements.Add(new Markup($"[rgb({s_textColor.R},{s_textColor.G},{s_textColor.B})]{new string(' ', padding)}{partialVersion}[/]")); + } + else + { + elements.Add(new Text("")); + } + + return new Rows(elements); + } + + private static Rows CreatePartialWelcome(string partial) + { + var elements = new List + { + new Markup($"[rgb({s_textColor.R},{s_textColor.G},{s_textColor.B})]{partial.EscapeMarkup()}[/]") + }; + + // Empty space for ASPIRE and version + foreach (var _ in s_aspireLines) + { + elements.Add(new Text("")); + } + elements.Add(new Text("")); + + return new Rows(elements); + } + + private static Rows CreatePartialAspire(string welcomeText, int visibleCols) + { + var elements = new List + { + new Markup($"[rgb({s_textColor.R},{s_textColor.G},{s_textColor.B})]{welcomeText.EscapeMarkup()}[/]") + }; + + foreach (var line in s_aspireLines) + { + var partialLine = line[..Math.Min(visibleCols, line.Length)].PadRight(line.Length); + var markup = BuildLineMarkup(partialLine, -1); + elements.Add(new Markup(markup)); + } + + elements.Add(new Text("")); + + return new Rows(elements); + } + + private static Rows CreateBannerWithShine(string welcomeText, string versionText, int versionPadding, int shineCol) + { + var elements = new List + { + new Markup($"[rgb({s_textColor.R},{s_textColor.G},{s_textColor.B})]{welcomeText.EscapeMarkup()}[/]") + }; + + foreach (var line in s_aspireLines) + { + var markup = BuildLineMarkup(line, shineCol); + elements.Add(new Markup(markup)); + } + + elements.Add(new Markup($"[rgb({s_textColor.R},{s_textColor.G},{s_textColor.B})]{new string(' ', versionPadding)}{versionText}[/]")); + + return new Rows(elements); + } + + private static Rows CreateAspireText(int shineCol) + { + var rows = new List(); + foreach (var line in s_aspireLines) + { + var markup = BuildLineMarkup(line, shineCol); + rows.Add(new Markup(markup)); + } + return new Rows(rows); + } + + private static string BuildLineMarkup(string line, int shineCol) + { + var markup = ""; + for (var col = 0; col < line.Length; col++) + { + var c = line[col]; + if (c is ' ') + { + markup += " "; + continue; + } + + var color = s_purpleAccent; + if (shineCol >= 0 && col >= shineCol && col < shineCol + 3) + { + color = s_purpleLight; + } + else if (c == '▀') + { + color = s_purpleDark; + } + + var charStr = c switch + { + '[' => "[[", + ']' => "]]", + _ => c.ToString() + }; + + markup += $"[rgb({color.R},{color.G},{color.B})]{charStr}[/]"; + } + + return markup; + } +} diff --git a/src/Aspire.Cli/Interaction/IBannerService.cs b/src/Aspire.Cli/Interaction/IBannerService.cs new file mode 100644 index 00000000000..070f9cbc157 --- /dev/null +++ b/src/Aspire.Cli/Interaction/IBannerService.cs @@ -0,0 +1,17 @@ +// 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.Interaction; + +/// +/// Provides functionality to display the Aspire CLI animated banner. +/// +internal interface IBannerService +{ + /// + /// Displays the animated Aspire CLI banner. + /// + /// A token to cancel the animation. + /// A task that completes when the banner animation is finished. + Task DisplayBannerAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 15a6dd35ff7..04cf93122e1 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -182,6 +182,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(_ => new FirstTimeUseNoticeSentinel(GetUsersAspirePath())); + builder.Services.AddSingleton(); builder.Services.AddMemoryCache(); // MCP server: aspire.dev docs services. @@ -337,31 +338,33 @@ private static IConfigurationService BuildConfigurationService(IServiceProvider return new ConfigurationService(configuration, executionContext, globalSettingsFile); } - internal static void DisplayFirstTimeUseNoticeIfNeeded(IServiceProvider serviceProvider, bool noLogo) + internal static async Task DisplayFirstTimeUseNoticeIfNeededAsync(IServiceProvider serviceProvider, bool noLogo, bool showBanner, CancellationToken cancellationToken = default) { var sentinel = serviceProvider.GetRequiredService(); + var isFirstRun = !sentinel.Exists(); - if (sentinel.Exists()) + // Show banner if explicitly requested OR on first run (unless suppressed by noLogo) + if (showBanner || (isFirstRun && !noLogo)) { - return; + var bannerService = serviceProvider.GetRequiredService(); + await bannerService.DisplayBannerAsync(cancellationToken); } - if (!noLogo) + // Only show telemetry notice on first run (not when banner is explicitly requested) + if (isFirstRun) { - // Write to stderr to avoid interfering with tools that parse stdout - var consoleEnvironment = serviceProvider.GetRequiredService(); + if (!noLogo) + { + // Write to stderr to avoid interfering with tools that parse stdout + var consoleEnvironment = serviceProvider.GetRequiredService(); - // Display welcome. Matches ConsoleInteractionService.DisplayMessage to display a message with emoji consistently. - consoleEnvironment.Error.Markup(":waving_hand:"); - consoleEnvironment.Error.Write("\u001b[4G"); - consoleEnvironment.Error.MarkupLine(RootCommandStrings.FirstTimeUseWelcome); + consoleEnvironment.Error.WriteLine(); + consoleEnvironment.Error.WriteLine(RootCommandStrings.FirstTimeUseTelemetryNotice); + consoleEnvironment.Error.WriteLine(); + } - consoleEnvironment.Error.WriteLine(); - consoleEnvironment.Error.WriteLine(RootCommandStrings.FirstTimeUseTelemetryNotice); - consoleEnvironment.Error.WriteLine(); + sentinel.CreateIfNotExists(); } - - sentinel.CreateIfNotExists(); } private static IAnsiConsole BuildAnsiConsole(IServiceProvider serviceProvider, TextWriter writer) @@ -386,7 +389,8 @@ private static IAnsiConsole BuildAnsiConsole(IServiceProvider serviceProvider, T if (hostEnvironment.SupportsAnsi) { settings.Ansi = AnsiSupport.Yes; - settings.ColorSystem = ColorSystemSupport.Standard; + // Using EightBit color system for better color support of Aspire brand colors in terminals that support ANSI + settings.ColorSystem = ColorSystemSupport.EightBit; } if (isPlayground) @@ -426,7 +430,8 @@ public static async Task Main(string[] args) // Display first run experience if this is the first time the CLI is run on this machine var configuration = app.Services.GetRequiredService(); var noLogo = args.Any(a => a == "--nologo") || configuration.GetBool(CliConfigNames.NoLogo, defaultValue: false); - DisplayFirstTimeUseNoticeIfNeeded(app.Services, noLogo); + var showBanner = args.Any(a => a == "--banner"); + await DisplayFirstTimeUseNoticeIfNeededAsync(app.Services, noLogo, showBanner, cts.Token); var rootCommand = app.Services.GetRequiredService(); var invokeConfig = new InvocationConfiguration() diff --git a/src/Aspire.Cli/Resources/RootCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/RootCommandStrings.Designer.cs index 90f3b51f9af..d90521752fe 100644 --- a/src/Aspire.Cli/Resources/RootCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/RootCommandStrings.Designer.cs @@ -10,8 +10,8 @@ namespace Aspire.Cli.Resources { using System; - - + + /// /// A strongly-typed resource class, for looking up localized strings, etc. /// @@ -23,15 +23,15 @@ namespace Aspire.Cli.Resources { [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class RootCommandStrings { - + private static global::System.Resources.ResourceManager resourceMan; - + private static global::System.Globalization.CultureInfo resourceCulture; - + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] internal RootCommandStrings() { } - + /// /// Returns the cached ResourceManager instance used by this class. /// @@ -45,7 +45,7 @@ internal RootCommandStrings() { return resourceMan; } } - + /// /// Overrides the current thread's CurrentUICulture property for all /// resource lookups using this strongly typed resource class. @@ -59,7 +59,34 @@ internal RootCommandStrings() { resourceCulture = value; } } - + + /// + /// Looks up a localized string similar to Display the animated Aspire CLI welcome banner.. + /// + public static string BannerArgumentDescription { + get { + return ResourceManager.GetString("BannerArgumentDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to CLI — version {0}. + /// + public static string BannerVersionFormat { + get { + return ResourceManager.GetString("BannerVersionFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Welcome to the. + /// + public static string BannerWelcomeText { + get { + return ResourceManager.GetString("BannerWelcomeText", resourceCulture); + } + } + /// /// Looks up a localized string similar to Wait for a debugger to attach before executing the command.. /// @@ -68,7 +95,7 @@ public static string CliWaitForDebuggerArgumentDescription { return ResourceManager.GetString("CliWaitForDebuggerArgumentDescription", resourceCulture); } } - + /// /// Looks up a localized string similar to Enable debug logging to the console.. /// @@ -77,7 +104,7 @@ public static string DebugArgumentDescription { return ResourceManager.GetString("DebugArgumentDescription", resourceCulture); } } - + /// /// Looks up a localized string similar to The Aspire CLI can be used to create, run, and publish Aspire-based applications.. /// @@ -86,7 +113,7 @@ public static string Description { return ResourceManager.GetString("Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Telemetry ///--------- @@ -100,7 +127,7 @@ public static string FirstTimeUseTelemetryNotice { return ResourceManager.GetString("FirstTimeUseTelemetryNotice", resourceCulture); } } - + /// /// Looks up a localized string similar to Welcome to Aspire! Learn more about Aspire at https://aspire.dev. /// @@ -109,7 +136,7 @@ public static string FirstTimeUseWelcome { return ResourceManager.GetString("FirstTimeUseWelcome", resourceCulture); } } - + /// /// Looks up a localized string similar to Suppress the startup banner and telemetry notice.. /// @@ -118,7 +145,7 @@ public static string NoLogoArgumentDescription { return ResourceManager.GetString("NoLogoArgumentDescription", resourceCulture); } } - + /// /// Looks up a localized string similar to Wait for a debugger to attach before executing the command.. /// @@ -127,7 +154,7 @@ public static string WaitForDebuggerArgumentDescription { return ResourceManager.GetString("WaitForDebuggerArgumentDescription", resourceCulture); } } - + /// /// Looks up a localized string similar to Waiting for debugger to attach to CLI process ID: {0}. /// diff --git a/src/Aspire.Cli/Resources/RootCommandStrings.resx b/src/Aspire.Cli/Resources/RootCommandStrings.resx index a4bf8ec27de..3b69afad727 100644 --- a/src/Aspire.Cli/Resources/RootCommandStrings.resx +++ b/src/Aspire.Cli/Resources/RootCommandStrings.resx @@ -1,17 +1,17 @@ - @@ -120,6 +120,15 @@ The Aspire CLI can be used to create, run, and publish Aspire-based applications. + + Display the animated Aspire CLI welcome banner. + + + Welcome to the + + + CLI — version {0} + Enable debug logging to the console. @@ -146,4 +155,4 @@ The Aspire CLI collects usage data. It is collected by Microsoft and is used to Read more about Aspire CLI telemetry: https://aka.ms/aspire/cli-telemetry - \ No newline at end of file + diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf index 4605fd9329d..4bd0885db3d 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf @@ -2,6 +2,21 @@ + + Display the animated Aspire CLI welcome banner. + Display the animated Aspire CLI welcome banner. + + + + CLI — version {0} + CLI — version {0} + + + + Welcome to the + Welcome to the + + Wait for a debugger to attach before executing the command. Před provedením příkazu počkejte na připojení ladicího programu. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf index 4fed320a607..5d4ada49a6f 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf @@ -2,6 +2,21 @@ + + Display the animated Aspire CLI welcome banner. + Display the animated Aspire CLI welcome banner. + + + + CLI — version {0} + CLI — version {0} + + + + Welcome to the + Welcome to the + + Wait for a debugger to attach before executing the command. Warten Sie, bis ein Debugger angefügt wurde, bevor Sie den Befehl ausführen. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf index b9984e9957e..64c08f340c2 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf @@ -2,6 +2,21 @@ + + Display the animated Aspire CLI welcome banner. + Display the animated Aspire CLI welcome banner. + + + + CLI — version {0} + CLI — version {0} + + + + Welcome to the + Welcome to the + + Wait for a debugger to attach before executing the command. Espere a que se asocie un depurador antes de ejecutar el comando. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf index 796580876d4..ad47b392aff 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf @@ -2,6 +2,21 @@ + + Display the animated Aspire CLI welcome banner. + Display the animated Aspire CLI welcome banner. + + + + CLI — version {0} + CLI — version {0} + + + + Welcome to the + Welcome to the + + Wait for a debugger to attach before executing the command. Attendez qu’un débogueur s’attache avant d’exécuter la commande. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf index 8d25fbd20f3..16d95b9a10c 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf @@ -2,6 +2,21 @@ + + Display the animated Aspire CLI welcome banner. + Display the animated Aspire CLI welcome banner. + + + + CLI — version {0} + CLI — version {0} + + + + Welcome to the + Welcome to the + + Wait for a debugger to attach before executing the command. Attendi che un debugger si connetta prima di eseguire il comando. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf index 207378d7605..8ef3d18cdff 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf @@ -2,6 +2,21 @@ + + Display the animated Aspire CLI welcome banner. + Display the animated Aspire CLI welcome banner. + + + + CLI — version {0} + CLI — version {0} + + + + Welcome to the + Welcome to the + + Wait for a debugger to attach before executing the command. デバッガーがアタッチされるまで待ってから、コマンドを実行します。 diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf index 71de041c0e6..a8b684e4d37 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf @@ -2,6 +2,21 @@ + + Display the animated Aspire CLI welcome banner. + Display the animated Aspire CLI welcome banner. + + + + CLI — version {0} + CLI — version {0} + + + + Welcome to the + Welcome to the + + Wait for a debugger to attach before executing the command. 명령을 실행하기 전에 디버거가 연결되기를 기다리세요. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf index 0997c599b14..5a98fbc5682 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf @@ -2,6 +2,21 @@ + + Display the animated Aspire CLI welcome banner. + Display the animated Aspire CLI welcome banner. + + + + CLI — version {0} + CLI — version {0} + + + + Welcome to the + Welcome to the + + Wait for a debugger to attach before executing the command. Przed wykonaniem polecenia poczekaj na dołączenie debugera. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf index 2b276eed5bf..636de9f5e9b 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf @@ -2,6 +2,21 @@ + + Display the animated Aspire CLI welcome banner. + Display the animated Aspire CLI welcome banner. + + + + CLI — version {0} + CLI — version {0} + + + + Welcome to the + Welcome to the + + Wait for a debugger to attach before executing the command. Aguarde um depurador anexar antes de executar o comando. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf index 5300f6e9c4b..b93ff739025 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf @@ -2,6 +2,21 @@ + + Display the animated Aspire CLI welcome banner. + Display the animated Aspire CLI welcome banner. + + + + CLI — version {0} + CLI — version {0} + + + + Welcome to the + Welcome to the + + Wait for a debugger to attach before executing the command. Дождитесь подключения отладчика, прежде чем выполнять команду. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf index 5242d9e48df..be24d24fb79 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf @@ -2,6 +2,21 @@ + + Display the animated Aspire CLI welcome banner. + Display the animated Aspire CLI welcome banner. + + + + CLI — version {0} + CLI — version {0} + + + + Welcome to the + Welcome to the + + Wait for a debugger to attach before executing the command. Komutu yürütmeden önce hata ayıklayıcısının eklenmesini bekleyin. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf index 9305acabf53..bfdd7c94dff 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf @@ -2,6 +2,21 @@ + + Display the animated Aspire CLI welcome banner. + Display the animated Aspire CLI welcome banner. + + + + CLI — version {0} + CLI — version {0} + + + + Welcome to the + Welcome to the + + Wait for a debugger to attach before executing the command. 在执行命令之前,请等待附加调试程序。 diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf index 93a273bddad..d9ce77bbfdf 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf @@ -2,6 +2,21 @@ + + Display the animated Aspire CLI welcome banner. + Display the animated Aspire CLI welcome banner. + + + + CLI — version {0} + CLI — version {0} + + + + Welcome to the + Welcome to the + + Wait for a debugger to attach before executing the command. 請等候偵錯工具連結後再執行命令。 diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs new file mode 100644 index 00000000000..50b3a507988 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs @@ -0,0 +1,229 @@ +// 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; +using Aspire.Cli.Tests.Utils; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end tests for Aspire CLI banner display functionality. +/// These tests verify that the banner appears on first run and when explicitly requested. +/// +public sealed class BannerTests(ITestOutputHelper output) +{ + [Fact] + public async Task Banner_DisplayedOnFirstRun() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(Banner_DisplayedOnFirstRun)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // Pattern to detect the ASPIRE banner text (the welcome message) + // The banner displays "Welcome to the" followed by ASCII art "ASPIRE" + var bannerPattern = new CellPatternSearcher() + .Find("Welcome to the"); + + // Pattern to detect the telemetry notice (shown on first run) + var telemetryNoticePattern = new CellPatternSearcher() + .Find("Telemetry"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Delete the first-time use sentinel file to simulate first run + // The sentinel is stored at ~/.aspire/cli/cli.firstUseSentinel + // Using 'aspire --version' instead of 'aspire --help' because help output + // is long and would scroll the banner off the terminal screen. + sequenceBuilder + .ClearFirstRunSentinel(counter) + .VerifySentinelDeleted(counter) + .ClearScreen(counter) + .Type("aspire --version") + .Enter() + .WaitUntil(s => + { + // Verify the banner appears + var hasBanner = bannerPattern.Search(s).Count > 0; + var hasTelemetryNotice = telemetryNoticePattern.Search(s).Count > 0; + + // Both should appear on first run + return hasBanner && hasTelemetryNotice; + }, TimeSpan.FromSeconds(30)) + .WaitForSuccessPrompt(counter) + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } + + [Fact] + public async Task Banner_DisplayedWithExplicitFlag() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(Banner_DisplayedWithExplicitFlag)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // Pattern to detect the ASPIRE banner welcome text + // The banner displays "Welcome to the" followed by ASCII art "ASPIRE" + var bannerPattern = new CellPatternSearcher() + .Find("Welcome to the"); + + // Pattern to detect version info in the banner + // The format is "CLI — version X.Y.Z" + var versionPattern = new CellPatternSearcher() + .Find("CLI"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Clear screen to have a clean slate for pattern matching + sequenceBuilder + .ClearScreen(counter) + .Type("aspire --banner") + .Enter() + .WaitUntil(s => + { + // Verify the banner appears with version info + var hasBanner = bannerPattern.Search(s).Count > 0; + var hasVersion = versionPattern.Search(s).Count > 0; + + return hasBanner && hasVersion; + }, TimeSpan.FromSeconds(30)) + .WaitForSuccessPrompt(counter) + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } + + [Fact] + public async Task Banner_NotDisplayedWithNoLogoFlag() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(Banner_NotDisplayedWithNoLogoFlag)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // Pattern to detect the ASPIRE banner - should NOT appear + // The banner displays "Welcome to the" followed by ASCII art "ASPIRE" + var bannerPattern = new CellPatternSearcher() + .Find("Welcome to the"); + + // Pattern to detect the help text (confirms command completed) + var helpPattern = new CellPatternSearcher() + .Find("Commands:"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Delete the first-time use sentinel file to simulate first run, + // but use --nologo to suppress the banner + sequenceBuilder + .ClearFirstRunSentinel(counter) + .ClearScreen(counter) + .Type("aspire --nologo --help") + .Enter() + .WaitUntil(s => + { + // Wait for help output to confirm command completed + var hasHelp = helpPattern.Search(s).Count > 0; + if (!hasHelp) + { + return false; + } + + // Verify the banner does NOT appear + var hasBanner = bannerPattern.Search(s).Count > 0; + if (hasBanner) + { + throw new InvalidOperationException( + "Unexpected banner displayed when --nologo flag was used!"); + } + + return true; + }, TimeSpan.FromSeconds(30)) + .WaitForSuccessPrompt(counter) + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index 39eb823bbe1..072a525208d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -320,6 +320,45 @@ internal static Hex1bTerminalInputSequenceBuilder ClearScreen( .WaitForSuccessPrompt(counter); } + /// + /// Clears the first-time use sentinel file to simulate a fresh CLI installation. + /// The sentinel is stored at ~/.aspire/cli/cli.firstUseSentinel and controls + /// whether the welcome banner and telemetry notice are displayed. + /// + /// The sequence builder. + /// The sequence counter for prompt detection. + /// The builder for chaining. + internal static Hex1bTerminalInputSequenceBuilder ClearFirstRunSentinel( + this Hex1bTerminalInputSequenceBuilder builder, + SequenceCounter counter) + { + // Remove the sentinel file to trigger first-time use behavior + return builder + .Type("rm -f ~/.aspire/cli/cli.firstUseSentinel") + .Enter() + .WaitForSuccessPrompt(counter); + } + + /// + /// Verifies that the first-time use sentinel file was successfully deleted. + /// This is a debugging aid to help diagnose banner test failures. + /// The command will fail if the sentinel file still exists after deletion. + /// + /// The sequence builder. + /// The sequence counter for prompt detection. + /// The builder for chaining. + internal static Hex1bTerminalInputSequenceBuilder VerifySentinelDeleted( + this Hex1bTerminalInputSequenceBuilder builder, + SequenceCounter counter) + { + // Verify the sentinel file doesn't exist - this will return exit code 1 (ERR) if file exists + // Using test -f which returns 0 if file exists, 1 if not. We negate with ! to fail if exists. + return builder + .Type("test ! -f ~/.aspire/cli/cli.firstUseSentinel") + .Enter() + .WaitForSuccessPrompt(counter); + } + /// /// Installs a specific GA version of the Aspire CLI using the install script. /// diff --git a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs index c8bd67c700f..b17f285f771 100644 --- a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Commands; -using Aspire.Cli.Resources; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.Configuration; @@ -75,82 +74,84 @@ public async Task NoLogoEnvironmentVariable_ParsedCorrectly(string? value, bool } [Fact] - public void FirstTimeUseNotice_DisplayedWhenSentinelDoesNotExist() + public async Task FirstTimeUseNotice_BannerDisplayedWhenSentinelDoesNotExist() { using var workspace = TemporaryWorkspace.Create(outputHelper); var errorWriter = new StringWriter(); var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false }; + var bannerService = new TestBannerService(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.ErrorTextWriter = errorWriter; options.FirstTimeUseNoticeSentinelFactory = _ => sentinel; + options.BannerServiceFactory = _ => bannerService; }); var provider = services.BuildServiceProvider(); - Program.DisplayFirstTimeUseNoticeIfNeeded(provider, noLogo: false); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: false); - var errorOutput = errorWriter.ToString(); - var lines = errorOutput.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - Assert.Contains(lines, line => line.EndsWith(RootCommandStrings.FirstTimeUseWelcome, StringComparison.Ordinal)); + Assert.True(bannerService.WasBannerDisplayed); Assert.True(sentinel.WasCreated); } [Fact] - public void FirstTimeUseNotice_NotDisplayedWhenSentinelExists() + public async Task FirstTimeUseNotice_BannerNotDisplayedWhenSentinelExists() { using var workspace = TemporaryWorkspace.Create(outputHelper); var errorWriter = new StringWriter(); var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = true }; + var bannerService = new TestBannerService(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.ErrorTextWriter = errorWriter; options.FirstTimeUseNoticeSentinelFactory = _ => sentinel; + options.BannerServiceFactory = _ => bannerService; }); var provider = services.BuildServiceProvider(); - Program.DisplayFirstTimeUseNoticeIfNeeded(provider, noLogo: false); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: false); - var errorOutput = errorWriter.ToString(); - var lines = errorOutput.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - Assert.DoesNotContain(lines, line => line.EndsWith(RootCommandStrings.FirstTimeUseWelcome, StringComparison.Ordinal)); + Assert.False(bannerService.WasBannerDisplayed); Assert.False(sentinel.WasCreated); } [Fact] - public void FirstTimeUseNotice_NotDisplayedWithNoLogoArgument() + public async Task FirstTimeUseNotice_BannerNotDisplayedWithNoLogoArgument() { using var workspace = TemporaryWorkspace.Create(outputHelper); var errorWriter = new StringWriter(); var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false }; + var bannerService = new TestBannerService(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.ErrorTextWriter = errorWriter; options.FirstTimeUseNoticeSentinelFactory = _ => sentinel; + options.BannerServiceFactory = _ => bannerService; }); var provider = services.BuildServiceProvider(); - Program.DisplayFirstTimeUseNoticeIfNeeded(provider, noLogo: true); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: true, showBanner: false); - var errorOutput = errorWriter.ToString(); - var lines = errorOutput.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - Assert.DoesNotContain(lines, line => line.EndsWith(RootCommandStrings.FirstTimeUseWelcome, StringComparison.Ordinal)); + Assert.False(bannerService.WasBannerDisplayed); Assert.True(sentinel.WasCreated); } [Fact] - public void FirstTimeUseNotice_NotDisplayedWithNoLogoEnvironmentVariable() + public async Task FirstTimeUseNotice_BannerNotDisplayedWithNoLogoEnvironmentVariable() { using var workspace = TemporaryWorkspace.Create(outputHelper); var errorWriter = new StringWriter(); var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false }; + var bannerService = new TestBannerService(); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.ErrorTextWriter = errorWriter; options.FirstTimeUseNoticeSentinelFactory = _ => sentinel; + options.BannerServiceFactory = _ => bannerService; options.ConfigurationCallback = config => { config[CliConfigNames.NoLogo] = "1"; @@ -161,11 +162,133 @@ public void FirstTimeUseNotice_NotDisplayedWithNoLogoEnvironmentVariable() var configuration = provider.GetRequiredService(); var noLogo = configuration.GetBool(CliConfigNames.NoLogo, defaultValue: false); - Program.DisplayFirstTimeUseNoticeIfNeeded(provider, noLogo); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo, showBanner: false); + + Assert.False(bannerService.WasBannerDisplayed); + Assert.True(sentinel.WasCreated); + } + + [Fact] + public async Task Banner_DisplayedWhenExplicitlyRequested() + { + // When --banner is passed, banner should show even if not first run + using var workspace = TemporaryWorkspace.Create(outputHelper); + var errorWriter = new StringWriter(); + var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = true }; // Not first run + var bannerService = new TestBannerService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ErrorTextWriter = errorWriter; + options.FirstTimeUseNoticeSentinelFactory = _ => sentinel; + options.BannerServiceFactory = _ => bannerService; + }); + var provider = services.BuildServiceProvider(); + + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: true); + Assert.True(bannerService.WasBannerDisplayed); + // Telemetry notice should NOT be shown since it's not first run var errorOutput = errorWriter.ToString(); - var lines = errorOutput.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - Assert.DoesNotContain(lines, line => line.EndsWith(RootCommandStrings.FirstTimeUseWelcome, StringComparison.Ordinal)); + Assert.DoesNotContain("Telemetry", errorOutput); + } + + [Fact] + public async Task Banner_CanBeInvokedMultipleTimes() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var bannerService = new TestBannerService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.BannerServiceFactory = _ => bannerService; + }); + var provider = services.BuildServiceProvider(); + + // Invoke multiple times (simulating multiple --banner calls) + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: true); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: true); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: true); + + Assert.Equal(3, bannerService.DisplayCount); + } + + [Fact] + public void BannerOption_HasCorrectDescription() + { + Assert.Equal("--banner", RootCommand.BannerOption.Name); + Assert.NotNull(RootCommand.BannerOption.Description); + Assert.NotEmpty(RootCommand.BannerOption.Description); + } + + [Fact] + public async Task Banner_DisplayedOnFirstRunAndExplicitRequest() + { + // When it's a first run AND user explicitly requests --banner, + // the banner should be shown (only once via the explicit request logic) + using var workspace = TemporaryWorkspace.Create(outputHelper); + var errorWriter = new StringWriter(); + var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false }; + var bannerService = new TestBannerService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ErrorTextWriter = errorWriter; + options.FirstTimeUseNoticeSentinelFactory = _ => sentinel; + options.BannerServiceFactory = _ => bannerService; + }); + var provider = services.BuildServiceProvider(); + + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: true); + + Assert.True(bannerService.WasBannerDisplayed); Assert.True(sentinel.WasCreated); + // Telemetry notice should be shown on first run + var errorOutput = errorWriter.ToString(); + Assert.Contains("Telemetry", errorOutput); + } + + [Fact] + public async Task Banner_TelemetryNoticeShownOnFirstRun() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var errorWriter = new StringWriter(); + var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false }; + var bannerService = new TestBannerService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ErrorTextWriter = errorWriter; + options.FirstTimeUseNoticeSentinelFactory = _ => sentinel; + options.BannerServiceFactory = _ => bannerService; + }); + var provider = services.BuildServiceProvider(); + + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: false); + + var errorOutput = errorWriter.ToString(); + Assert.Contains("Telemetry", errorOutput); + } + + [Fact] + public async Task Banner_TelemetryNoticeNotShownOnSubsequentRuns() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var errorWriter = new StringWriter(); + var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = true }; // Not first run + var bannerService = new TestBannerService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ErrorTextWriter = errorWriter; + options.FirstTimeUseNoticeSentinelFactory = _ => sentinel; + options.BannerServiceFactory = _ => bannerService; + }); + var provider = services.BuildServiceProvider(); + + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: false); + + var errorOutput = errorWriter.ToString(); + Assert.DoesNotContain("Telemetry", errorOutput); } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestBannerService.cs b/tests/Aspire.Cli.Tests/TestServices/TestBannerService.cs new file mode 100644 index 00000000000..8b1a499b886 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TestBannerService.cs @@ -0,0 +1,19 @@ +// 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.Interaction; + +namespace Aspire.Cli.Tests.TestServices; + +internal sealed class TestBannerService : IBannerService +{ + public bool WasBannerDisplayed { get; private set; } + public int DisplayCount { get; private set; } + + public Task DisplayBannerAsync(CancellationToken cancellationToken = default) + { + WasBannerDisplayed = true; + DisplayCount++; + return Task.CompletedTask; + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 1e742c38151..2f704584d23 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -103,6 +103,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(options.CliHostEnvironmentFactory); services.AddSingleton(options.CliDownloaderFactory); services.AddSingleton(options.FirstTimeUseNoticeSentinelFactory); + services.AddSingleton(options.BannerServiceFactory); services.AddSingleton(); services.AddSingleton(options.ProjectUpdaterFactory); services.AddSingleton(); @@ -276,6 +277,7 @@ private static FileInfo GetGlobalSettingsFile(DirectoryInfo workingDirectory) public Func SolutionLocatorFactory { get; set; } public Func CliExecutionContextFactory { get; set; } public Func FirstTimeUseNoticeSentinelFactory { get; set; } = _ => new TestFirstTimeUseNoticeSentinel(); + public Func BannerServiceFactory { get; set; } = _ => new TestBannerService(); public IProjectLocator CreateDefaultProjectLocatorFactory(IServiceProvider serviceProvider) { From 42cd6dc9db71cd6602b104862dc7dc705e5d82ef Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 3 Feb 2026 03:25:35 +0800 Subject: [PATCH 020/256] Add MCP command tests (#14283) * Add MCP command tests * More * Clean up and call test * Clean up * Clean up * Add list_resources tests. Fix including env var value from MCP * Fix merge * Fix capturing stdio --- .../AppHostAuxiliaryBackchannel.cs | 71 +--- .../Backchannel/AppHostConnectionHelper.cs | 2 +- .../Backchannel/AppHostConnectionResolver.cs | 4 +- .../AuxiliaryBackchannelMonitor.cs | 10 +- .../IAppHostAuxiliaryBackchannel.cs | 110 ++++++ .../IAuxiliaryBackchannelMonitor.cs | 8 +- .../Backchannel/ResourceSnapshotMapper.cs | 10 +- src/Aspire.Cli/Commands/AgentMcpCommand.cs | 25 +- src/Aspire.Cli/Commands/LogsCommand.cs | 8 +- src/Aspire.Cli/Commands/McpCommand.cs | 21 +- src/Aspire.Cli/Commands/McpStartCommand.cs | 29 +- src/Aspire.Cli/Commands/PsCommand.cs | 4 +- src/Aspire.Cli/Commands/ResourcesCommand.cs | 4 +- src/Aspire.Cli/Commands/RunCommand.cs | 4 +- src/Aspire.Cli/Mcp/IMcpTransportFactory.cs | 20 + src/Aspire.Cli/Mcp/KnownMcpTools.cs | 28 +- .../Mcp/StdioMcpTransportFactory.cs | 20 + src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs | 2 +- src/Aspire.Cli/Program.cs | 9 +- src/Aspire.Cli/Properties/launchSettings.json | 5 + .../Commands/AgentMcpCommandTests.cs | 374 ++++++++++++++++++ .../Mcp/ListResourcesToolTests.cs | 184 +++++++++ .../Mcp/MockPackagingService.cs | 8 +- .../Mcp/TestDocsIndexService.cs | 60 +++ .../Mcp/TestMcpServerTransport.cs | 81 ++++ .../TestAppHostAuxiliaryBackchannel.cs | 120 ++++++ .../TestAuxiliaryBackchannelMonitor.cs | 14 +- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 23 +- 28 files changed, 1093 insertions(+), 165 deletions(-) create mode 100644 src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs create mode 100644 src/Aspire.Cli/Mcp/IMcpTransportFactory.cs create mode 100644 src/Aspire.Cli/Mcp/StdioMcpTransportFactory.cs create mode 100644 tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs create mode 100644 tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs create mode 100644 tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs create mode 100644 tests/Aspire.Cli.Tests/Mcp/TestMcpServerTransport.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs diff --git a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs index c27ba375905..7cc62196b90 100644 --- a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs @@ -16,7 +16,7 @@ namespace Aspire.Cli.Backchannel; /// Represents a connection to an AppHost instance via the auxiliary backchannel. /// Encapsulates connection management and RPC method calls. /// -internal sealed class AppHostAuxiliaryBackchannel : IDisposable +internal sealed class AppHostAuxiliaryBackchannel : IAppHostAuxiliaryBackchannel { private readonly ILogger? _logger; private JsonRpc? _rpc; @@ -61,39 +61,25 @@ internal AppHostAuxiliaryBackchannel( { } - /// - /// Gets the hash identifier for this AppHost instance. - /// + /// public string Hash { get; private set; } - /// - /// Gets the socket path for this connection. - /// + /// public string SocketPath { get; } - /// - /// Gets the MCP connection information for the Dashboard. - /// + /// public DashboardMcpConnectionInfo? McpInfo { get; private set; } - /// - /// Gets the AppHost information. - /// + /// public AppHostInformation? AppHostInfo { get; private set; } - /// - /// Gets a value indicating whether this AppHost is within the scope of the MCP server's working directory. - /// + /// public bool IsInScope { get; internal set; } - /// - /// Gets the timestamp when this connection was established. - /// + /// public DateTimeOffset ConnectedAt { get; } - /// - /// Gets a value indicating whether the AppHost supports v2 API. - /// + /// public bool SupportsV2 => _capabilities.Contains(AuxiliaryBackchannelCapabilities.V2); /// @@ -227,11 +213,7 @@ internal static async Task CreateFromSocketAsync( return appHostInfo; } - /// - /// Requests the AppHost to stop gracefully. - /// - /// Cancellation token. - /// True if the RPC call succeeded, false if the method wasn't available (older AppHost). + /// public async Task StopAppHostAsync(CancellationToken cancellationToken = default) { var rpc = EnsureConnected(); @@ -275,11 +257,7 @@ await rpc.InvokeWithCancellationAsync( return mcpInfo; } - /// - /// Gets the Dashboard URLs including the login token. - /// - /// Cancellation token. - /// The Dashboard URLs state including health and login URLs. + /// public async Task GetDashboardUrlsAsync(CancellationToken cancellationToken = default) { var rpc = EnsureConnected(); @@ -303,11 +281,7 @@ await rpc.InvokeWithCancellationAsync( } } - /// - /// Gets the current resource snapshots from the AppHost. - /// - /// Cancellation token. - /// A list of resource snapshots representing current state. + /// public async Task> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default) { var rpc = EnsureConnected(); @@ -330,11 +304,7 @@ public async Task> GetResourceSnapshotsAsync(Cancellation } } - /// - /// Watches for resource snapshot changes and streams them from the AppHost. - /// - /// Cancellation token. - /// An async enumerable of resource snapshots as they change. + /// public async IAsyncEnumerable WatchResourceSnapshotsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) { var rpc = EnsureConnected(); @@ -366,13 +336,7 @@ public async IAsyncEnumerable WatchResourceSnapshotsAsync([Enu } } - /// - /// Gets resource log lines from the AppHost. - /// - /// Optional resource name. If null, streams logs from all resources (only valid when follow is true). - /// If true, continuously streams new logs. If false, returns existing logs and completes. - /// Cancellation token. - /// An async enumerable of log lines. + /// public async IAsyncEnumerable GetResourceLogsAsync( string? resourceName = null, bool follow = false, @@ -412,14 +376,7 @@ public async IAsyncEnumerable GetResourceLogsAsync( } } - /// - /// Invokes an MCP tool on a resource via the AppHost. - /// - /// The resource name. - /// The tool name. - /// Tool arguments. - /// Cancellation token. - /// A JSON representation of the MCP CallToolResult. + /// public async Task CallResourceMcpToolAsync( string resourceName, string toolName, diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionHelper.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionHelper.cs index a844a43b052..9a05fefdbc5 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionHelper.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionHelper.cs @@ -23,7 +23,7 @@ internal static class AppHostConnectionHelper /// Logger for debug output. /// Cancellation token. /// The selected connection, or null if none available. - public static async Task GetSelectedConnectionAsync( + public static async Task GetSelectedConnectionAsync( IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILogger logger, CancellationToken cancellationToken = default) diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index 575a3d52c15..790f0decaef 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs @@ -13,7 +13,7 @@ namespace Aspire.Cli.Backchannel; /// internal sealed class AppHostConnectionResult { - public AppHostAuxiliaryBackchannel? Connection { get; init; } + public IAppHostAuxiliaryBackchannel? Connection { get; init; } [MemberNotNullWhen(true, nameof(Connection))] public bool Success => Connection is not null; @@ -101,7 +101,7 @@ public async Task ResolveConnectionAsync( var inScopeConnections = connections.Where(c => c.IsInScope).ToList(); var outOfScopeConnections = connections.Where(c => !c.IsInScope).ToList(); - AppHostAuxiliaryBackchannel? selectedConnection = null; + IAppHostAuxiliaryBackchannel? selectedConnection = null; if (inScopeConnections.Count == 1) { diff --git a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs index 094cea949fb..137dad9c2eb 100644 --- a/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs +++ b/src/Aspire.Cli/Backchannel/AuxiliaryBackchannelMonitor.cs @@ -37,7 +37,7 @@ internal sealed class AuxiliaryBackchannelMonitor( /// /// Gets all active AppHost connections, flattened from all hashes. /// - public IEnumerable Connections => + public IEnumerable Connections => _connectionsByHash.Values.SelectMany(d => d.Values); /// @@ -45,7 +45,7 @@ internal sealed class AuxiliaryBackchannelMonitor( /// /// The AppHost hash. /// All connections for the given hash, or empty if none. - public IEnumerable GetConnectionsByHash(string hash) => + public IEnumerable GetConnectionsByHash(string hash) => _connectionsByHash.TryGetValue(hash, out var connections) ? connections.Values : []; /// @@ -56,7 +56,7 @@ public IEnumerable GetConnectionsByHash(string hash /// /// Gets the currently selected AppHost connection based on the selection logic. /// - public AppHostAuxiliaryBackchannel? SelectedConnection + public IAppHostAuxiliaryBackchannel? SelectedConnection { get { @@ -99,7 +99,7 @@ public AppHostAuxiliaryBackchannel? SelectedConnection /// /// Gets all connections that are within the scope of the specified working directory. /// - public IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory) + public IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory) { return Connections .Where(c => IsAppHostInScopeOfDirectory(c.AppHostInfo?.AppHostPath, workingDirectory.FullName)) @@ -483,7 +483,7 @@ private bool IsAppHostInScope(string? appHostPath) return !relativePath.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relativePath); } - private static async Task DisconnectAsync(AppHostAuxiliaryBackchannel connection) + private static async Task DisconnectAsync(IAppHostAuxiliaryBackchannel connection) { try { diff --git a/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs new file mode 100644 index 00000000000..8b91d46ff75 --- /dev/null +++ b/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs @@ -0,0 +1,110 @@ +// 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 ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Backchannel; + +/// +/// Represents a connection to an AppHost instance via the auxiliary backchannel. +/// +internal interface IAppHostAuxiliaryBackchannel : IDisposable +{ + /// + /// Gets the hash identifier for this AppHost instance. + /// + string Hash { get; } + + /// + /// Gets the socket path for this connection. + /// + string SocketPath { get; } + + /// + /// Gets the MCP connection information for the Dashboard. + /// + DashboardMcpConnectionInfo? McpInfo { get; } + + /// + /// Gets the AppHost information. + /// + AppHostInformation? AppHostInfo { get; } + + /// + /// Gets a value indicating whether this AppHost is within the scope of the MCP server's working directory. + /// + bool IsInScope { get; } + + /// + /// Gets the timestamp when this connection was established. + /// + DateTimeOffset ConnectedAt { get; } + + /// + /// Gets a value indicating whether the AppHost supports v2 API. + /// + bool SupportsV2 { get; } + + /// + /// Gets the Dashboard URLs from the AppHost. + /// + /// Cancellation token. + /// The Dashboard URLs state including health and login URLs. + Task GetDashboardUrlsAsync(CancellationToken cancellationToken = default); + + /// + /// Gets the current resource snapshots from the AppHost. + /// + /// Cancellation token. + /// A list of resource snapshots representing current state. + Task> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default); + + /// + /// Watches for resource snapshot changes and streams them from the AppHost. + /// + /// Cancellation token. + /// An async enumerable of resource snapshots as they change. + IAsyncEnumerable WatchResourceSnapshotsAsync(CancellationToken cancellationToken = default); + + /// + /// Gets resource log lines from the AppHost. + /// + /// Optional resource name. If null, streams logs from all resources (only valid when follow is true). + /// If true, continuously streams new logs. If false, returns existing logs and completes. + /// Cancellation token. + /// An async enumerable of log lines. + IAsyncEnumerable GetResourceLogsAsync( + string? resourceName = null, + bool follow = false, + CancellationToken cancellationToken = default); + + /// + /// Stops the AppHost by sending a stop request via the backchannel. + /// + /// Cancellation token. + /// True if the stop request was sent successfully, false otherwise. + Task StopAppHostAsync(CancellationToken cancellationToken = default); + + /// + /// Calls an MCP tool on a resource via the AppHost backchannel. + /// + /// The name of the resource. + /// The name of the tool to call. + /// Optional arguments to pass to the tool. + /// Cancellation token. + /// The result of the tool call. + Task CallResourceMcpToolAsync( + string resourceName, + string toolName, + IReadOnlyDictionary? arguments, + CancellationToken cancellationToken = default); + + /// + /// Gets Dashboard information using the v2 API. + /// Falls back to v1 if not supported. + /// + /// Cancellation token. + /// The Dashboard information response. + Task GetDashboardInfoV2Async(CancellationToken cancellationToken = default); +} diff --git a/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs index 75ff05d4edf..36f0a90d257 100644 --- a/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs +++ b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs @@ -11,14 +11,14 @@ internal interface IAuxiliaryBackchannelMonitor /// /// Gets all active AppHost connections. /// - IEnumerable Connections { get; } + IEnumerable Connections { get; } /// /// Gets connections for a specific AppHost hash (prefix). /// /// The AppHost hash. /// All connections for the given hash, or empty if none. - IEnumerable GetConnectionsByHash(string hash); + IEnumerable GetConnectionsByHash(string hash); /// /// Gets or sets the path to the selected AppHost. When set, this AppHost will be used for MCP operations. @@ -29,14 +29,14 @@ internal interface IAuxiliaryBackchannelMonitor /// Gets the currently selected AppHost connection based on the selection logic. /// Returns the explicitly selected AppHost, or the single in-scope AppHost, or null if none available. /// - AppHostAuxiliaryBackchannel? SelectedConnection { get; } + IAppHostAuxiliaryBackchannel? SelectedConnection { get; } /// /// Gets all connections that are within the scope of the specified working directory. /// /// The working directory to check against. /// A list of in-scope connections. - IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory); + IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory); /// /// Triggers an immediate scan of the backchannels directory for new/removed AppHosts. diff --git a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs index c06c192717b..bb05bbb3481 100644 --- a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs +++ b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs @@ -17,10 +17,11 @@ internal static class ResourceSnapshotMapper /// /// The resource snapshots to map. /// Optional base URL of the Aspire Dashboard for generating resource URLs. - public static List MapToResourceJsonList(IEnumerable snapshots, string? dashboardBaseUrl = null) + /// Whether to include environment variable values. Defaults to true. Set to false to exclude values for security reasons. + public static List MapToResourceJsonList(IEnumerable snapshots, string? dashboardBaseUrl = null, bool includeEnvironmentVariableValues = true) { var snapshotList = snapshots.ToList(); - return snapshotList.Select(s => MapToResourceJson(s, snapshotList, dashboardBaseUrl)).ToList(); + return snapshotList.Select(s => MapToResourceJson(s, snapshotList, dashboardBaseUrl, includeEnvironmentVariableValues)).ToList(); } /// @@ -29,7 +30,8 @@ public static List MapToResourceJsonList(IEnumerableThe resource snapshot to map. /// All resource snapshots for resolving relationships. /// Optional base URL of the Aspire Dashboard for generating resource URLs. - public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnlyList allSnapshots, string? dashboardBaseUrl = null) + /// Whether to include environment variable values. Defaults to true. Set to false to exclude values for security reasons. + public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnlyList allSnapshots, string? dashboardBaseUrl = null, bool includeEnvironmentVariableValues = true) { var urls = snapshot.Urls .Select(u => new ResourceUrlJson @@ -66,7 +68,7 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl .Select(e => new ResourceEnvironmentVariableJson { Name = e.Name, - Value = e.Value + Value = includeEnvironmentVariableValues ? e.Value : null }) .ToArray(); diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index dae7a9eda62..6cbd134ed1b 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -37,16 +37,23 @@ internal sealed class AgentMcpCommand : BaseCommand private McpServer? _server; private readonly IAuxiliaryBackchannelMonitor _auxiliaryBackchannelMonitor; private readonly CliExecutionContext _executionContext; + private readonly IMcpTransportFactory _transportFactory; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; private readonly IDocsIndexService _docsIndexService; + /// + /// Gets the dictionary of known MCP tools. Exposed for testing purposes. + /// + internal IReadOnlyDictionary KnownTools => _knownTools; + public AgentMcpCommand( IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, + IMcpTransportFactory transportFactory, ILoggerFactory loggerFactory, ILogger logger, IPackagingService packagingService, @@ -58,6 +65,7 @@ public AgentMcpCommand( { _auxiliaryBackchannelMonitor = auxiliaryBackchannelMonitor; _executionContext = executionContext; + _transportFactory = transportFactory; _loggerFactory = loggerFactory; _logger = logger; _docsIndexService = docsIndexService; @@ -110,7 +118,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell }, }; - await using var server = McpServer.Create(new StdioServerTransport("aspire-mcp-server"), options); + var transport = _transportFactory.CreateTransport(); + await using var server = McpServer.Create(transport, options, _loggerFactory); // Keep a reference to the server for sending notifications _server = server; @@ -145,7 +154,7 @@ private async ValueTask HandleListToolsAsync(RequestContext(); - tools.AddRange(_knownTools.Values.Select(tool => new Tool + tools.AddRange(KnownTools.Values.Select(tool => new Tool { Name = tool.Name, Description = tool.Description, @@ -187,18 +196,18 @@ private async ValueTask HandleCallToolAsync(RequestContext; + var args = request.Params?.Arguments; return await tool.CallToolAsync(null!, args, cancellationToken).ConfigureAwait(false); } if (KnownMcpTools.IsDashboardTool(toolName)) { - var args = request.Params?.Arguments as IReadOnlyDictionary; + var args = request.Params?.Arguments; return await CallDashboardToolAsync(toolName, tool, args, cancellationToken).ConfigureAwait(false); } @@ -230,7 +239,7 @@ private async ValueTask HandleCallToolAsync(RequestContext; + var args = request.Params?.Arguments; if (_logger.IsEnabled(LogLevel.Debug)) { @@ -392,13 +401,13 @@ private async Task RefreshResourceToolMapAsync(CancellationToken cancellati _resourceToolMap = refreshedMap; } - return _resourceToolMap.Count + _knownTools.Count; + return _resourceToolMap.Count + KnownTools.Count; } /// /// Gets the appropriate AppHost connection based on the selection logic. /// - private Task GetSelectedConnectionAsync(CancellationToken cancellationToken) + private Task GetSelectedConnectionAsync(CancellationToken cancellationToken) { return AppHostConnectionHelper.GetSelectedConnectionAsync(_auxiliaryBackchannelMonitor, _logger, cancellationToken); } diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs index de11ef4d8e1..c06426e13f9 100644 --- a/src/Aspire.Cli/Commands/LogsCommand.cs +++ b/src/Aspire.Cli/Commands/LogsCommand.cs @@ -182,7 +182,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } private async Task ExecuteGetAsync( - AppHostAuxiliaryBackchannel connection, + IAppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, int? tail, @@ -234,7 +234,7 @@ private async Task ExecuteGetAsync( } private async Task ExecuteWatchAsync( - AppHostAuxiliaryBackchannel connection, + IAppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, int? tail, @@ -269,7 +269,7 @@ private async Task ExecuteWatchAsync( /// Collects all logs for a resource (or all resources if resourceName is null) into a list. /// private async Task> CollectLogsAsync( - AppHostAuxiliaryBackchannel connection, + IAppHostAuxiliaryBackchannel connection, string? resourceName, CancellationToken cancellationToken) { @@ -285,7 +285,7 @@ private async Task> CollectLogsAsync( /// Gets logs for a resource (or all resources if resourceName is null) as an async enumerable. /// private async IAsyncEnumerable GetLogsAsync( - AppHostAuxiliaryBackchannel connection, + IAppHostAuxiliaryBackchannel connection, string? resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) { diff --git a/src/Aspire.Cli/Commands/McpCommand.cs b/src/Aspire.Cli/Commands/McpCommand.cs index 589804412ad..455fa01cb35 100644 --- a/src/Aspire.Cli/Commands/McpCommand.cs +++ b/src/Aspire.Cli/Commands/McpCommand.cs @@ -3,18 +3,11 @@ using System.CommandLine; using System.CommandLine.Help; -using Aspire.Cli.Agents; -using Aspire.Cli.Backchannel; using Aspire.Cli.Configuration; -using Aspire.Cli.Git; using Aspire.Cli.Interaction; -using Aspire.Cli.Mcp.Docs; -using Aspire.Cli.Packaging; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; -using Aspire.Cli.Utils.EnvironmentChecker; -using Microsoft.Extensions.Logging; namespace Aspire.Cli.Commands; @@ -28,25 +21,15 @@ public McpCommand( IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, - IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, - ILoggerFactory loggerFactory, - ILogger logger, - IAgentEnvironmentDetector agentEnvironmentDetector, - IGitRepository gitRepository, - IPackagingService packagingService, - IEnvironmentChecker environmentChecker, - IDocsSearchService docsSearchService, - IDocsIndexService docsIndexService, + McpStartCommand startCommand, + McpInitCommand initCommand, AspireCliTelemetry telemetry) : base("mcp", McpCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { // Mark as hidden - use 'aspire agent' instead Hidden = true; - var startCommand = new McpStartCommand(interactionService, features, updateNotifier, executionContext, auxiliaryBackchannelMonitor, loggerFactory, logger, packagingService, environmentChecker, docsSearchService, docsIndexService, telemetry); Subcommands.Add(startCommand); - - var initCommand = new McpInitCommand(interactionService, features, updateNotifier, executionContext, agentEnvironmentDetector, gitRepository, telemetry); Subcommands.Add(initCommand); } diff --git a/src/Aspire.Cli/Commands/McpStartCommand.cs b/src/Aspire.Cli/Commands/McpStartCommand.cs index 553c3e5ba1c..586c5033e5b 100644 --- a/src/Aspire.Cli/Commands/McpStartCommand.cs +++ b/src/Aspire.Cli/Commands/McpStartCommand.cs @@ -2,16 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using Aspire.Cli.Backchannel; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; -using Aspire.Cli.Mcp.Docs; -using Aspire.Cli.Packaging; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; -using Aspire.Cli.Utils.EnvironmentChecker; -using Microsoft.Extensions.Logging; namespace Aspire.Cli.Commands; @@ -28,30 +23,12 @@ public McpStartCommand( IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, - IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, - ILoggerFactory loggerFactory, - ILogger agentMcpLogger, - IPackagingService packagingService, - IEnvironmentChecker environmentChecker, - IDocsSearchService docsSearchService, - IDocsIndexService docsIndexService, + AgentMcpCommand agentMcpCommand, AspireCliTelemetry telemetry) : base("start", McpCommandStrings.StartCommand_Description, features, updateNotifier, executionContext, interactionService, telemetry) { - // Create the AgentMcpCommand to delegate execution to - _agentMcpCommand = new AgentMcpCommand( - interactionService, - features, - updateNotifier, - executionContext, - auxiliaryBackchannelMonitor, - loggerFactory, - agentMcpLogger, - packagingService, - environmentChecker, - docsSearchService, - docsIndexService, - telemetry); + // Use the injected AgentMcpCommand to delegate execution to + _agentMcpCommand = agentMcpCommand; } protected override bool UpdateNotificationsEnabled => false; diff --git a/src/Aspire.Cli/Commands/PsCommand.cs b/src/Aspire.Cli/Commands/PsCommand.cs index 5b8142e9b7c..51538606230 100644 --- a/src/Aspire.Cli/Commands/PsCommand.cs +++ b/src/Aspire.Cli/Commands/PsCommand.cs @@ -77,7 +77,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Scan for running AppHosts (same as ListAppHostsTool) // Skip status display for JSON output to avoid contaminating stdout - List connections; + List connections; if (format == OutputFormat.Json) { await _backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); @@ -128,7 +128,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.Success; } - private async Task> GatherAppHostInfosAsync(List connections, CancellationToken cancellationToken) + private async Task> GatherAppHostInfosAsync(List connections, CancellationToken cancellationToken) { var appHostInfos = new List(); diff --git a/src/Aspire.Cli/Commands/ResourcesCommand.cs b/src/Aspire.Cli/Commands/ResourcesCommand.cs index 66224a8aed6..52ec48e2325 100644 --- a/src/Aspire.Cli/Commands/ResourcesCommand.cs +++ b/src/Aspire.Cli/Commands/ResourcesCommand.cs @@ -144,7 +144,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } } - private async Task ExecuteSnapshotAsync(AppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, CancellationToken cancellationToken) + private async Task ExecuteSnapshotAsync(IAppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, CancellationToken cancellationToken) { // Get dashboard URL and resource snapshots in parallel var dashboardUrlsTask = connection.GetDashboardUrlsAsync(cancellationToken); @@ -186,7 +186,7 @@ private async Task ExecuteSnapshotAsync(AppHostAuxiliaryBackchannel connect return ExitCodeConstants.Success; } - private async Task ExecuteWatchAsync(AppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, CancellationToken cancellationToken) + private async Task ExecuteWatchAsync(IAppHostAuxiliaryBackchannel connection, string? resourceName, OutputFormat format, CancellationToken cancellationToken) { // Get dashboard URL first for generating resource links var dashboardUrls = await connection.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 0d7ce4aa4a1..3f30e98ae3d 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -736,7 +736,7 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? var childExitedEarly = false; var childExitCode = 0; - async Task StartAndWaitForBackchannelAsync() + async Task StartAndWaitForBackchannelAsync() { // Failure mode 2: Failed to spawn child process try @@ -820,7 +820,7 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? } // For JSON output, skip the status spinner to avoid contaminating stdout - AppHostAuxiliaryBackchannel? backchannel; + IAppHostAuxiliaryBackchannel? backchannel; if (format == OutputFormat.Json) { backchannel = await StartAndWaitForBackchannelAsync(); diff --git a/src/Aspire.Cli/Mcp/IMcpTransportFactory.cs b/src/Aspire.Cli/Mcp/IMcpTransportFactory.cs new file mode 100644 index 00000000000..384871c8066 --- /dev/null +++ b/src/Aspire.Cli/Mcp/IMcpTransportFactory.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Mcp; + +/// +/// Factory for creating MCP transport instances. +/// This allows transport creation to be deferred until actually needed, +/// avoiding issues with stdin/stdout being captured too early. +/// +internal interface IMcpTransportFactory +{ + /// + /// Creates a new MCP transport instance. + /// + /// A new transport instance. + ITransport CreateTransport(); +} diff --git a/src/Aspire.Cli/Mcp/KnownMcpTools.cs b/src/Aspire.Cli/Mcp/KnownMcpTools.cs index 3e90912de4d..73ecfc50cb1 100644 --- a/src/Aspire.Cli/Mcp/KnownMcpTools.cs +++ b/src/Aspire.Cli/Mcp/KnownMcpTools.cs @@ -25,21 +25,21 @@ internal static class KnownMcpTools internal const string GetDoc = "get_doc"; public static bool IsLocalTool(string toolName) => toolName is - KnownMcpTools.SelectAppHost or - KnownMcpTools.ListAppHosts or - KnownMcpTools.ListIntegrations or - KnownMcpTools.Doctor or - KnownMcpTools.RefreshTools or - KnownMcpTools.ListDocs or - KnownMcpTools.SearchDocs or - KnownMcpTools.GetDoc or - KnownMcpTools.ListResources; + SelectAppHost or + ListAppHosts or + ListIntegrations or + Doctor or + RefreshTools or + ListDocs or + SearchDocs or + GetDoc or + ListResources; public static bool IsDashboardTool(string toolName) => toolName is - KnownMcpTools.ListConsoleLogs or - KnownMcpTools.ExecuteResourceCommand or - KnownMcpTools.ListStructuredLogs or - KnownMcpTools.ListTraces or - KnownMcpTools.ListTraceStructuredLogs; + ListConsoleLogs or + ExecuteResourceCommand or + ListStructuredLogs or + ListTraces or + ListTraceStructuredLogs; } diff --git a/src/Aspire.Cli/Mcp/StdioMcpTransportFactory.cs b/src/Aspire.Cli/Mcp/StdioMcpTransportFactory.cs new file mode 100644 index 00000000000..9e146d58f6f --- /dev/null +++ b/src/Aspire.Cli/Mcp/StdioMcpTransportFactory.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Aspire.Cli.Mcp; + +/// +/// Default factory that creates instances for production use. +/// +internal sealed class StdioMcpTransportFactory(ILoggerFactory? loggerFactory) : IMcpTransportFactory +{ + /// + public ITransport CreateTransport() + { + return new StdioServerTransport("aspire-mcp-server", loggerFactory); + } +} diff --git a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs index 508f5cce451..5eb3a39e7b7 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs @@ -89,7 +89,7 @@ public override async ValueTask CallToolAsync(ModelContextProtoc // Use the dashboard base URL if available var dashboardBaseUrl = dashboardUrls?.BaseUrlWithLoginToken; - var resources = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardBaseUrl); + var resources = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardBaseUrl, includeEnvironmentVariableValues: false); var resourceGraphData = JsonSerializer.Serialize(resources.ToArray(), ListResourcesToolJsonContext.RelaxedEscaping.ResourceJsonArray); var response = $""" diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 04cf93122e1..c84d866f125 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -20,6 +20,8 @@ using Aspire.Cli.DotNet; using Aspire.Cli.Git; using Aspire.Cli.Interaction; +using Aspire.Cli.Mcp; +using Aspire.Cli.Mcp.Docs; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Aspire.Cli.Projects; @@ -29,7 +31,6 @@ using Aspire.Cli.Templating; using Aspire.Cli.Utils; using Aspire.Cli.Utils.EnvironmentChecker; -using Aspire.Cli.Mcp.Docs; using Aspire.Hosting; using Aspire.Shared; using Microsoft.Extensions.Configuration; @@ -239,6 +240,10 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // MCP server transport factory - creates transport only when needed to avoid + // capturing stdin/stdout before the MCP server command is actually executed. + builder.Services.AddSingleton(); + // Commands. builder.Services.AddTransient(); builder.Services.AddTransient(); @@ -257,6 +262,8 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Properties/launchSettings.json b/src/Aspire.Cli/Properties/launchSettings.json index 238071565f1..3f8c48abb40 100644 --- a/src/Aspire.Cli/Properties/launchSettings.json +++ b/src/Aspire.Cli/Properties/launchSettings.json @@ -46,6 +46,11 @@ "commandLineArgs": "deploy", "workingDirectory": "../../playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost" }, + "new": { + "commandName": "Project", + "dotnetRunMessages": true, + "commandLineArgs": "new -d" + }, "run-testapphost": { "commandName": "Project", "dotnetRunMessages": true, diff --git a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs new file mode 100644 index 00000000000..1dbbe997ff6 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs @@ -0,0 +1,374 @@ +// 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.Backchannel; +using Aspire.Cli.Commands; +using Aspire.Cli.Mcp; +using Aspire.Cli.Tests.Mcp; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using System.Threading.Channels; + +namespace Aspire.Cli.Tests.Commands; + +/// +/// In-process unit tests for AgentMcpCommand that test the MCP server functionality +/// without starting a new CLI process. The IO communication between the MCP server +/// and test client is abstracted using in-memory pipes via DI. +/// +public class AgentMcpCommandTests(ITestOutputHelper outputHelper) : IAsyncLifetime +{ + private TemporaryWorkspace _workspace = null!; + private ServiceProvider _serviceProvider = null!; + private TestMcpServerTransport _testTransport = null!; + private McpClient _mcpClient = null!; + private AgentMcpCommand _agentMcpCommand = null!; + private Task _serverRunTask = null!; + private CancellationTokenSource _cts = null!; + private ILoggerFactory _loggerFactory = null!; + private TestAuxiliaryBackchannelMonitor _backchannelMonitor = null!; + + public async ValueTask InitializeAsync() + { + _cts = new CancellationTokenSource(); + _workspace = TemporaryWorkspace.Create(outputHelper); + + // Create the test transport with in-memory pipes + _loggerFactory = LoggerFactory.Create(builder => builder.AddXunit(outputHelper)); + _testTransport = new TestMcpServerTransport(_loggerFactory); + + // Create a backchannel monitor that we can configure for resource tool tests + _backchannelMonitor = new TestAuxiliaryBackchannelMonitor(); + + // Create services using CliTestHelper with custom MCP transport and test docs service + var services = CliTestHelper.CreateServiceCollection(_workspace, outputHelper, options => + { + // Override the MCP transport factory with our test transport (which implements IMcpTransportFactory) + options.McpServerTransportFactory = _ => _testTransport; + // Override the docs index service with a test implementation that doesn't make network calls + options.DocsIndexServiceFactory = _ => new TestDocsIndexService(); + // Override the backchannel monitor with our test implementation + options.AuxiliaryBackchannelMonitorFactory = _ => _backchannelMonitor; + }); + + _serviceProvider = services.BuildServiceProvider(); + + // Get the AgentMcpCommand from DI and start the server + _agentMcpCommand = _serviceProvider.GetRequiredService(); + var rootCommand = _serviceProvider.GetRequiredService(); + var parseResult = rootCommand.Parse("agent mcp"); + + // Start the MCP server in the background + _serverRunTask = Task.Run(async () => + { + try + { + await _agentMcpCommand.ExecuteCommandAsync(parseResult, _cts.Token); + } + catch (OperationCanceledException) + { + // Expected when cancellation is requested + } + }, _cts.Token); + + // Wait a brief moment for the server to start + await Task.Delay(100, _cts.Token); + + // Create and connect the MCP client using the test transport's client side + _mcpClient = await _testTransport.CreateClientAsync(_loggerFactory, _cts.Token); + } + + public async ValueTask DisposeAsync() + { + if (_mcpClient is not null) + { + await _mcpClient.DisposeAsync(); + } + + await _cts.CancelAsync(); + + try + { + if (_serverRunTask is not null) + { + await _serverRunTask.WaitAsync(TimeSpan.FromSeconds(2)); + } + } + catch (OperationCanceledException) + { + // Expected when cancellation is requested + } + catch (TimeoutException) + { + // Server didn't stop in time, but that's OK for tests + } + + _testTransport?.Dispose(); + if (_serviceProvider is not null) + { + await _serviceProvider.DisposeAsync(); + } + _workspace?.Dispose(); + _loggerFactory?.Dispose(); + _cts?.Dispose(); + } + + [Fact] + public async Task McpServer_ListTools_ReturnsExpectedTools() + { + // Act + var tools = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout(); + + // Assert + Assert.NotNull(tools); + Assert.Collection(tools.OrderBy(t => t.Name), + tool => AssertTool(KnownMcpTools.Doctor, tool), + tool => AssertTool(KnownMcpTools.ExecuteResourceCommand, tool), + tool => AssertTool(KnownMcpTools.GetDoc, tool), + tool => AssertTool(KnownMcpTools.ListAppHosts, tool), + tool => AssertTool(KnownMcpTools.ListConsoleLogs, tool), + tool => AssertTool(KnownMcpTools.ListDocs, tool), + tool => AssertTool(KnownMcpTools.ListIntegrations, tool), + tool => AssertTool(KnownMcpTools.ListResources, tool), + tool => AssertTool(KnownMcpTools.ListStructuredLogs, tool), + tool => AssertTool(KnownMcpTools.ListTraceStructuredLogs, tool), + tool => AssertTool(KnownMcpTools.ListTraces, tool), + tool => AssertTool(KnownMcpTools.RefreshTools, tool), + tool => AssertTool(KnownMcpTools.SearchDocs, tool), + tool => AssertTool(KnownMcpTools.SelectAppHost, tool)); + + static void AssertTool(string expectedName, McpClientTool tool) + { + Assert.Equal(expectedName, tool.Name); + Assert.False(string.IsNullOrEmpty(tool.Description), $"Tool '{tool.Name}' should have a description"); + Assert.NotEqual(default, tool.JsonSchema); + } + } + + [Fact] + public async Task McpServer_ListTools_IncludesResourceMcpTools() + { + // Arrange - Create a mock backchannel with a resource that has MCP tools + var mockBackchannel = new TestAppHostAuxiliaryBackchannel + { + Hash = "test-apphost-hash", + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + ProcessId = 12345 + }, + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = "test-resource", + DisplayName = "Test Resource", + ResourceType = "Container", + State = "Running", + McpServer = new ResourceSnapshotMcpServer + { + EndpointUrl = "http://localhost:8080/mcp", + Tools = + [ + new Tool + { + Name = "resource_tool_one", + Description = "A test tool from the resource" + }, + new Tool + { + Name = "resource_tool_two", + Description = "Another test tool from the resource" + } + ] + } + } + ] + }; + + // Register the mock backchannel + _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); + + // First call refresh_tools to discover the resource tools + await _mcpClient.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: _cts.Token).DefaultTimeout(); + + // Act - List all tools + var tools = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout(); + + // Assert - Verify resource tools are included + Assert.NotNull(tools); + + // The resource tools should be exposed with a prefixed name: {resource_name}_{tool_name} + // Resource name "test-resource" becomes "test_resource" (dashes replaced with underscores) + var resourceToolOne = tools.FirstOrDefault(t => t.Name == "test_resource_resource_tool_one"); + var resourceToolTwo = tools.FirstOrDefault(t => t.Name == "test_resource_resource_tool_two"); + + Assert.NotNull(resourceToolOne); + Assert.NotNull(resourceToolTwo); + + Assert.Equal("A test tool from the resource", resourceToolOne.Description); + Assert.Equal("Another test tool from the resource", resourceToolTwo.Description); + } + + [Fact] + public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult() + { + // Arrange - Create a mock backchannel with a resource that has MCP tools + var expectedToolResult = "Tool executed successfully with custom data"; + string? callResourceName = null; + string? callToolName = null; + + var mockBackchannel = new TestAppHostAuxiliaryBackchannel + { + Hash = "test-apphost-hash", + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + ProcessId = 12345 + }, + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = "my-resource", + DisplayName = "My Resource", + ResourceType = "Container", + State = "Running", + McpServer = new ResourceSnapshotMcpServer + { + EndpointUrl = "http://localhost:8080/mcp", + Tools = + [ + new Tool + { + Name = "do_something", + Description = "Does something useful" + } + ] + } + } + ], + // Configure the handler to capture the arguments and return a specific result + CallResourceMcpToolHandler = (resourceName, toolName, arguments, ct) => + { + callResourceName = resourceName; + callToolName = toolName; + return Task.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Text = expectedToolResult }] + }); + } + }; + + // Register the mock backchannel + _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); + + // First call refresh_tools to discover the resource tools + await _mcpClient.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: _cts.Token).DefaultTimeout(); + + // Act - Call the resource tool (name format: {resource_name}_{tool_name} with dashes replaced by underscores) + var result = await _mcpClient.CallToolAsync( + "my_resource_do_something", + cancellationToken: _cts.Token).DefaultTimeout(); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}"); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Equal(expectedToolResult, textContent.Text); + + // Verify the handler was called with the correct resource and tool names + Assert.Equal("my-resource", callResourceName); + Assert.Equal("do_something", callToolName); + } + + [Fact] + public async Task McpServer_CallTool_ListAppHosts_ReturnsResult() + { + // Act + var result = await _mcpClient.CallToolAsync( + KnownMcpTools.ListAppHosts, + cancellationToken: _cts.Token).DefaultTimeout(); + + // Assert + Assert.NotNull(result); + Assert.Null(result.IsError); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("App hosts", textContent.Text); + } + + [Fact] + public async Task McpServer_CallTool_RefreshTools_ReturnsResult() + { + // Arrange - Set up a channel to receive the ToolListChanged notification + var notificationChannel = Channel.CreateUnbounded(); + await using var notificationHandler = _mcpClient.RegisterNotificationHandler( + NotificationMethods.ToolListChangedNotification, + (notification, cancellationToken) => + { + notificationChannel.Writer.TryWrite(notification); + return default; + }); + + // Act + var result = await _mcpClient.CallToolAsync( + KnownMcpTools.RefreshTools, + cancellationToken: _cts.Token).DefaultTimeout(); + + // Assert - Verify result + Assert.NotNull(result); + Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}"); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + + // Verify the exact text content with the correct tool count + var expectedToolCount = _agentMcpCommand.KnownTools.Count; + Assert.Equal($"Tools refreshed: {expectedToolCount} tools available", textContent.Text); + + // Assert - Verify the ToolListChanged notification was received + var notification = await notificationChannel.Reader.ReadAsync(_cts.Token).AsTask().DefaultTimeout(); + Assert.NotNull(notification); + Assert.Equal(NotificationMethods.ToolListChangedNotification, notification.Method); + } + + [Fact] + public async Task McpServer_CallTool_UnknownTool_ReturnsError() + { + // Act & Assert - The MCP client throws McpProtocolException when the server returns an error + var exception = await Assert.ThrowsAsync(async () => + await _mcpClient.CallToolAsync( + "nonexistent_tool_that_does_not_exist", + cancellationToken: _cts.Token).DefaultTimeout()); + + Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode); + } + + private static string GetResultText(CallToolResult result) + { + if (result.Content?.FirstOrDefault() is TextContentBlock textContent) + { + return textContent.Text; + } + + return string.Empty; + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs new file mode 100644 index 00000000000..3dffdbf757f --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs @@ -0,0 +1,184 @@ +// 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.Mcp.Tools; +using Aspire.Cli.Tests.TestServices; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Cli.Tests.Mcp; + +public class ListResourcesToolTests +{ + [Fact] + public async Task ListResourcesTool_ThrowsException_WhenNoAppHostRunning() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var tool = new ListResourcesTool(monitor, NullLogger.Instance); + + var exception = await Assert.ThrowsAsync( + () => tool.CallToolAsync(null!, null, CancellationToken.None).AsTask()).DefaultTimeout(); + + Assert.Contains("No Aspire AppHost", exception.Message); + } + + [Fact] + public async Task ListResourcesTool_ReturnsNoResourcesFound_WhenSnapshotsAreEmpty() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + ResourceSnapshots = [], + DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "http://localhost:18888" } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListResourcesTool(monitor, NullLogger.Instance); + var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("No resources found", textContent.Text); + } + + [Fact] + public async Task ListResourcesTool_ReturnsMultipleResources() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = "api-service", + DisplayName = "API Service", + ResourceType = "Project", + State = "Running" + }, + new ResourceSnapshot + { + Name = "redis", + DisplayName = "Redis", + ResourceType = "Container", + State = "Running" + }, + new ResourceSnapshot + { + Name = "postgres", + DisplayName = "PostgreSQL", + ResourceType = "Container", + State = "Starting" + } + ], + DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "http://localhost:18888" } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListResourcesTool(monitor, NullLogger.Instance); + var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + + Assert.Contains("api-service", textContent.Text); + Assert.Contains("redis", textContent.Text); + Assert.Contains("postgres", textContent.Text); + } + + [Fact] + public async Task ListResourcesTool_IncludesEnvironmentVariableNamesButNotValues() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = "api-service", + ResourceType = "Project", + State = "Running", + EnvironmentVariables = + [ + new ResourceSnapshotEnvironmentVariable { Name = "ASPNETCORE_ENVIRONMENT", Value = "Development", IsFromSpec = true }, + new ResourceSnapshotEnvironmentVariable { Name = "ConnectionStrings__Database", Value = "SuperSecretPassword123", IsFromSpec = true } + ] + } + ], + DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "http://localhost:18888" } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListResourcesTool(monitor, NullLogger.Instance); + var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + + var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + + // Environment variable names should be included + Assert.Contains("ASPNETCORE_ENVIRONMENT", textContent.Text); + Assert.Contains("ConnectionStrings__Database", textContent.Text); + + // Environment variable values should NOT be included (to protect sensitive information) + Assert.DoesNotContain("Development", textContent.Text); + Assert.DoesNotContain("SuperSecretPassword123", textContent.Text); + } + + [Fact] + public async Task ListResourcesTool_ReturnsValidJson() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = "api-service", + DisplayName = "API Service", + ResourceType = "Project", + State = "Running", + StateStyle = "success", + HealthStatus = "Healthy", + Urls = + [ + new ResourceSnapshotUrl { Name = "http", Url = "http://localhost:5000" } + ] + } + ], + DashboardUrlsState = new DashboardUrlsState { BaseUrlWithLoginToken = "http://localhost:18888" } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListResourcesTool(monitor, NullLogger.Instance); + var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + + var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + + // Extract JSON portion from the response (after "# RESOURCE DATA") + var jsonStartIndex = textContent.Text.IndexOf('['); + var jsonEndIndex = textContent.Text.LastIndexOf(']') + 1; + Assert.True(jsonStartIndex >= 0 && jsonEndIndex > jsonStartIndex, "Response should contain JSON array"); + + var jsonPortion = textContent.Text.Substring(jsonStartIndex, jsonEndIndex - jsonStartIndex); + + // Verify it's valid JSON + var jsonDoc = JsonDocument.Parse(jsonPortion); + Assert.Equal(JsonValueKind.Array, jsonDoc.RootElement.ValueKind); + Assert.Equal(1, jsonDoc.RootElement.GetArrayLength()); + + var resource = jsonDoc.RootElement[0]; + Assert.Equal("api-service", resource.GetProperty("name").GetString()); + Assert.Equal("API Service", resource.GetProperty("display_name").GetString()); + Assert.Equal("Project", resource.GetProperty("resource_type").GetString()); + Assert.Equal("Running", resource.GetProperty("state").GetString()); + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs index 4e51587826e..2733ad4a126 100644 --- a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs +++ b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs @@ -61,17 +61,17 @@ public static CliExecutionContext CreateTestContext() internal sealed class MockAuxiliaryBackchannelMonitor : IAuxiliaryBackchannelMonitor { - public IEnumerable Connections => []; + public IEnumerable Connections => []; - public IEnumerable GetConnectionsByHash(string hash) => []; + public IEnumerable GetConnectionsByHash(string hash) => []; public string? SelectedAppHostPath { get; set; } - public AppHostAuxiliaryBackchannel? SelectedConnection => null; + public IAppHostAuxiliaryBackchannel? SelectedConnection => null; public Task ScanAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; - public IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory) + public IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory) { // Return empty list by default (no in-scope AppHosts) return []; diff --git a/tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs b/tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs new file mode 100644 index 00000000000..e464cdf51c9 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs @@ -0,0 +1,60 @@ +// 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.Mcp.Docs; + +namespace Aspire.Cli.Tests.Mcp; + +/// +/// Test implementation of IDocsIndexService that returns canned data without making network calls. +/// +internal sealed class TestDocsIndexService : IDocsIndexService +{ + private readonly List _documents = + [ + new DocsListItem { Slug = "getting-started", Title = "Getting Started", Summary = "Learn how to get started with Aspire" }, + new DocsListItem { Slug = "fundamentals/app-host", Title = "App Host", Summary = "Learn about the Aspire app host" }, + new DocsListItem { Slug = "deployment/azure", Title = "Deploy to Azure", Summary = "Deploy your Aspire app to Azure" }, + ]; + + public ValueTask EnsureIndexedAsync(CancellationToken cancellationToken = default) + { + return ValueTask.CompletedTask; + } + + public ValueTask GetDocumentAsync(string slug, string? section = null, CancellationToken cancellationToken = default) + { + var doc = _documents.FirstOrDefault(d => d.Slug == slug); + if (doc is null) + { + return ValueTask.FromResult(null); + } + + var content = $"# {doc.Title}\n\n{doc.Summary}\n\nThis is test content for the document."; + return ValueTask.FromResult(new DocsContent + { + Slug = doc.Slug, + Title = doc.Title, + Summary = doc.Summary, + Content = content, + Sections = [] + }); + } + + public ValueTask> ListDocumentsAsync(CancellationToken cancellationToken = default) + { + return ValueTask.FromResult>(_documents); + } + + public ValueTask> SearchAsync(string query, int topK = 10, CancellationToken cancellationToken = default) + { + var results = _documents + .Where(d => (d.Title?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false) || + (d.Summary?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false)) + .Take(topK) + .Select(d => new DocsSearchResult { Slug = d.Slug, Title = d.Title, Summary = d.Summary, Score = 1.0f }) + .ToList(); + + return ValueTask.FromResult>(results); + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/TestMcpServerTransport.cs b/tests/Aspire.Cli.Tests/Mcp/TestMcpServerTransport.cs new file mode 100644 index 00000000000..8cdde5c7825 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/TestMcpServerTransport.cs @@ -0,0 +1,81 @@ +// 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.Pipelines; +using Aspire.Cli.Mcp; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Aspire.Cli.Tests.Mcp; + +/// +/// A test helper that creates in-memory pipe-based transports for testing the MCP server. +/// Provides both the server transport (for DI injection) and a way to create a connected client. +/// Implements so it can be registered in DI. +/// +internal sealed class TestMcpServerTransport : IMcpTransportFactory, IDisposable +{ + private readonly ILoggerFactory? _loggerFactory; + + /// + /// The pipe for sending data from client to server. + /// + public Pipe ClientToServerPipe { get; } = new(); + + /// + /// The pipe for sending data from server to client. + /// + public Pipe ServerToClientPipe { get; } = new(); + + /// + /// The server transport that should be registered in DI. + /// + public ITransport ServerTransport { get; } + + public TestMcpServerTransport(ILoggerFactory? loggerFactory = null) + { + _loggerFactory = loggerFactory; + ServerTransport = new StreamServerTransport( + ClientToServerPipe.Reader.AsStream(), + ServerToClientPipe.Writer.AsStream(), + serverName: "aspire-mcp-server", + loggerFactory: _loggerFactory); + } + + /// + public ITransport CreateTransport() => ServerTransport; + + /// + /// Creates an MCP client that connects to the server through the in-memory pipes. + /// + /// Logger factory for the client. + /// Cancellation token. + /// A connected MCP client. + public Task CreateClientAsync(ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default) + { + var clientTransport = new StreamClientTransport( + serverInput: ClientToServerPipe.Writer.AsStream(), + serverOutput: ServerToClientPipe.Reader.AsStream(), + loggerFactory: loggerFactory); + + return McpClient.CreateAsync(clientTransport, loggerFactory: loggerFactory, cancellationToken: cancellationToken); + } + + /// + /// Completes the pipes to clean up resources. + /// + public void CompletePipes() + { + ClientToServerPipe.Reader.Complete(); + ClientToServerPipe.Writer.Complete(); + ServerToClientPipe.Reader.Complete(); + ServerToClientPipe.Writer.Complete(); + } + + public void Dispose() + { + CompletePipes(); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs new file mode 100644 index 00000000000..b13da223aec --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; +using System.Text.Json; +using Aspire.Cli.Backchannel; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// A test implementation of IAppHostAuxiliaryBackchannel for unit testing. +/// +internal sealed class TestAppHostAuxiliaryBackchannel : IAppHostAuxiliaryBackchannel +{ + public string Hash { get; set; } = "test-hash"; + public string SocketPath { get; set; } = "/tmp/test.sock"; + public DashboardMcpConnectionInfo? McpInfo { get; set; } + public AppHostInformation? AppHostInfo { get; set; } + public bool IsInScope { get; set; } = true; + public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow; + public bool SupportsV2 { get; set; } = true; + + /// + /// Gets or sets the resource snapshots to return from GetResourceSnapshotsAsync and WatchResourceSnapshotsAsync. + /// + public List ResourceSnapshots { get; set; } = []; + + /// + /// Gets or sets the dashboard URLs state to return from GetDashboardUrlsAsync. + /// + public DashboardUrlsState? DashboardUrlsState { get; set; } + + /// + /// Gets or sets the log lines to return from GetResourceLogsAsync. + /// + public List LogLines { get; set; } = []; + + /// + /// Gets or sets the result to return from StopAppHostAsync. + /// + public bool StopAppHostResult { get; set; } = true; + + /// + /// Gets or sets the function to call when CallResourceMcpToolAsync is invoked. + /// + public Func?, CancellationToken, Task>? CallResourceMcpToolHandler { get; set; } + + public Task GetDashboardUrlsAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(DashboardUrlsState); + } + + public Task> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(ResourceSnapshots); + } + + public async IAsyncEnumerable WatchResourceSnapshotsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var snapshot in ResourceSnapshots) + { + yield return snapshot; + } + await Task.CompletedTask; + } + + public async IAsyncEnumerable GetResourceLogsAsync( + string? resourceName = null, + bool follow = false, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var lines = resourceName is null + ? LogLines + : LogLines.Where(l => l.ResourceName == resourceName); + + foreach (var line in lines) + { + yield return line; + } + await Task.CompletedTask; + } + + public Task StopAppHostAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(StopAppHostResult); + } + + public Task CallResourceMcpToolAsync( + string resourceName, + string toolName, + IReadOnlyDictionary? arguments, + CancellationToken cancellationToken = default) + { + if (CallResourceMcpToolHandler is not null) + { + return CallResourceMcpToolHandler(resourceName, toolName, arguments, cancellationToken); + } + + return Task.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Text = $"Mock result for {resourceName}/{toolName}" }] + }); + } + + /// + /// Gets or sets the dashboard info response to return from GetDashboardInfoV2Async. + /// + public GetDashboardInfoResponse? DashboardInfoResponse { get; set; } + + public Task GetDashboardInfoV2Async(CancellationToken cancellationToken = default) + { + return Task.FromResult(DashboardInfoResponse); + } + + public void Dispose() + { + // Nothing to dispose in the test implementation + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs b/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs index b7af9535c01..6fa2f3fae9a 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAuxiliaryBackchannelMonitor.cs @@ -9,12 +9,12 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class TestAuxiliaryBackchannelMonitor : IAuxiliaryBackchannelMonitor { // Outer key: hash, Inner key: socketPath - private readonly ConcurrentDictionary> _connectionsByHash = new(); + private readonly ConcurrentDictionary> _connectionsByHash = new(); - public IEnumerable Connections => + public IEnumerable Connections => _connectionsByHash.Values.SelectMany(d => d.Values); - public IEnumerable GetConnectionsByHash(string hash) => + public IEnumerable GetConnectionsByHash(string hash) => _connectionsByHash.TryGetValue(hash, out var connections) ? connections.Values : []; public string? SelectedAppHostPath { get; set; } @@ -33,7 +33,7 @@ public Task ScanAsync(CancellationToken cancellationToken = default) return Task.CompletedTask; } - public AppHostAuxiliaryBackchannel? SelectedConnection + public IAppHostAuxiliaryBackchannel? SelectedConnection { get { @@ -73,7 +73,7 @@ public AppHostAuxiliaryBackchannel? SelectedConnection } } - public IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory) + public IReadOnlyList GetConnectionsForWorkingDirectory(DirectoryInfo workingDirectory) { return Connections .Where(c => IsAppHostInScopeOfDirectory(c.AppHostInfo?.AppHostPath, workingDirectory.FullName)) @@ -96,9 +96,9 @@ private static bool IsAppHostInScopeOfDirectory(string? appHostPath, string work return !relativePath.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relativePath); } - public void AddConnection(string hash, string socketPath, AppHostAuxiliaryBackchannel connection) + public void AddConnection(string hash, string socketPath, IAppHostAuxiliaryBackchannel connection) { - var connectionsDict = _connectionsByHash.GetOrAdd(hash, _ => new ConcurrentDictionary()); + var connectionsDict = _connectionsByHash.GetOrAdd(hash, _ => new ConcurrentDictionary()); connectionsDict[socketPath] = connection; } diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 2f704584d23..a88ed74a1d9 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -10,6 +10,7 @@ using Aspire.Cli.DotNet; using Aspire.Cli.Git; using Aspire.Cli.Interaction; +using Aspire.Cli.Mcp; using Aspire.Cli.Mcp.Docs; using Aspire.Cli.NuGet; using Aspire.Cli.Projects; @@ -24,11 +25,11 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Spectre.Console; using Aspire.Cli.Configuration; using Aspire.Cli.Utils; using Aspire.Cli.Utils.EnvironmentChecker; -using Microsoft.Extensions.Logging.Abstractions; using Aspire.Cli.Packaging; using Aspire.Cli.Caching; @@ -133,11 +134,14 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(); services.AddSingleton(); + // MCP server transport + services.AddSingleton(options.McpServerTransportFactory); + // MCP docs services - use test doubles services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(options.DocsIndexServiceFactory); services.AddSingleton(); services.AddTransient(); @@ -158,6 +162,8 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -455,6 +461,19 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser { return new TestAppHostServerSessionFactory(); }; + + public Func McpServerTransportFactory { get; set; } = (IServiceProvider serviceProvider) => + { + var loggerFactory = serviceProvider.GetService(); + return new StdioMcpTransportFactory(loggerFactory ?? NullLoggerFactory.Instance); + }; + + public Func DocsIndexServiceFactory { get; set; } = (IServiceProvider serviceProvider) => + { + var fetcher = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + return new DocsIndexService(fetcher, logger); + }; } internal sealed class TestOutputTextWriter : TextWriter From 5e018b0264e2e3e29bd5d7a3ff81ff6800da5f93 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 3 Feb 2026 07:26:53 +1100 Subject: [PATCH 021/256] chore: Trigger deployment E2E test workflow (#14166) * chore: Trigger deployment E2E test workflow * fix: Add completion comment to deployment test workflow When deployment tests are triggered via /deployment-test command on a PR, the workflow now posts a completion comment with the result (success/failure/cancelled/skipped) back to the PR. * fix: Use single-line template literal to fix YAML parsing * feat: Add asciinema recording links to deployment test PR comment When deployment tests complete, the PR comment now includes: - Links to asciinema.org recordings for each test - Recordings are uploaded from the deployment-test-recordings-* artifacts - Table format matching the CLI E2E recording comment style * feat: Add Python FastAPI deployment E2E test Adds PythonFastApiDeploymentTests that: - Creates a new project using the Python React + FastAPI template - Adds Azure Container Apps hosting - Deploys to ACA - Verifies endpoints are accessible * fix: Use correct template name for Python FastAPI test - Template is 'Starter App (FastAPI/React)' not 'Python React + FastAPI' - Use Find() instead of FindPattern() for special characters - Match pattern from working CLI E2E test * fix: Add missing Redis Cache prompt handling for Python template The Python FastAPI template prompts for Redis Cache after the localhost URLs prompt. Added the missing wait and response for this prompt. * fix: Correct Python single-file AppHost structure handling - Python template uses single-file AppHost (apphost.cs in project root) - No {projectName}.AppHost subdirectory needed - Resource group naming uses project name directly (no .AppHost suffix) - File is apphost.cs (lowercase) not AppHost.cs * fix: Use eastus2 region for Python test to avoid quota conflicts The subscription has a limit of 1 Container App Environment per region. Using eastus2 for Python tests while other tests use westus3. * test: Temporarily disable ACA starter test to verify Python test in isolation * fix: Set ASPNETCORE_APPLICATIONNAME for single-file AppHost deployment Single-file AppHosts default ApplicationName to 'apphost' which causes resource naming collisions. Explicitly set ASPNETCORE_APPLICATIONNAME to the project name for proper resource group naming. * Re-enable AcaStarterDeploymentTests to run both deployment tests * Use AZURE__RESOURCEGROUP for consistent resource group naming in deployment tests - Add GetRunId() and GetRunAttempt() helpers to get GitHub Actions context - Add GenerateResourceGroupName() to create pattern: e2e-[testcasename]-[runid]-[attempt] - Update AcaStarterDeploymentTests to use AZURE__RESOURCEGROUP - Update PythonFastApiDeploymentTests to use AZURE__RESOURCEGROUP - Remove dependency on ASPNETCORE_APPLICATIONNAME which wasn't being respected * Use AZURE__LOCATION=westus3 for both deployment tests - Change Python test from eastus2 to westus3 (eastus2 has quota limits) - Use consistent AZURE__LOCATION env var naming (double underscore) * Update E2E CLI and Deployment tests to use 160x48 terminal size --------- Co-authored-by: Mitch Denny --- .github/workflows/deployment-tests.yml | 174 ++++++++++ .../AgentCommandTests.cs | 3 + .../DockerDeploymentTests.cs | 2 + .../DoctorCommandTests.cs | 2 + .../EmptyAppHostTemplateTests.cs | 1 + .../JsReactTemplateTests.cs | 1 + .../LogsCommandTests.cs | 1 + .../PsCommandTests.cs | 1 + .../PythonReactTemplateTests.cs | 1 + .../ResourcesCommandTests.cs | 1 + tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs | 1 + .../StartStopTests.cs | 1 + .../TypeScriptPolyglotTests.cs | 1 + .../AcaStarterDeploymentTests.cs | 36 +-- .../Helpers/DeploymentE2ETestHelpers.cs | 33 ++ .../PythonFastApiDeploymentTests.cs | 298 ++++++++++++++++++ .../README.md | 2 +- 17 files changed, 540 insertions(+), 19 deletions(-) create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs diff --git a/.github/workflows/deployment-tests.yml b/.github/workflows/deployment-tests.yml index ff3ea3eb191..e780e567389 100644 --- a/.github/workflows/deployment-tests.yml +++ b/.github/workflows/deployment-tests.yml @@ -321,3 +321,177 @@ jobs: }); console.log(`Created issue: ${issue.data.html_url}`); } + + # Post completion comment back to PR when triggered via /deployment-test command + post_pr_comment: + name: Post PR Comment + needs: [deploy-test] + runs-on: ubuntu-latest + if: ${{ always() && inputs.pr_number != '' }} + permissions: + pull-requests: write + actions: read + steps: + - name: Download recording artifacts + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + // List all artifacts for the current workflow run + const allArtifacts = await github.paginate( + github.rest.actions.listWorkflowRunArtifacts, + { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId, + per_page: 100 + } + ); + + console.log(`Total artifacts found: ${allArtifacts.length}`); + + // Filter for deployment test recording artifacts + const recordingArtifacts = allArtifacts.filter(a => + a.name.startsWith('deployment-test-recordings-') + ); + + console.log(`Found ${recordingArtifacts.length} recording artifacts`); + + // Create recordings directory + const recordingsDir = 'recordings'; + fs.mkdirSync(recordingsDir, { recursive: true }); + + // Download each artifact + for (const artifact of recordingArtifacts) { + console.log(`Downloading ${artifact.name}...`); + + const download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: artifact.id, + archive_format: 'zip' + }); + + const artifactPath = path.join(recordingsDir, `${artifact.name}.zip`); + fs.writeFileSync(artifactPath, Buffer.from(download.data)); + console.log(`Saved to ${artifactPath}`); + } + + core.setOutput('artifact_count', recordingArtifacts.length); + + - name: Extract recordings from artifacts + shell: bash + run: | + mkdir -p cast_files + + for zipfile in recordings/*.zip; do + if [ -f "$zipfile" ]; then + echo "Extracting $zipfile..." + unzip -o "$zipfile" -d "recordings/extracted_$(basename "$zipfile" .zip)" || true + fi + done + + # Find and copy all .cast files + find recordings -name "*.cast" -exec cp {} cast_files/ \; 2>/dev/null || true + + echo "Found recordings:" + ls -la cast_files/ || echo "No .cast files found" + + - name: Upload recordings to asciinema and post comment + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + PR_NUMBER="${{ inputs.pr_number }}" + RUN_ID="${{ github.run_id }}" + RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${RUN_ID}" + TEST_RESULT="${{ needs.deploy-test.result }}" + + # Determine status emoji and message + case "$TEST_RESULT" in + success) + EMOJI="✅" + STATUS="passed" + DETAILS="All deployment tests completed successfully." + ;; + failure) + EMOJI="❌" + STATUS="failed" + DETAILS="One or more deployment tests failed. Check the workflow run for details." + ;; + cancelled) + EMOJI="⚠️" + STATUS="cancelled" + DETAILS="The deployment tests were cancelled." + ;; + skipped) + EMOJI="⏭️" + STATUS="skipped" + DETAILS="The deployment tests were skipped (no tests to run or prerequisites not met)." + ;; + *) + EMOJI="❓" + STATUS="${TEST_RESULT:-unknown}" + DETAILS="The deployment test result could not be determined." + ;; + esac + + # Build the comment body + COMMENT_BODY="${EMOJI} **Deployment E2E Tests ${STATUS}** + + ${DETAILS} + + [View workflow run](${RUN_URL})" + + # Check for recordings and upload them + RECORDINGS_DIR="cast_files" + + if [ -d "$RECORDINGS_DIR" ] && compgen -G "$RECORDINGS_DIR"/*.cast > /dev/null; then + # Install asciinema + pip install --quiet asciinema + + RECORDING_TABLE=" + + ### 🎬 Terminal Recordings + + | Test | Recording | + |------|-----------|" + + UPLOAD_COUNT=0 + + for castfile in "$RECORDINGS_DIR"/*.cast; do + if [ -f "$castfile" ]; then + filename=$(basename "$castfile" .cast) + echo "Uploading $castfile..." + + # Upload to asciinema and capture URL + UPLOAD_OUTPUT=$(asciinema upload "$castfile" 2>&1) || true + ASCIINEMA_URL=$(echo "$UPLOAD_OUTPUT" | grep -oP 'https://asciinema\.org/a/[a-zA-Z0-9_-]+' | head -1) || true + + if [ -n "$ASCIINEMA_URL" ]; then + RECORDING_TABLE="${RECORDING_TABLE} + | ${filename} | [▶️ View Recording](${ASCIINEMA_URL}) |" + echo "Uploaded: $ASCIINEMA_URL" + UPLOAD_COUNT=$((UPLOAD_COUNT + 1)) + else + RECORDING_TABLE="${RECORDING_TABLE} + | ${filename} | ❌ Upload failed |" + echo "Failed to upload $castfile" + fi + fi + done + + if [ $UPLOAD_COUNT -gt 0 ]; then + COMMENT_BODY="${COMMENT_BODY}${RECORDING_TABLE}" + fi + + echo "Uploaded $UPLOAD_COUNT recordings" + else + echo "No recordings found in $RECORDINGS_DIR" + fi + + # Post the comment + gh pr comment "${PR_NUMBER}" --repo "${{ github.repository }}" --body "$COMMENT_BODY" + echo "Posted comment to PR #${PR_NUMBER}" diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs index e203faa127a..f3081afc8ac 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs @@ -36,6 +36,7 @@ public async Task AgentCommands_AllHelpOutputs_AreCorrect() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); @@ -140,6 +141,7 @@ public async Task AgentInitCommand_MigratesDeprecatedConfig() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); @@ -258,6 +260,7 @@ public async Task DoctorCommand_DetectsDeprecatedAgentConfig() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs index b7fd503c90a..a61ac5e0133 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs @@ -30,6 +30,7 @@ public async Task CreateAndDeployToDockerCompose() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); @@ -206,6 +207,7 @@ public async Task CreateAndDeployToDockerComposeInteractive() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs index a81966e8869..a9c51496d59 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs @@ -28,6 +28,7 @@ public async Task DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); @@ -91,6 +92,7 @@ public async Task DoctorCommand_WithSslCertDir_ShowsTrusted() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs index 235d72ea901..384165d596e 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs @@ -27,6 +27,7 @@ public async Task CreateEmptyAppHostProject() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs index e30544ea64f..978af50c2c4 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs @@ -27,6 +27,7 @@ public async Task CreateAndRunJsReactProject() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs index ec1bad8e4a5..30ffe3adabd 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs @@ -27,6 +27,7 @@ public async Task LogsCommandShowsResourceLogs() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs index e575a87ade3..78a98c7d392 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs @@ -27,6 +27,7 @@ public async Task PsCommandListsRunningAppHost() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs index 31160cd0d83..684bab355f8 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs @@ -27,6 +27,7 @@ public async Task CreateAndRunPythonReactProject() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ResourcesCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ResourcesCommandTests.cs index 13d92f98780..41edab5154e 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ResourcesCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ResourcesCommandTests.cs @@ -27,6 +27,7 @@ public async Task ResourcesCommandShowsRunningResources() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs index b42d80f96d9..e85f579a1c4 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs @@ -27,6 +27,7 @@ public async Task CreateAndRunAspireStarterProject() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs index e89966b5357..371eab030c2 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs @@ -27,6 +27,7 @@ public async Task CreateStartAndStopAspireProject() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs index c248e47ba9e..1a94f2b127d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs @@ -27,6 +27,7 @@ public async Task CreateTypeScriptAppHostWithViteApp() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs index aceb6c11135..a9fecb3c94f 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs @@ -54,14 +54,14 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateToAzureContainerApps)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); - // Note: aspire deploy creates its own resource group with pattern rg-aspire-{appname} - // We add a unique suffix per run to avoid collisions with concurrent runs or cleanup in progress - var runSuffix = DateTime.UtcNow.ToString("HHmmss"); - var projectName = $"AcaTest{runSuffix}"; + // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt] + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("starter"); + // Project name can be simpler since resource group is explicitly set + var projectName = "AcaStarter"; output.WriteLine($"Test: {nameof(DeployStarterTemplateToAzureContainerApps)}"); output.WriteLine($"Project Name: {projectName}"); - output.WriteLine($"Expected Resource Group: rg-aspire-{projectName.ToLowerInvariant()}apphost"); + output.WriteLine($"Resource Group: {resourceGroupName}"); output.WriteLine($"Subscription: {subscriptionId[..8]}..."); output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); @@ -69,6 +69,7 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok { var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); @@ -195,8 +196,11 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok .Enter() .WaitForSuccessPrompt(counter); - // Step 8: Unset ASPIRE_PLAYGROUND before deploy and set Azure location - sequenceBuilder.Type("unset ASPIRE_PLAYGROUND && export Azure__Location=westus3") + // Step 8: Set environment variables for deployment + // - Unset ASPIRE_PLAYGROUND to avoid conflicts + // - Set Azure location + // - Set AZURE__RESOURCEGROUP to use our unique resource group name + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") .Enter() .WaitForSuccessPrompt(counter); @@ -212,9 +216,8 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok // Step 10: Extract deployment URLs and verify endpoints output.WriteLine("Step 8: Verifying deployed endpoints..."); - var expectedResourceGroup = $"rg-aspire-{projectName.ToLowerInvariant()}apphost"; sequenceBuilder - .Type($"RG_NAME=\"{expectedResourceGroup}\" && " + + .Type($"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 && " + // Get external endpoints only (exclude .internal. which are not publicly accessible) @@ -245,7 +248,7 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok // Report success DeploymentReporter.ReportDeploymentSuccess( nameof(DeployStarterTemplateToAzureContainerApps), - $"rg-aspire-{projectName.ToLowerInvariant()}apphost", + resourceGroupName, deploymentUrls, duration); @@ -258,7 +261,7 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok DeploymentReporter.ReportDeploymentFailure( nameof(DeployStarterTemplateToAzureContainerApps), - $"rg-aspire-{projectName.ToLowerInvariant()}apphost", + resourceGroupName, ex.Message, ex.StackTrace); @@ -266,13 +269,10 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok } finally { - // Note: aspire deploy creates its own resource group (rg-aspire-{appname}) - // The cleanup workflow runs hourly and removes resource groups older than 3 hours. - // We trigger cleanup here as a best-effort, but rely on the cleanup workflow for reliability. - var resourceGroupToCleanup = $"rg-aspire-{projectName.ToLowerInvariant()}apphost"; - output.WriteLine($"Triggering cleanup of resource group: {resourceGroupToCleanup}"); - TriggerCleanupResourceGroup(resourceGroupToCleanup, output); - DeploymentReporter.ReportCleanupStatus(resourceGroupToCleanup, success: true, "Cleanup triggered (fire-and-forget)"); + // Clean up the resource group we created + output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName, output); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); } } diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs index 39c0bd1ee2b..1b07a17e257 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs @@ -42,6 +42,39 @@ internal static string GetCommitSha() return string.IsNullOrEmpty(commitSha) ? "local0000" : commitSha; } + /// + /// Gets the GitHub Actions run ID from the GITHUB_RUN_ID environment variable. + /// When running locally (not in CI), returns a timestamp-based ID. + /// + internal static string GetRunId() + { + var runId = Environment.GetEnvironmentVariable("GITHUB_RUN_ID"); + return string.IsNullOrEmpty(runId) ? DateTime.UtcNow.ToString("yyyyMMddHHmmss") : runId; + } + + /// + /// Gets the GitHub Actions run attempt from the GITHUB_RUN_ATTEMPT environment variable. + /// When running locally (not in CI), returns "1". + /// + internal static string GetRunAttempt() + { + var runAttempt = Environment.GetEnvironmentVariable("GITHUB_RUN_ATTEMPT"); + return string.IsNullOrEmpty(runAttempt) ? "1" : runAttempt; + } + + /// + /// Generates a unique resource group name for deployment tests. + /// Format: e2e-[testcasename]-[runid]-[attempt] + /// + /// Short name for the test case (e.g., "starter", "python"). + /// A unique resource group name. + internal static string GenerateResourceGroupName(string testCaseName) + { + var runId = GetRunId(); + var attempt = GetRunAttempt(); + return $"e2e-{testCaseName}-{runId}-{attempt}"; + } + /// /// Gets the path for storing asciinema recordings that will be uploaded as CI artifacts. /// diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs new file mode 100644 index 00000000000..29ae174bb83 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs @@ -0,0 +1,298 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Python FastAPI Aspire applications to Azure Container Apps. +/// +public sealed class PythonFastApiDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 20 minutes to allow for Azure provisioning and Python environment setup. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(20); + + [Fact] + public async Task DeployPythonFastApiTemplateToAzureContainerApps() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployPythonFastApiTemplateToAzureContainerAppsCore(cancellationToken); + } + + private async Task DeployPythonFastApiTemplateToAzureContainerAppsCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployPythonFastApiTemplateToAzureContainerApps)); + var startTime = DateTime.UtcNow; + var deploymentUrls = new Dictionary(); + // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt] + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("python"); + // Project name can be simpler since resource group is explicitly set + var projectName = "PyFastApi"; + + output.WriteLine($"Test: {nameof(DeployPythonFastApiTemplateToAzureContainerApps)}"); + output.WriteLine($"Project Name: {projectName}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + // Pattern searchers for aspire new interactive prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + // Wait for the FastAPI/React template to be highlighted (after pressing Down twice) + // Use Find() instead of FindPattern() because parentheses and slashes are regex special characters + var waitingForPythonReactTemplateSelected = new CellPatternSearcher() + .Find("> Starter App (FastAPI/React)"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find("Enter the output path:"); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create Python FastAPI project using aspire new with interactive prompts + // Navigate down to select Starter App (FastAPI/React) which is the 3rd option + output.WriteLine("Step 3: Creating Python FastAPI project..."); + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + // Navigate to Starter App (FastAPI/React) - it's the 3rd option (after ASP.NET and JS) + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .WaitUntil(s => waitingForPythonReactTemplateSelected.Search(s).Count > 0, TimeSpan.FromSeconds(5)) + .Enter() // Select Starter App (FastAPI/React) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Type(projectName) + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Accept default output path + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for localhost URLs (default) + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + // For Redis prompt, default is "Yes" so we need to select "No" by pressing Down + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .Enter() // Select "No" for Redis Cache + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 4: Navigate to project directory + output.WriteLine("Step 4: Navigating to project directory..."); + sequenceBuilder + .Type($"cd {projectName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5: Add Aspire.Hosting.Azure.AppContainers package + output.WriteLine("Step 5: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter(); + + // In CI, aspire add shows a version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 6: Modify apphost.cs to add Azure Container App Environment + // Note: Python template uses single-file AppHost (apphost.cs in project root) + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + // Single-file AppHost is in the project root, not a subdirectory + var appHostFilePath = Path.Combine(projectDir, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + // Insert the Azure Container App Environment before builder.Build().Run(); + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Container App Environment for deployment +builder.AddAzureContainerAppEnvironment("infra"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs at: {appHostFilePath}"); + }); + + // Step 7: Set environment for deployment + // - Unset ASPIRE_PLAYGROUND to avoid conflicts + // - Set Azure location to westus3 (same as other tests to use region with capacity) + // - Set AZURE__RESOURCEGROUP to use our unique resource group name + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 9: Deploy to Azure Container Apps using aspire deploy + output.WriteLine("Step 7: Starting Azure Container Apps deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + // Wait for pipeline to complete successfully + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(15)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 10: Extract deployment URLs and verify endpoints + output.WriteLine("Step 8: Verifying deployed endpoints..."); + sequenceBuilder + .Type($"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 && " + + // Get external endpoints only (exclude .internal. which are not publicly accessible) + "urls=$(az containerapp list -g \"$RG_NAME\" --query \"[].properties.configuration.ingress.fqdn\" -o tsv 2>/dev/null | grep -v '\\.internal\\.') && " + + "if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " + + "failed=0 && " + + "for url in $urls; do " + + "echo -n \"Checking https://$url... \"; " + + "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 10 2>/dev/null); " + + "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \"✅ $STATUS\"; else echo \"❌ $STATUS\"; failed=1; fi; " + + "done && " + + "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 11: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + // Report success + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployPythonFastApiTemplateToAzureContainerApps), + resourceGroupName, + deploymentUrls, + duration); + + output.WriteLine("✅ Test passed!"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployPythonFastApiTemplateToAzureContainerApps), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + // Clean up the resource group we created + output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName, output); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + } + } + + private static void TriggerCleanupResourceGroup(string resourceGroupName, ITestOutputHelper output) + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/README.md b/tests/Aspire.Deployment.EndToEnd.Tests/README.md index 9dd63ee881b..189547f5757 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/README.md +++ b/tests/Aspire.Deployment.EndToEnd.Tests/README.md @@ -1,6 +1,6 @@ # Aspire Deployment End-to-End Tests -This project contains end-to-end tests that deploy Aspire applications to real Azure infrastructure. These tests verify that the complete deployment workflow works correctly, from project creation to live deployment and endpoint verification. +This project contains end-to-end tests that deploy Aspire applications to real Azure infrastructure. These tests verify that the complete deployment workflow works correctly, from project creation to live deployment and endpoint verification. ## Overview From 0b4d429951aa8854bc6d064081499de1dbd8a0ea Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:21:03 -0800 Subject: [PATCH 022/256] Fix template version parsing for .NET 10.0 SDK separator change (#14285) * Initial plan * Fix template version parsing to support @ separator for .NET 10.0 SDK Co-authored-by: JamesNK <303201+JamesNK@users.noreply.github.com> * Refactor into parameterized test using Theory and InlineData Co-authored-by: JamesNK <303201+JamesNK@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JamesNK <303201+JamesNK@users.noreply.github.com> --- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 9 ++++++--- .../DotNet/DotNetCliRunnerTests.cs | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 0d2a1b833a9..b3247b6c6c5 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -649,7 +649,7 @@ public async Task TrustHttpCertificateAsync(DotNetCliRunnerInvocationOption } } - private static bool TryParsePackageVersionFromStdout(string stdout, [NotNullWhen(true)] out string? version) + internal static bool TryParsePackageVersionFromStdout(string stdout, [NotNullWhen(true)] out string? version) { var lines = stdout.Split(Environment.NewLine); var successLine = lines.SingleOrDefault(x => x.StartsWith("Success: Aspire.ProjectTemplates")); @@ -661,9 +661,12 @@ private static bool TryParsePackageVersionFromStdout(string stdout, [NotNullWhen } var templateVersion = successLine.Split(" ") switch { // Break up the success line. - { Length: > 2 } chunks => chunks[1].Split("::") switch { // Break up the template+version string + { Length: > 2 } chunks => chunks[1].Split("@") switch { // Break up the template+version string (@ separator for .NET 10.0+) { Length: 2 } versionChunks => versionChunks[1], // The version in the second chunk - _ => null + _ => chunks[1].Split("::") switch { // Fallback to :: separator for older SDK versions + { Length: 2 } versionChunks => versionChunks[1], + _ => null + } }, _ => null }; diff --git a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs index 98fc52b4c5e..dd27c7c5041 100644 --- a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs @@ -1192,4 +1192,19 @@ public async Task SearchPackagesAsyncSucceedsOnFirstAttemptWithoutRetry() Assert.NotNull(result.Packages); Assert.Equal(1, executor.AttemptCount); // Should have attempted only once } + + [Theory] + [InlineData("Success: Aspire.ProjectTemplates@13.2.0-preview.1.26101.12 installed the following templates:", true, "13.2.0-preview.1.26101.12")] // New .NET 10.0 SDK format with @ separator + [InlineData("Success: Aspire.ProjectTemplates::13.2.0-preview.1.26101.12 installed the following templates:", true, "13.2.0-preview.1.26101.12")] // Old SDK format with :: separator + [InlineData("Some other output", false, null)] // Missing success line + [InlineData("Success: Aspire.ProjectTemplates installed the following templates:", false, null)] // Invalid format without version separator + public void TryParsePackageVersionFromStdout_ParsesCorrectly(string stdout, bool expectedResult, string? expectedVersion) + { + // Act + var result = DotNetCliRunner.TryParsePackageVersionFromStdout(stdout, out var version); + + // Assert + Assert.Equal(expectedResult, result); + Assert.Equal(expectedVersion, version); + } } From 6672dc4151ca330e2b7d6c2492a69cff6dc1bf8a Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 3 Feb 2026 13:33:17 +0800 Subject: [PATCH 023/256] Remove MCP tests delay (#14303) * Remove MCP tests delay * [automated] Disable Aspire.Cli.EndToEnd.Tests.BannerTests.Banner_NotDisplayedWithNoLogoFlag --------- Co-authored-by: github-actions --- tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs | 3 ++- tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs index 50b3a507988..e01500953cb 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.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; @@ -151,6 +151,7 @@ public async Task Banner_DisplayedWithExplicitFlag() } [Fact] + [ActiveIssue("https://github.com/dotnet/aspire/issues/14307")] public async Task Banner_NotDisplayedWithNoLogoFlag() { var workspace = TemporaryWorkspace.Create(output); diff --git a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs index 1dbbe997ff6..e0b3f071083 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs @@ -77,9 +77,6 @@ public async ValueTask InitializeAsync() } }, _cts.Token); - // Wait a brief moment for the server to start - await Task.Delay(100, _cts.Token); - // Create and connect the MCP client using the test transport's client side _mcpClient = await _testTransport.CreateClientAsync(_loggerFactory, _cts.Token); } From 74ac3b971164712a4e177f596d4ffc613ef0e348 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 3 Feb 2026 13:35:34 +0800 Subject: [PATCH 024/256] Update dashboard telemetry notice URL not to have dotnet in the name (#14308) --- src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor index 5c9bd988489..3ee9dd98595 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor @@ -48,7 +48,7 @@ {

- @Loc[nameof(Dialogs.SettingsDialogTelemetryEnabledInfo)] @Loc[nameof(Dialogs.SettingsDialogTelemetryInfoLinkText)] + @Loc[nameof(Dialogs.SettingsDialogTelemetryEnabledInfo)] @Loc[nameof(Dialogs.SettingsDialogTelemetryInfoLinkText)]

} From db7fc72bb03c233774d6eac4536375d94c1e45b2 Mon Sep 17 00:00:00 2001 From: David Pine Date: Tue, 3 Feb 2026 00:34:09 -0600 Subject: [PATCH 025/256] Change Aspire docs issue link format (#14297) * Change Aspire docs issue link format Updated the link for Aspire docs issue template. * Update .github/pull_request_template.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: David Fowler Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/pull_request_template.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f6206cdd433..a172bd39751 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -29,8 +29,6 @@ Fixes # (issue) - [ ] No - Does the change require an update in our Aspire docs? - [ ] Yes - - Link to aspire-docs issue (consider using one of the following templates): - - [New (or update) `doc-idea` template](https://github.com/dotnet/docs-aspire/issues/new?template=02-docs-request.yml) - - [New `breaking-change` template](https://github.com/dotnet/docs-aspire/issues/new?template=04-breaking-change.yml) - - [New `diagnostic` template](https://github.com/dotnet/docs-aspire/issues/new?template=06-diagnostic-addition.yml) + - Link to `aspire.dev` issue: + - [New issue](https://github.com/microsoft/aspire.dev/issues/new) - [ ] No From 107822175ced641de489cc10ea30b7e7f82eeeb1 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 2 Feb 2026 22:37:38 -0800 Subject: [PATCH 026/256] Add CLI commands for resource lifecycle management (#14310) * Add CLI commands for resource lifecycle management Add new CLI commands for starting, stopping, and restarting resources: - aspire start - Start a stopped resource - aspire stop - Stop a running resource (optional arg) - aspire restart - Restart a running resource - aspire command - Execute any resource command Implementation: - Add ExecuteResourceCommandRequest/Response RPC types to backchannel - Add ExecuteResourceCommandAsync handler to AuxiliaryBackchannelRpcTarget - Add ExecuteResourceCommandAsync to CLI backchannel interface - Create StartCommand, RestartCommand, CommandCommand - Modify StopCommand to accept optional resource argument - Extract shared logic to ResourceCommandHelper - Replat MCP execute_resource_command tool to use backchannel directly - Reclassify execute_resource_command as local tool (not dashboard tool) - Configure JSON serialization with camelCase for backchannel compatibility * Fix past tense to base verb conversion in error messages * Address PR review: use KnownResourceCommands constants, remove TrimEnd * Refactor: extract ResourceCommandBase for Start/Restart commands * Add tests for resource commands and ExecuteResourceCommandTool * Address review comments: pass explicit verbs, add friendly error messages * Simplify GetFriendlyErrorMessage --- src/Aspire.Cli/Aspire.Cli.csproj | 1 + .../AppHostAuxiliaryBackchannel.cs | 28 ++++ .../BackchannelJsonSerializerContext.cs | 2 + .../IAppHostAuxiliaryBackchannel.cs | 12 ++ src/Aspire.Cli/Commands/AgentMcpCommand.cs | 2 +- src/Aspire.Cli/Commands/ResourceCommand.cs | 83 ++++++++++ .../Commands/ResourceCommandBase.cs | 109 +++++++++++++ .../Commands/ResourceCommandHelper.cs | 114 +++++++++++++ src/Aspire.Cli/Commands/RestartCommand.cs | 36 +++++ src/Aspire.Cli/Commands/RootCommand.cs | 6 + src/Aspire.Cli/Commands/StartCommand.cs | 36 +++++ src/Aspire.Cli/Commands/StopCommand.cs | 33 ++++ src/Aspire.Cli/ExitCodeConstants.cs | 1 + src/Aspire.Cli/Mcp/KnownMcpTools.cs | 4 +- .../Mcp/Tools/ExecuteResourceCommandTool.cs | 76 +++++++-- src/Aspire.Cli/Program.cs | 3 + .../ResourceCommandStrings.Designer.cs | 120 ++++++++++++++ .../Resources/ResourceCommandStrings.resx | 97 +++++++++++ .../xlf/ResourceCommandStrings.cs.xlf | 67 ++++++++ .../xlf/ResourceCommandStrings.de.xlf | 67 ++++++++ .../xlf/ResourceCommandStrings.es.xlf | 67 ++++++++ .../xlf/ResourceCommandStrings.fr.xlf | 67 ++++++++ .../xlf/ResourceCommandStrings.it.xlf | 67 ++++++++ .../xlf/ResourceCommandStrings.ja.xlf | 67 ++++++++ .../xlf/ResourceCommandStrings.ko.xlf | 67 ++++++++ .../xlf/ResourceCommandStrings.pl.xlf | 67 ++++++++ .../xlf/ResourceCommandStrings.pt-BR.xlf | 67 ++++++++ .../xlf/ResourceCommandStrings.ru.xlf | 67 ++++++++ .../xlf/ResourceCommandStrings.tr.xlf | 67 ++++++++ .../xlf/ResourceCommandStrings.zh-Hans.xlf | 67 ++++++++ .../xlf/ResourceCommandStrings.zh-Hant.xlf | 67 ++++++++ .../AuxiliaryBackchannelRpcTarget.cs | 21 +++ .../AuxiliaryBackchannelService.cs | 7 +- .../Backchannel/BackchannelDataTypes.cs | 37 +++++ .../Commands/ResourceCommandTests.cs | 110 +++++++++++++ .../Commands/RestartCommandTests.cs | 70 ++++++++ .../Commands/StartCommandTests.cs | 70 ++++++++ .../Mcp/ExecuteResourceCommandToolTests.cs | 152 ++++++++++++++++++ .../TestAppHostAuxiliaryBackchannel.cs | 13 ++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 3 + 40 files changed, 2099 insertions(+), 18 deletions(-) create mode 100644 src/Aspire.Cli/Commands/ResourceCommand.cs create mode 100644 src/Aspire.Cli/Commands/ResourceCommandBase.cs create mode 100644 src/Aspire.Cli/Commands/ResourceCommandHelper.cs create mode 100644 src/Aspire.Cli/Commands/RestartCommand.cs create mode 100644 src/Aspire.Cli/Commands/StartCommand.cs create mode 100644 src/Aspire.Cli/Resources/ResourceCommandStrings.Designer.cs create mode 100644 src/Aspire.Cli/Resources/ResourceCommandStrings.resx create mode 100644 src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf create mode 100644 tests/Aspire.Cli.Tests/Commands/ResourceCommandTests.cs create mode 100644 tests/Aspire.Cli.Tests/Commands/RestartCommandTests.cs create mode 100644 tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs create mode 100644 tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index cd93b5c2f7a..f2396b691e9 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -60,6 +60,7 @@ + diff --git a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs index 7cc62196b90..d850db55c41 100644 --- a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs @@ -705,6 +705,34 @@ await rpc.InvokeWithCancellationAsync( } } + /// + /// Executes a command on a resource. + /// + public async Task ExecuteResourceCommandAsync( + string resourceName, + string commandName, + CancellationToken cancellationToken = default) + { + var rpc = EnsureConnected(); + + _logger?.LogDebug("Executing command '{CommandName}' on resource '{ResourceName}'", commandName, resourceName); + + var request = new ExecuteResourceCommandRequest + { + ResourceName = resourceName, + CommandName = commandName + }; + + var response = await rpc.InvokeWithCancellationAsync( + "ExecuteResourceCommandAsync", + [request], + cancellationToken).ConfigureAwait(false); + + _logger?.LogDebug("Command '{CommandName}' on resource '{ResourceName}' completed with success={Success}", commandName, resourceName, response.Success); + + return response; + } + #endregion /// diff --git a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs index 32aa129a3cd..682cbc656f9 100644 --- a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs +++ b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs @@ -73,6 +73,8 @@ namespace Aspire.Cli.Backchannel; [JsonSerializable(typeof(McpToolContentItem[]))] [JsonSerializable(typeof(StopAppHostRequest))] [JsonSerializable(typeof(StopAppHostResponse))] +[JsonSerializable(typeof(ExecuteResourceCommandRequest))] +[JsonSerializable(typeof(ExecuteResourceCommandResponse))] internal partial class BackchannelJsonSerializerContext : JsonSerializerContext { [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Using the Json source generator.")] diff --git a/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs index 8b91d46ff75..f7d91f26b6d 100644 --- a/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs @@ -107,4 +107,16 @@ Task CallResourceMcpToolAsync( /// Cancellation token. /// The Dashboard information response. Task GetDashboardInfoV2Async(CancellationToken cancellationToken = default); + + /// + /// Executes a command on a resource. + /// + /// The name of the resource. + /// The name of the command (e.g., "resource-start", "resource-stop", "resource-restart"). + /// Cancellation token. + /// The result of the command execution. + Task ExecuteResourceCommandAsync( + string resourceName, + string commandName, + CancellationToken cancellationToken = default); } diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index 6cbd134ed1b..444a774ceeb 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -73,7 +73,7 @@ public AgentMcpCommand( { [KnownMcpTools.ListResources] = new ListResourcesTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), [KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(), - [KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(), + [KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), [KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(), [KnownMcpTools.ListTraces] = new ListTracesTool(), [KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(), diff --git a/src/Aspire.Cli/Commands/ResourceCommand.cs b/src/Aspire.Cli/Commands/ResourceCommand.cs new file mode 100644 index 00000000000..7aac95fdf01 --- /dev/null +++ b/src/Aspire.Cli/Commands/ResourceCommand.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Commands; + +internal sealed class ResourceCommand : BaseCommand +{ + private readonly IInteractionService _interactionService; + private readonly AppHostConnectionResolver _connectionResolver; + private readonly ILogger _logger; + + private static readonly Argument s_resourceArgument = new("resource") + { + Description = ResourceCommandStrings.CommandResourceArgumentDescription + }; + + private static readonly Argument s_commandArgument = new("command") + { + Description = ResourceCommandStrings.CommandNameArgumentDescription + }; + + private static readonly Option s_projectOption = new("--project") + { + Description = ResourceCommandStrings.ProjectOptionDescription + }; + + public ResourceCommand( + IInteractionService interactionService, + IAuxiliaryBackchannelMonitor backchannelMonitor, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + ILogger logger, + AspireCliTelemetry telemetry) + : base("command", ResourceCommandStrings.CommandDescription, features, updateNotifier, executionContext, interactionService, telemetry) + { + _interactionService = interactionService; + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); + _logger = logger; + + Arguments.Add(s_resourceArgument); + Arguments.Add(s_commandArgument); + Options.Add(s_projectOption); + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var resourceName = parseResult.GetValue(s_resourceArgument)!; + var commandName = parseResult.GetValue(s_commandArgument)!; + var passedAppHostProjectFile = parseResult.GetValue(s_projectOption); + + var result = await _connectionResolver.ResolveConnectionAsync( + passedAppHostProjectFile, + ResourceCommandStrings.ScanningForRunningAppHosts, + ResourceCommandStrings.SelectAppHost, + ResourceCommandStrings.NoInScopeAppHostsShowingAll, + ResourceCommandStrings.NoRunningAppHostsFound, + cancellationToken); + + if (!result.Success) + { + _interactionService.DisplayError(result.ErrorMessage ?? ResourceCommandStrings.NoRunningAppHostsFound); + return ExitCodeConstants.FailedToFindProject; + } + + return await ResourceCommandHelper.ExecuteGenericCommandAsync( + result.Connection!, + _interactionService, + _logger, + resourceName, + commandName, + cancellationToken); + } +} diff --git a/src/Aspire.Cli/Commands/ResourceCommandBase.cs b/src/Aspire.Cli/Commands/ResourceCommandBase.cs new file mode 100644 index 00000000000..d760cf38ac1 --- /dev/null +++ b/src/Aspire.Cli/Commands/ResourceCommandBase.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Commands; + +/// +/// Base class for commands that execute resource lifecycle operations (start, restart, etc.). +/// +internal abstract class ResourceCommandBase : BaseCommand +{ + protected readonly AppHostConnectionResolver ConnectionResolver; + protected readonly ILogger Logger; + + private readonly Argument _resourceArgument; + + protected static readonly Option s_projectOption = new("--project") + { + Description = ResourceCommandStrings.ProjectOptionDescription + }; + + /// + /// The resource command name to execute (e.g., KnownResourceCommands.StartCommand). + /// + protected abstract string CommandName { get; } + + /// + /// The verb to display during progress (e.g., "Starting"). + /// + protected abstract string ProgressVerb { get; } + + /// + /// The base verb for error messages (e.g., "start"). + /// + protected abstract string BaseVerb { get; } + + /// + /// The past tense verb for success messages (e.g., "started"). + /// + protected abstract string PastTenseVerb { get; } + + /// + /// The description for the resource argument. + /// + protected abstract string ResourceArgumentDescription { get; } + + protected ResourceCommandBase( + string name, + string description, + IInteractionService interactionService, + IAuxiliaryBackchannelMonitor backchannelMonitor, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + ILogger logger, + AspireCliTelemetry telemetry) + : base(name, description, features, updateNotifier, executionContext, interactionService, telemetry) + { + ConnectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); + Logger = logger; + + _resourceArgument = new Argument("resource") + { + Description = ResourceArgumentDescription + }; + + Arguments.Add(_resourceArgument); + Options.Add(s_projectOption); + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var resourceName = parseResult.GetValue(_resourceArgument)!; + var passedAppHostProjectFile = parseResult.GetValue(s_projectOption); + + var result = await ConnectionResolver.ResolveConnectionAsync( + passedAppHostProjectFile, + ResourceCommandStrings.ScanningForRunningAppHosts, + ResourceCommandStrings.SelectAppHost, + ResourceCommandStrings.NoInScopeAppHostsShowingAll, + ResourceCommandStrings.NoRunningAppHostsFound, + cancellationToken); + + if (!result.Success) + { + InteractionService.DisplayError(result.ErrorMessage ?? ResourceCommandStrings.NoRunningAppHostsFound); + return ExitCodeConstants.FailedToFindProject; + } + + return await ResourceCommandHelper.ExecuteResourceCommandAsync( + result.Connection!, + InteractionService, + Logger, + resourceName, + CommandName, + ProgressVerb, + BaseVerb, + PastTenseVerb, + cancellationToken); + } +} diff --git a/src/Aspire.Cli/Commands/ResourceCommandHelper.cs b/src/Aspire.Cli/Commands/ResourceCommandHelper.cs new file mode 100644 index 00000000000..cbfc9c2ee79 --- /dev/null +++ b/src/Aspire.Cli/Commands/ResourceCommandHelper.cs @@ -0,0 +1,114 @@ +// 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.Backchannel; +using Aspire.Cli.Interaction; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Commands; + +/// +/// Helper for executing resource commands via the backchannel. +/// Provides common functionality for start/stop/restart/command operations. +/// +internal static class ResourceCommandHelper +{ + /// + /// Executes a resource command and handles the response with appropriate user feedback. + /// + /// The backchannel connection to use. + /// The interaction service for user feedback. + /// The logger for debug output. + /// The name of the resource. + /// The command to execute (e.g., "resource-start"). + /// The verb to display during progress (e.g., "Starting", "Stopping"). + /// The base verb for error messages (e.g., "start", "stop"). + /// The past tense verb for success messages (e.g., "started", "stopped"). + /// Cancellation token. + /// Exit code indicating success or failure. + public static async Task ExecuteResourceCommandAsync( + IAppHostAuxiliaryBackchannel connection, + IInteractionService interactionService, + ILogger logger, + string resourceName, + string commandName, + string progressVerb, + string baseVerb, + string pastTenseVerb, + CancellationToken cancellationToken) + { + logger.LogDebug("{Verb} resource '{ResourceName}'", progressVerb, resourceName); + + var response = await interactionService.ShowStatusAsync( + $"{progressVerb} resource '{resourceName}'...", + async () => await connection.ExecuteResourceCommandAsync(resourceName, commandName, cancellationToken)); + + return HandleResponse(response, interactionService, resourceName, progressVerb, baseVerb, pastTenseVerb); + } + + /// + /// Executes a generic command and handles the response with appropriate user feedback. + /// + public static async Task ExecuteGenericCommandAsync( + IAppHostAuxiliaryBackchannel connection, + IInteractionService interactionService, + ILogger logger, + string resourceName, + string commandName, + CancellationToken cancellationToken) + { + logger.LogDebug("Executing command '{CommandName}' on resource '{ResourceName}'", commandName, resourceName); + + var response = await interactionService.ShowStatusAsync( + $"Executing command '{commandName}' on resource '{resourceName}'...", + async () => await connection.ExecuteResourceCommandAsync(resourceName, commandName, cancellationToken)); + + if (response.Success) + { + interactionService.DisplaySuccess($"Command '{commandName}' executed successfully on resource '{resourceName}'."); + return ExitCodeConstants.Success; + } + else if (response.Canceled) + { + interactionService.DisplayMessage("warning", $"Command '{commandName}' on '{resourceName}' was canceled."); + return ExitCodeConstants.FailedToExecuteResourceCommand; + } + else + { + var errorMessage = GetFriendlyErrorMessage(response.ErrorMessage); + interactionService.DisplayError($"Failed to execute command '{commandName}' on resource '{resourceName}': {errorMessage}"); + return ExitCodeConstants.FailedToExecuteResourceCommand; + } + } + + private static int HandleResponse( + ExecuteResourceCommandResponse response, + IInteractionService interactionService, + string resourceName, + string progressVerb, + string baseVerb, + string pastTenseVerb) + { + if (response.Success) + { + interactionService.DisplaySuccess($"Resource '{resourceName}' {pastTenseVerb} successfully."); + return ExitCodeConstants.Success; + } + else if (response.Canceled) + { + interactionService.DisplayMessage("warning", $"{progressVerb} command for '{resourceName}' was canceled."); + return ExitCodeConstants.FailedToExecuteResourceCommand; + } + else + { + var errorMessage = GetFriendlyErrorMessage(response.ErrorMessage); + interactionService.DisplayError($"Failed to {baseVerb} resource '{resourceName}': {errorMessage}"); + return ExitCodeConstants.FailedToExecuteResourceCommand; + } + } + + private static string GetFriendlyErrorMessage(string? errorMessage) + { + return string.IsNullOrEmpty(errorMessage) ? "Unknown error occurred." : errorMessage; + } +} diff --git a/src/Aspire.Cli/Commands/RestartCommand.cs b/src/Aspire.Cli/Commands/RestartCommand.cs new file mode 100644 index 00000000000..bfdb70693f9 --- /dev/null +++ b/src/Aspire.Cli/Commands/RestartCommand.cs @@ -0,0 +1,36 @@ +// 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.Backchannel; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Commands; + +internal sealed class RestartCommand : ResourceCommandBase +{ + protected override string CommandName => KnownResourceCommands.RestartCommand; + protected override string ProgressVerb => "Restarting"; + protected override string BaseVerb => "restart"; + protected override string PastTenseVerb => "restarted"; + protected override string ResourceArgumentDescription => ResourceCommandStrings.RestartResourceArgumentDescription; + + public RestartCommand( + IInteractionService interactionService, + IAuxiliaryBackchannelMonitor backchannelMonitor, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + ILogger logger, + AspireCliTelemetry telemetry) + : base("restart", ResourceCommandStrings.RestartDescription, + interactionService, backchannelMonitor, features, updateNotifier, + executionContext, logger, telemetry) + { + } +} diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 3680df57341..5699c62d2d6 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -64,6 +64,9 @@ public RootCommand( InitCommand initCommand, RunCommand runCommand, StopCommand stopCommand, + StartCommand startCommand, + RestartCommand restartCommand, + ResourceCommand commandCommand, PsCommand psCommand, ResourcesCommand resourcesCommand, LogsCommand logsCommand, @@ -130,6 +133,9 @@ public RootCommand( Subcommands.Add(initCommand); Subcommands.Add(runCommand); Subcommands.Add(stopCommand); + Subcommands.Add(startCommand); + Subcommands.Add(restartCommand); + Subcommands.Add(commandCommand); Subcommands.Add(psCommand); Subcommands.Add(resourcesCommand); Subcommands.Add(logsCommand); diff --git a/src/Aspire.Cli/Commands/StartCommand.cs b/src/Aspire.Cli/Commands/StartCommand.cs new file mode 100644 index 00000000000..46f2d88f244 --- /dev/null +++ b/src/Aspire.Cli/Commands/StartCommand.cs @@ -0,0 +1,36 @@ +// 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.Backchannel; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Commands; + +internal sealed class StartCommand : ResourceCommandBase +{ + protected override string CommandName => KnownResourceCommands.StartCommand; + protected override string ProgressVerb => "Starting"; + protected override string BaseVerb => "start"; + protected override string PastTenseVerb => "started"; + protected override string ResourceArgumentDescription => ResourceCommandStrings.StartResourceArgumentDescription; + + public StartCommand( + IInteractionService interactionService, + IAuxiliaryBackchannelMonitor backchannelMonitor, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + ILogger logger, + AspireCliTelemetry telemetry) + : base("start", ResourceCommandStrings.StartDescription, + interactionService, backchannelMonitor, features, updateNotifier, + executionContext, logger, telemetry) + { + } +} diff --git a/src/Aspire.Cli/Commands/StopCommand.cs b/src/Aspire.Cli/Commands/StopCommand.cs index a74ed27961b..b931a6d5b6c 100644 --- a/src/Aspire.Cli/Commands/StopCommand.cs +++ b/src/Aspire.Cli/Commands/StopCommand.cs @@ -10,6 +10,7 @@ using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; +using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Commands; @@ -20,6 +21,13 @@ internal sealed class StopCommand : BaseCommand private readonly AppHostConnectionResolver _connectionResolver; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; + + private static readonly Argument s_resourceArgument = new("resource") + { + Description = "The name of the resource to stop. If not specified, stops the entire AppHost.", + Arity = ArgumentArity.ZeroOrOne + }; + private static readonly Option s_projectOption = new("--project") { Description = StopCommandStrings.ProjectArgumentDescription @@ -41,11 +49,13 @@ public StopCommand( _logger = logger; _timeProvider = timeProvider ?? TimeProvider.System; + Arguments.Add(s_resourceArgument); Options.Add(s_projectOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { + var resourceName = parseResult.GetValue(s_resourceArgument); var passedAppHostProjectFile = parseResult.GetValue(s_projectOption); var result = await _connectionResolver.ResolveConnectionAsync( @@ -64,6 +74,12 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var selectedConnection = result.Connection!; + // If a resource name is provided, stop that specific resource instead of the AppHost + if (!string.IsNullOrEmpty(resourceName)) + { + return await StopResourceAsync(selectedConnection, resourceName, cancellationToken); + } + // Stop the selected AppHost var appHostPath = selectedConnection.AppHostInfo?.AppHostPath ?? "Unknown"; // Use relative path for in-scope, full path for out-of-scope @@ -193,4 +209,21 @@ private static void SendStopSignal(int pid) // Some other error (e.g., permission denied) - ignore } } + + /// + /// Stops a specific resource instead of the entire AppHost. + /// + private Task StopResourceAsync(IAppHostAuxiliaryBackchannel connection, string resourceName, CancellationToken cancellationToken) + { + return ResourceCommandHelper.ExecuteResourceCommandAsync( + connection, + _interactionService, + _logger, + resourceName, + KnownResourceCommands.StopCommand, + "Stopping", + "stop", + "stopped", + cancellationToken); + } } diff --git a/src/Aspire.Cli/ExitCodeConstants.cs b/src/Aspire.Cli/ExitCodeConstants.cs index f7ddc2cd570..fed6c27ac66 100644 --- a/src/Aspire.Cli/ExitCodeConstants.cs +++ b/src/Aspire.Cli/ExitCodeConstants.cs @@ -21,4 +21,5 @@ internal static class ExitCodeConstants public const int FailedToUpgradeProject = 13; public const int CentralPackageManagementNotSupported = 14; public const int SingleFileAppHostNotSupported = 15; + public const int FailedToExecuteResourceCommand = 16; } diff --git a/src/Aspire.Cli/Mcp/KnownMcpTools.cs b/src/Aspire.Cli/Mcp/KnownMcpTools.cs index 73ecfc50cb1..c4e28eaae88 100644 --- a/src/Aspire.Cli/Mcp/KnownMcpTools.cs +++ b/src/Aspire.Cli/Mcp/KnownMcpTools.cs @@ -33,11 +33,11 @@ RefreshTools or ListDocs or SearchDocs or GetDoc or - ListResources; + ListResources or + ExecuteResourceCommand; public static bool IsDashboardTool(string toolName) => toolName is ListConsoleLogs or - ExecuteResourceCommand or ListStructuredLogs or ListTraces or ListTraceStructuredLogs; diff --git a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs index 0bb9c47a51f..cf285d044a5 100644 --- a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs @@ -2,12 +2,20 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; +using Aspire.Cli.Backchannel; +using Microsoft.Extensions.Logging; using ModelContextProtocol; using ModelContextProtocol.Protocol; namespace Aspire.Cli.Mcp.Tools; -internal sealed class ExecuteResourceCommandTool : CliMcpTool +/// +/// MCP tool for executing commands on resources. +/// Executes commands directly via the AppHost backchannel. +/// +internal sealed class ExecuteResourceCommandTool( + IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, + ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ExecuteResourceCommand; @@ -35,22 +43,62 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) { - // Convert JsonElement arguments to Dictionary - Dictionary? convertedArgs = null; - if (arguments != null) + // This tool does not use the MCP client as it operates via backchannel + _ = mcpClient; + + if (arguments is null || + !arguments.TryGetValue("resourceName", out var resourceNameElement) || + !arguments.TryGetValue("commandName", out var commandNameElement)) + { + throw new McpProtocolException("Missing required arguments 'resourceName' and 'commandName'.", McpErrorCode.InvalidParams); + } + + var resourceName = resourceNameElement.GetString(); + var commandName = commandNameElement.GetString(); + + if (string.IsNullOrEmpty(resourceName) || string.IsNullOrEmpty(commandName)) + { + throw new McpProtocolException("Arguments 'resourceName' and 'commandName' cannot be empty.", McpErrorCode.InvalidParams); + } + + var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + if (connection is null) + { + logger.LogWarning("No Aspire AppHost is currently running"); + throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError); + } + + try { - convertedArgs = new Dictionary(); - foreach (var kvp in arguments) + logger.LogDebug("Executing command '{CommandName}' on resource '{ResourceName}' via backchannel", commandName, resourceName); + + var response = await connection.ExecuteResourceCommandAsync(resourceName, commandName, cancellationToken).ConfigureAwait(false); + + if (response.Success) + { + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"Command '{commandName}' executed successfully on resource '{resourceName}'." }] + }; + } + else if (response.Canceled) + { + throw new McpProtocolException($"Command '{commandName}' was cancelled.", McpErrorCode.InternalError); + } + else { - convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; + var message = response.ErrorMessage is { Length: > 0 } ? response.ErrorMessage : "Unknown error. See logs for details."; + throw new McpProtocolException($"Command '{commandName}' failed for resource '{resourceName}': {message}", McpErrorCode.InternalError); } } - - // Forward the call to the dashboard's MCP server - return await mcpClient.CallToolAsync( - Name, - convertedArgs, - serializerOptions: McpJsonUtilities.DefaultOptions, - cancellationToken: cancellationToken); + catch (McpProtocolException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Error executing command '{CommandName}' on resource '{ResourceName}'", commandName, resourceName); + throw new McpProtocolException($"Error executing command '{commandName}' for resource '{resourceName}': {ex.Message}", McpErrorCode.InternalError); + } } } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index c84d866f125..76640bd299b 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -249,6 +249,9 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Resources/ResourceCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/ResourceCommandStrings.Designer.cs new file mode 100644 index 00000000000..2d00376b88f --- /dev/null +++ b/src/Aspire.Cli/Resources/ResourceCommandStrings.Designer.cs @@ -0,0 +1,120 @@ +//------------------------------------------------------------------------------ +// +// 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.Cli.Resources { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class ResourceCommandStrings { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal ResourceCommandStrings() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Aspire.Cli.Resources.ResourceCommandStrings", typeof(ResourceCommandStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + internal static string ScanningForRunningAppHosts { + get { + return ResourceManager.GetString("ScanningForRunningAppHosts", resourceCulture); + } + } + + internal static string SelectAppHost { + get { + return ResourceManager.GetString("SelectAppHost", resourceCulture); + } + } + + internal static string NoInScopeAppHostsShowingAll { + get { + return ResourceManager.GetString("NoInScopeAppHostsShowingAll", resourceCulture); + } + } + + internal static string NoRunningAppHostsFound { + get { + return ResourceManager.GetString("NoRunningAppHostsFound", resourceCulture); + } + } + + internal static string ProjectOptionDescription { + get { + return ResourceManager.GetString("ProjectOptionDescription", resourceCulture); + } + } + + internal static string StartDescription { + get { + return ResourceManager.GetString("StartDescription", resourceCulture); + } + } + + internal static string StartResourceArgumentDescription { + get { + return ResourceManager.GetString("StartResourceArgumentDescription", resourceCulture); + } + } + + internal static string RestartDescription { + get { + return ResourceManager.GetString("RestartDescription", resourceCulture); + } + } + + internal static string RestartResourceArgumentDescription { + get { + return ResourceManager.GetString("RestartResourceArgumentDescription", resourceCulture); + } + } + + internal static string CommandDescription { + get { + return ResourceManager.GetString("CommandDescription", resourceCulture); + } + } + + internal static string CommandResourceArgumentDescription { + get { + return ResourceManager.GetString("CommandResourceArgumentDescription", resourceCulture); + } + } + + internal static string CommandNameArgumentDescription { + get { + return ResourceManager.GetString("CommandNameArgumentDescription", resourceCulture); + } + } + } +} diff --git a/src/Aspire.Cli/Resources/ResourceCommandStrings.resx b/src/Aspire.Cli/Resources/ResourceCommandStrings.resx new file mode 100644 index 00000000000..87360ae7821 --- /dev/null +++ b/src/Aspire.Cli/Resources/ResourceCommandStrings.resx @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Scanning for running AppHosts... + + + Select which AppHost to connect to: + + + No in-scope AppHosts found. Showing all running AppHosts. + + + No running AppHosts found. + + + The path to the Aspire AppHost project file. + + + Start a stopped resource. + + + The name of the resource to start. + + + Restart a running resource. + + + The name of the resource to restart. + + + Execute a command on a resource. + + + The name of the resource to execute the command on. + + + The name of the command to execute. + + diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf new file mode 100644 index 00000000000..fe0a04c4f4d --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf @@ -0,0 +1,67 @@ + + + + + + Execute a command on a resource. + Execute a command on a resource. + + + + The name of the command to execute. + The name of the command to execute. + + + + The name of the resource to execute the command on. + The name of the resource to execute the command on. + + + + No in-scope AppHosts found. Showing all running AppHosts. + No in-scope AppHosts found. Showing all running AppHosts. + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Restart a running resource. + Restart a running resource. + + + + The name of the resource to restart. + The name of the resource to restart. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select which AppHost to connect to: + Select which AppHost to connect to: + + + + Start a stopped resource. + Start a stopped resource. + + + + The name of the resource to start. + The name of the resource to start. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf new file mode 100644 index 00000000000..e739f22fd4a --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf @@ -0,0 +1,67 @@ + + + + + + Execute a command on a resource. + Execute a command on a resource. + + + + The name of the command to execute. + The name of the command to execute. + + + + The name of the resource to execute the command on. + The name of the resource to execute the command on. + + + + No in-scope AppHosts found. Showing all running AppHosts. + No in-scope AppHosts found. Showing all running AppHosts. + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Restart a running resource. + Restart a running resource. + + + + The name of the resource to restart. + The name of the resource to restart. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select which AppHost to connect to: + Select which AppHost to connect to: + + + + Start a stopped resource. + Start a stopped resource. + + + + The name of the resource to start. + The name of the resource to start. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf new file mode 100644 index 00000000000..a5de6775537 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf @@ -0,0 +1,67 @@ + + + + + + Execute a command on a resource. + Execute a command on a resource. + + + + The name of the command to execute. + The name of the command to execute. + + + + The name of the resource to execute the command on. + The name of the resource to execute the command on. + + + + No in-scope AppHosts found. Showing all running AppHosts. + No in-scope AppHosts found. Showing all running AppHosts. + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Restart a running resource. + Restart a running resource. + + + + The name of the resource to restart. + The name of the resource to restart. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select which AppHost to connect to: + Select which AppHost to connect to: + + + + Start a stopped resource. + Start a stopped resource. + + + + The name of the resource to start. + The name of the resource to start. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf new file mode 100644 index 00000000000..dd2e07680fb --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf @@ -0,0 +1,67 @@ + + + + + + Execute a command on a resource. + Execute a command on a resource. + + + + The name of the command to execute. + The name of the command to execute. + + + + The name of the resource to execute the command on. + The name of the resource to execute the command on. + + + + No in-scope AppHosts found. Showing all running AppHosts. + No in-scope AppHosts found. Showing all running AppHosts. + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Restart a running resource. + Restart a running resource. + + + + The name of the resource to restart. + The name of the resource to restart. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select which AppHost to connect to: + Select which AppHost to connect to: + + + + Start a stopped resource. + Start a stopped resource. + + + + The name of the resource to start. + The name of the resource to start. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf new file mode 100644 index 00000000000..907a0dadf05 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf @@ -0,0 +1,67 @@ + + + + + + Execute a command on a resource. + Execute a command on a resource. + + + + The name of the command to execute. + The name of the command to execute. + + + + The name of the resource to execute the command on. + The name of the resource to execute the command on. + + + + No in-scope AppHosts found. Showing all running AppHosts. + No in-scope AppHosts found. Showing all running AppHosts. + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Restart a running resource. + Restart a running resource. + + + + The name of the resource to restart. + The name of the resource to restart. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select which AppHost to connect to: + Select which AppHost to connect to: + + + + Start a stopped resource. + Start a stopped resource. + + + + The name of the resource to start. + The name of the resource to start. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf new file mode 100644 index 00000000000..237a2c6e770 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf @@ -0,0 +1,67 @@ + + + + + + Execute a command on a resource. + Execute a command on a resource. + + + + The name of the command to execute. + The name of the command to execute. + + + + The name of the resource to execute the command on. + The name of the resource to execute the command on. + + + + No in-scope AppHosts found. Showing all running AppHosts. + No in-scope AppHosts found. Showing all running AppHosts. + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Restart a running resource. + Restart a running resource. + + + + The name of the resource to restart. + The name of the resource to restart. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select which AppHost to connect to: + Select which AppHost to connect to: + + + + Start a stopped resource. + Start a stopped resource. + + + + The name of the resource to start. + The name of the resource to start. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf new file mode 100644 index 00000000000..37bc26221ed --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf @@ -0,0 +1,67 @@ + + + + + + Execute a command on a resource. + Execute a command on a resource. + + + + The name of the command to execute. + The name of the command to execute. + + + + The name of the resource to execute the command on. + The name of the resource to execute the command on. + + + + No in-scope AppHosts found. Showing all running AppHosts. + No in-scope AppHosts found. Showing all running AppHosts. + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Restart a running resource. + Restart a running resource. + + + + The name of the resource to restart. + The name of the resource to restart. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select which AppHost to connect to: + Select which AppHost to connect to: + + + + Start a stopped resource. + Start a stopped resource. + + + + The name of the resource to start. + The name of the resource to start. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf new file mode 100644 index 00000000000..0461ed7042f --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf @@ -0,0 +1,67 @@ + + + + + + Execute a command on a resource. + Execute a command on a resource. + + + + The name of the command to execute. + The name of the command to execute. + + + + The name of the resource to execute the command on. + The name of the resource to execute the command on. + + + + No in-scope AppHosts found. Showing all running AppHosts. + No in-scope AppHosts found. Showing all running AppHosts. + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Restart a running resource. + Restart a running resource. + + + + The name of the resource to restart. + The name of the resource to restart. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select which AppHost to connect to: + Select which AppHost to connect to: + + + + Start a stopped resource. + Start a stopped resource. + + + + The name of the resource to start. + The name of the resource to start. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf new file mode 100644 index 00000000000..fc1c5559858 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf @@ -0,0 +1,67 @@ + + + + + + Execute a command on a resource. + Execute a command on a resource. + + + + The name of the command to execute. + The name of the command to execute. + + + + The name of the resource to execute the command on. + The name of the resource to execute the command on. + + + + No in-scope AppHosts found. Showing all running AppHosts. + No in-scope AppHosts found. Showing all running AppHosts. + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Restart a running resource. + Restart a running resource. + + + + The name of the resource to restart. + The name of the resource to restart. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select which AppHost to connect to: + Select which AppHost to connect to: + + + + Start a stopped resource. + Start a stopped resource. + + + + The name of the resource to start. + The name of the resource to start. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf new file mode 100644 index 00000000000..1244b5f428a --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf @@ -0,0 +1,67 @@ + + + + + + Execute a command on a resource. + Execute a command on a resource. + + + + The name of the command to execute. + The name of the command to execute. + + + + The name of the resource to execute the command on. + The name of the resource to execute the command on. + + + + No in-scope AppHosts found. Showing all running AppHosts. + No in-scope AppHosts found. Showing all running AppHosts. + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Restart a running resource. + Restart a running resource. + + + + The name of the resource to restart. + The name of the resource to restart. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select which AppHost to connect to: + Select which AppHost to connect to: + + + + Start a stopped resource. + Start a stopped resource. + + + + The name of the resource to start. + The name of the resource to start. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf new file mode 100644 index 00000000000..f9f70c8d8f1 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf @@ -0,0 +1,67 @@ + + + + + + Execute a command on a resource. + Execute a command on a resource. + + + + The name of the command to execute. + The name of the command to execute. + + + + The name of the resource to execute the command on. + The name of the resource to execute the command on. + + + + No in-scope AppHosts found. Showing all running AppHosts. + No in-scope AppHosts found. Showing all running AppHosts. + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Restart a running resource. + Restart a running resource. + + + + The name of the resource to restart. + The name of the resource to restart. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select which AppHost to connect to: + Select which AppHost to connect to: + + + + Start a stopped resource. + Start a stopped resource. + + + + The name of the resource to start. + The name of the resource to start. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf new file mode 100644 index 00000000000..1c9182cd8c0 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf @@ -0,0 +1,67 @@ + + + + + + Execute a command on a resource. + Execute a command on a resource. + + + + The name of the command to execute. + The name of the command to execute. + + + + The name of the resource to execute the command on. + The name of the resource to execute the command on. + + + + No in-scope AppHosts found. Showing all running AppHosts. + No in-scope AppHosts found. Showing all running AppHosts. + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Restart a running resource. + Restart a running resource. + + + + The name of the resource to restart. + The name of the resource to restart. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select which AppHost to connect to: + Select which AppHost to connect to: + + + + Start a stopped resource. + Start a stopped resource. + + + + The name of the resource to start. + The name of the resource to start. + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf new file mode 100644 index 00000000000..212bfdcdff0 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf @@ -0,0 +1,67 @@ + + + + + + Execute a command on a resource. + Execute a command on a resource. + + + + The name of the command to execute. + The name of the command to execute. + + + + The name of the resource to execute the command on. + The name of the resource to execute the command on. + + + + No in-scope AppHosts found. Showing all running AppHosts. + No in-scope AppHosts found. Showing all running AppHosts. + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Restart a running resource. + Restart a running resource. + + + + The name of the resource to restart. + The name of the resource to restart. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select which AppHost to connect to: + Select which AppHost to connect to: + + + + Start a stopped resource. + Start a stopped resource. + + + + The name of the resource to start. + The name of the resource to start. + + + + + \ No newline at end of file diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 7099483db5c..9bee5ce6325 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -205,6 +205,27 @@ public async Task StopAsync(StopAppHostRequest? request = n return new StopAppHostResponse(); } + /// + /// Executes a command on a resource. + /// + /// The request containing resource name and command name. + /// A cancellation token. + /// The response indicating success or failure. + public async Task ExecuteResourceCommandAsync(ExecuteResourceCommandRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var resourceCommandService = serviceProvider.GetRequiredService(); + var result = await resourceCommandService.ExecuteCommandAsync(request.ResourceName, request.CommandName, cancellationToken).ConfigureAwait(false); + + return new ExecuteResourceCommandResponse + { + Success = result.Success, + Canceled = result.Canceled, + ErrorMessage = result.ErrorMessage + }; + } + #endregion #region V1 API Methods (Legacy - Keep for backward compatibility) diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs index 772afe99710..6bf750cff6a 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs @@ -143,8 +143,13 @@ await eventing.PublishAsync( // Create JSON-RPC connection with proper System.Text.Json formatter so it doesn't use Newtonsoft.Json // and handles correct MCP SDK type serialization - + // Configure to use camelCase naming to match CLI's MCP SDK options var formatter = new SystemTextJsonFormatter(); + formatter.JsonSerializerOptions = new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase + }; var handler = new HeaderDelimitedMessageHandler(stream, formatter); using var rpc = new JsonRpc(handler, rpcTarget); diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 247638fdd5e..00e5c76733b 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -260,6 +260,43 @@ internal sealed class StopAppHostRequest /// internal sealed class StopAppHostResponse { } +/// +/// Request for executing a resource command. +/// +internal sealed class ExecuteResourceCommandRequest +{ + /// + /// Gets the resource name (or resource ID for replicas). + /// + public required string ResourceName { get; init; } + + /// + /// Gets the command name (e.g., "resource-start", "resource-stop", "resource-restart"). + /// + public required string CommandName { get; init; } +} + +/// +/// Response from executing a resource command. +/// +internal sealed class ExecuteResourceCommandResponse +{ + /// + /// Gets whether the command executed successfully. + /// + public required bool Success { get; init; } + + /// + /// Gets whether the command was canceled. + /// + public bool Canceled { get; init; } + + /// + /// Gets the error message if the command failed. + /// + public string? ErrorMessage { get; init; } +} + #endregion /// diff --git a/tests/Aspire.Cli.Tests/Commands/ResourceCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ResourceCommandTests.cs new file mode 100644 index 00000000000..4d9d0dbcba3 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/ResourceCommandTests.cs @@ -0,0 +1,110 @@ +// 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.Commands; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.InternalTesting; + +namespace Aspire.Cli.Tests.Commands; + +public class ResourceCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task ResourceCommand_Help_Works() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("command --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task ResourceCommand_RequiresResourceArgument() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("command"); + + // Missing required argument should fail + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.NotEqual(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task ResourceCommand_RequiresCommandArgument() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("command myresource"); + + // Missing required command argument should fail + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.NotEqual(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task ResourceCommand_AcceptsBothArguments() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("command myresource my-command --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task ResourceCommand_AcceptsProjectOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("command myresource my-command --project /path/to/project.csproj --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task ResourceCommand_AcceptsKnownCommandNames() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + + // Test with resource-start + var startResult = command.Parse("command myresource resource-start --help"); + var startExitCode = await startResult.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, startExitCode); + + // Test with resource-stop + var stopResult = command.Parse("command myresource resource-stop --help"); + var stopExitCode = await stopResult.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, stopExitCode); + + // Test with resource-restart + var restartResult = command.Parse("command myresource resource-restart --help"); + var restartExitCode = await restartResult.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, restartExitCode); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/RestartCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RestartCommandTests.cs new file mode 100644 index 00000000000..cbbb3a638ec --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/RestartCommandTests.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. + +using Aspire.Cli.Commands; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.InternalTesting; + +namespace Aspire.Cli.Tests.Commands; + +public class RestartCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task RestartCommand_Help_Works() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("restart --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task RestartCommand_RequiresResourceArgument() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("restart"); + + // Missing required argument should fail + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.NotEqual(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task RestartCommand_AcceptsResourceArgument() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("restart myresource --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task RestartCommand_AcceptsProjectOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("restart myresource --project /path/to/project.csproj --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs new file mode 100644 index 00000000000..4350993cd41 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.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. + +using Aspire.Cli.Commands; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.InternalTesting; + +namespace Aspire.Cli.Tests.Commands; + +public class StartCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task StartCommand_Help_Works() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("start --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task StartCommand_RequiresResourceArgument() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("start"); + + // Missing required argument should fail + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.NotEqual(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task StartCommand_AcceptsResourceArgument() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("start myresource --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task StartCommand_AcceptsProjectOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("start myresource --project /path/to/project.csproj --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs new file mode 100644 index 00000000000..d941afded2c --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs @@ -0,0 +1,152 @@ +// 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.Mcp.Tools; +using Aspire.Cli.Tests.TestServices; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Cli.Tests.Mcp; + +public class ExecuteResourceCommandToolTests +{ + private static IReadOnlyDictionary CreateArguments(string resourceName, string commandName) + { + var doc = JsonDocument.Parse($$""" + { + "resourceName": "{{resourceName}}", + "commandName": "{{commandName}}" + } + """); + return doc.RootElement.EnumerateObject() + .ToDictionary(p => p.Name, p => p.Value.Clone()); + } + + [Fact] + public async Task ExecuteResourceCommandTool_ThrowsException_WhenNoAppHostRunning() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); + + var exception = await Assert.ThrowsAsync( + () => tool.CallToolAsync(null!, CreateArguments("test-resource", "resource-start"), CancellationToken.None).AsTask()).DefaultTimeout(); + + Assert.Contains("No Aspire AppHost", exception.Message); + } + + [Fact] + public async Task ExecuteResourceCommandTool_ReturnsSuccess_WhenCommandExecutedSuccessfully() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + ExecuteResourceCommandResult = new ExecuteResourceCommandResponse { Success = true } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); + var result = await tool.CallToolAsync(null!, CreateArguments("api-service", "resource-start"), CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("successfully", textContent.Text); + Assert.Contains("api-service", textContent.Text); + Assert.Contains("resource-start", textContent.Text); + } + + [Fact] + public async Task ExecuteResourceCommandTool_ThrowsException_WhenCommandFails() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + ExecuteResourceCommandResult = new ExecuteResourceCommandResponse + { + Success = false, + ErrorMessage = "Resource not found" + } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); + + var exception = await Assert.ThrowsAsync( + () => tool.CallToolAsync(null!, CreateArguments("nonexistent", "resource-start"), CancellationToken.None).AsTask()).DefaultTimeout(); + + Assert.Contains("Resource not found", exception.Message); + } + + [Fact] + public async Task ExecuteResourceCommandTool_ThrowsException_WhenCommandCanceled() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + ExecuteResourceCommandResult = new ExecuteResourceCommandResponse + { + Success = false, + Canceled = true + } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); + + var exception = await Assert.ThrowsAsync( + () => tool.CallToolAsync(null!, CreateArguments("api-service", "resource-stop"), CancellationToken.None).AsTask()).DefaultTimeout(); + + Assert.Contains("cancelled", exception.Message); + } + + [Fact] + public async Task ExecuteResourceCommandTool_WorksWithKnownCommands() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + ExecuteResourceCommandResult = new ExecuteResourceCommandResponse { Success = true } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); + + // Test with resource-start + var startResult = await tool.CallToolAsync(null!, CreateArguments("api-service", "resource-start"), CancellationToken.None).DefaultTimeout(); + Assert.True(startResult.IsError is null or false); + + // Test with resource-stop + var stopResult = await tool.CallToolAsync(null!, CreateArguments("api-service", "resource-stop"), CancellationToken.None).DefaultTimeout(); + Assert.True(stopResult.IsError is null or false); + + // Test with resource-restart + var restartResult = await tool.CallToolAsync(null!, CreateArguments("api-service", "resource-restart"), CancellationToken.None).DefaultTimeout(); + Assert.True(restartResult.IsError is null or false); + } + + [Fact] + public async Task ExecuteResourceCommandTool_ThrowsException_WhenMissingArguments() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel(); + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); + + // Test with null arguments + var exception1 = await Assert.ThrowsAsync( + () => tool.CallToolAsync(null!, null, CancellationToken.None).AsTask()).DefaultTimeout(); + Assert.Contains("Missing required arguments", exception1.Message); + + // Test with only resourceName + var partialArgs = JsonDocument.Parse("""{"resourceName": "test"}""").RootElement + .EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()); + var exception2 = await Assert.ThrowsAsync( + () => tool.CallToolAsync(null!, partialArgs, CancellationToken.None).AsTask()).DefaultTimeout(); + Assert.Contains("Missing required arguments", exception2.Message); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs index b13da223aec..149ea2c6e2a 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs @@ -86,6 +86,19 @@ public Task StopAppHostAsync(CancellationToken cancellationToken = default return Task.FromResult(StopAppHostResult); } + /// + /// Gets or sets the result to return from ExecuteResourceCommandAsync. + /// + public ExecuteResourceCommandResponse ExecuteResourceCommandResult { get; set; } = new ExecuteResourceCommandResponse { Success = true }; + + public Task ExecuteResourceCommandAsync( + string resourceName, + string commandName, + CancellationToken cancellationToken = default) + { + return Task.FromResult(ExecuteResourceCommandResult); + } + public Task CallResourceMcpToolAsync( string resourceName, string toolName, diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index a88ed74a1d9..5cd76143d51 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -149,6 +149,9 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); From 769c3df1e2a6e08e4d2ee29a511ea1b13e2383f5 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 3 Feb 2026 16:09:19 +0800 Subject: [PATCH 027/256] Refactor CLI list_console_logs to get data from app host (#14311) --- src/Aspire.Cli/Aspire.Cli.csproj | 8 + src/Aspire.Cli/Commands/AgentMcpCommand.cs | 2 +- src/Aspire.Cli/Mcp/KnownMcpTools.cs | 3 +- .../Mcp/Tools/ListConsoleLogsTool.cs | 86 ++++++-- src/Aspire.Dashboard/Aspire.Dashboard.csproj | 4 + .../Components/Controls/GridValue.razor.cs | 2 +- .../Components/Controls/LogViewer.razor | 2 +- .../Components/Controls/LogViewer.razor.cs | 2 +- .../Components/Pages/ConsoleLogs.razor.cs | 2 +- .../ConsoleLogs/LogEntrySerializer.cs | 2 +- .../Mcp/AspireResourceMcpTools.cs | 12 +- .../Model/Assistant/AIHelpers.cs | 117 +--------- .../Assistant/AssistantChatDataContext.cs | 12 +- .../Model/Assistant/PromptContext.cs | 4 +- .../Model/ConsoleLogsFetcher.cs | 3 +- .../ApplicationModel/ResourceLoggerService.cs | 2 +- .../Dashboard/DashboardEventHandlers.cs | 2 +- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 3 +- src/Aspire.Hosting/IConsoleLogsService.cs | 4 +- .../ConsoleLogs/AnsiParser.cs | 2 +- src/Shared/ConsoleLogs/LogEntries.cs | 2 +- src/Shared/ConsoleLogs/LogEntry.cs | 2 +- .../ConsoleLogs/LogParser.cs | 3 +- src/Shared/ConsoleLogs/LogPauseViewModel.cs | 4 +- src/Shared/ConsoleLogs/SharedAIHelpers.cs | 159 ++++++++++++++ src/Shared/ConsoleLogs/TimestampParser.cs | 2 +- .../ConsoleLogs/UrlParser.cs | 2 +- .../Mcp/ListConsoleLogsToolTests.cs | 207 ++++++++++++++++++ .../Pages/ConsoleLogsTests.cs | 2 +- .../ConsoleLogsTests/AnsiParserTests.cs | 2 +- .../ConsoleLogsTests/LogEntriesTests.cs | 3 +- .../ConsoleLogsTests/TimestampParserTests.cs | 2 +- .../ConsoleLogsTests/UrlParserTests.cs | 2 +- .../Model/AIAssistant/AIHelpersTests.cs | 15 +- .../AssistantChatDataContextTests.cs | 3 +- .../Dashboard/DashboardLifecycleHookTests.cs | 2 +- .../Dashboard/DashboardServiceTests.cs | 2 +- .../ResourceLoggerServiceTests.cs | 2 +- .../Utils/TestConsoleLogsService.cs | 2 +- 39 files changed, 516 insertions(+), 176 deletions(-) rename src/{Aspire.Dashboard => Shared}/ConsoleLogs/AnsiParser.cs (99%) rename src/{Aspire.Dashboard => Shared}/ConsoleLogs/LogParser.cs (97%) create mode 100644 src/Shared/ConsoleLogs/SharedAIHelpers.cs rename src/{Aspire.Dashboard => Shared}/ConsoleLogs/UrlParser.cs (98%) create mode 100644 tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index f2396b691e9..fa4d3683a6f 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -79,6 +79,14 @@ + + + + + + + + diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index 444a774ceeb..ef5a35719d5 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -72,7 +72,7 @@ public AgentMcpCommand( _knownTools = new Dictionary { [KnownMcpTools.ListResources] = new ListResourcesTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), - [KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(), + [KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), [KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), [KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(), [KnownMcpTools.ListTraces] = new ListTracesTool(), diff --git a/src/Aspire.Cli/Mcp/KnownMcpTools.cs b/src/Aspire.Cli/Mcp/KnownMcpTools.cs index c4e28eaae88..78e65c1d01a 100644 --- a/src/Aspire.Cli/Mcp/KnownMcpTools.cs +++ b/src/Aspire.Cli/Mcp/KnownMcpTools.cs @@ -34,12 +34,11 @@ ListDocs or SearchDocs or GetDoc or ListResources or + ListConsoleLogs or ExecuteResourceCommand; public static bool IsDashboardTool(string toolName) => toolName is - ListConsoleLogs or ListStructuredLogs or ListTraces or ListTraceStructuredLogs; - } diff --git a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs index ee83b0398a1..445b0710850 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs @@ -2,12 +2,19 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Shared.ConsoleLogs; +using Microsoft.Extensions.Logging; using ModelContextProtocol; using ModelContextProtocol.Protocol; namespace Aspire.Cli.Mcp.Tools; -internal sealed class ListConsoleLogsTool : CliMcpTool +/// +/// MCP tool for listing console logs for a resource. +/// Gets log data directly from the AppHost backchannel instead of forwarding to the dashboard. +/// +internal sealed class ListConsoleLogsTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListConsoleLogs; @@ -31,22 +38,73 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) { - // Convert JsonElement arguments to Dictionary - Dictionary? convertedArgs = null; - if (arguments != null) + // This tool does not use the MCP client as it operates via backchannel + _ = mcpClient; + + // Get the resource name from arguments + string? resourceName = null; + if (arguments is not null && arguments.TryGetValue("resourceName", out var resourceNameElement)) + { + resourceName = resourceNameElement.GetString(); + } + + if (string.IsNullOrEmpty(resourceName)) { - convertedArgs = new Dictionary(); - foreach (var kvp in arguments) + throw new McpProtocolException("The resourceName parameter is required.", McpErrorCode.InvalidParams); + } + + var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + if (connection is null) + { + logger.LogWarning("No Aspire AppHost is currently running"); + throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError); + } + + try + { + var logParser = new LogParser(ConsoleColor.Black); + var logEntries = new LogEntries(maximumEntryCount: SharedAIHelpers.ConsoleLogsLimit) { BaseLineNumber = 1 }; + + // Collect logs from the backchannel + await foreach (var logLine in connection.GetResourceLogsAsync(resourceName, follow: false, cancellationToken).ConfigureAwait(false)) { - convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; + logEntries.InsertSorted(logParser.CreateLogEntry(logLine.Content, logLine.IsError, resourceName)); } - } - // Forward the call to the dashboard's MCP server - return await mcpClient.CallToolAsync( - Name, - convertedArgs, - serializerOptions: McpJsonUtilities.DefaultOptions, - cancellationToken: cancellationToken); + var entries = logEntries.GetEntries().ToList(); + var totalLogsCount = entries.Count == 0 ? 0 : entries.Last().LineNumber; + var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( + entries, + totalLogsCount, + SharedAIHelpers.ConsoleLogsLimit, + "console log", + "console logs", + SharedAIHelpers.SerializeLogEntry, + logEntry => SharedAIHelpers.EstimateTokenCount((string)logEntry)); + var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems.Cast().ToList()); + + var consoleLogsData = $""" + {limitMessage} + + # CONSOLE LOGS + + ```plaintext + {consoleLogsText.Trim()} + ``` + """; + + return new CallToolResult + { + Content = [new TextContentBlock { Text = consoleLogsData }] + }; + } + catch (Exception ex) when (ex is not McpProtocolException) + { + logger.LogError(ex, "Error retrieving console logs for resource '{ResourceName}'", resourceName); + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"Error retrieving console logs for resource '{resourceName}': {ex.Message}" }] + }; + } } } diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index fcf85d0989b..79511ee4e99 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -295,10 +295,14 @@ + + + + diff --git a/src/Aspire.Dashboard/Components/Controls/GridValue.razor.cs b/src/Aspire.Dashboard/Components/Controls/GridValue.razor.cs index 4567f0f10bc..11cb86c31ed 100644 --- a/src/Aspire.Dashboard/Components/Controls/GridValue.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/GridValue.razor.cs @@ -3,9 +3,9 @@ using System.Net; using Aspire.Dashboard.Components.Dialogs; -using Aspire.Dashboard.ConsoleLogs; using Aspire.Dashboard.Model; using Aspire.Dashboard.Resources; +using Aspire.Shared.ConsoleLogs; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.JSInterop; diff --git a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor index 70ab7be1cec..5da12931c3a 100644 --- a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor +++ b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor @@ -1,6 +1,6 @@ @namespace Aspire.Dashboard.Components @using Aspire.Dashboard.Resources -@using Aspire.Hosting.ConsoleLogs +@using Aspire.Shared.ConsoleLogs @inject IJSRuntime JS @inject IStringLocalizer Loc diff --git a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs index 8d62a723fb9..424735f5519 100644 --- a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs @@ -5,7 +5,7 @@ using Aspire.Dashboard.Extensions; using Aspire.Dashboard.Model; using Aspire.Dashboard.Utils; -using Aspire.Hosting.ConsoleLogs; +using Aspire.Shared.ConsoleLogs; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web.Virtualization; using Microsoft.JSInterop; diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index 34f7e3b3f86..2391d64f1c7 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -17,7 +17,7 @@ using Aspire.Dashboard.Resources; using Aspire.Dashboard.Telemetry; using Aspire.Dashboard.Utils; -using Aspire.Hosting.ConsoleLogs; +using Aspire.Shared.ConsoleLogs; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; diff --git a/src/Aspire.Dashboard/ConsoleLogs/LogEntrySerializer.cs b/src/Aspire.Dashboard/ConsoleLogs/LogEntrySerializer.cs index 3d1f4bddaea..01fec90db30 100644 --- a/src/Aspire.Dashboard/ConsoleLogs/LogEntrySerializer.cs +++ b/src/Aspire.Dashboard/ConsoleLogs/LogEntrySerializer.cs @@ -1,7 +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.Hosting.ConsoleLogs; +using Aspire.Shared.ConsoleLogs; namespace Aspire.Dashboard.ConsoleLogs; diff --git a/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs b/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs index 9b1ff606bd0..5e81a195940 100644 --- a/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs +++ b/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs @@ -3,10 +3,9 @@ using System.ComponentModel; using Aspire.Dashboard.Configuration; -using Aspire.Dashboard.ConsoleLogs; using Aspire.Dashboard.Model; using Aspire.Dashboard.Model.Assistant; -using Aspire.Hosting.ConsoleLogs; +using Aspire.Shared.ConsoleLogs; using Microsoft.Extensions.Options; using ModelContextProtocol; using ModelContextProtocol.Server; @@ -116,14 +115,15 @@ public async Task ListConsoleLogsAsync( var entries = logEntries.GetEntries().ToList(); var totalLogsCount = entries.Count == 0 ? 0 : entries.Last().LineNumber; - var (trimmedItems, limitMessage) = AIHelpers.GetLimitFromEndWithSummary( + var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( entries, totalLogsCount, AIHelpers.ConsoleLogsLimit, "console log", - AIHelpers.SerializeLogEntry, - logEntry => AIHelpers.EstimateTokenCount((string)logEntry)); - var consoleLogsText = AIHelpers.SerializeConsoleLogs(trimmedItems.Cast().ToList()); + "console logs", + SharedAIHelpers.SerializeLogEntry, + logEntry => SharedAIHelpers.EstimateTokenCount((string)logEntry)); + var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems.Cast().ToList()); var consoleLogsData = $""" {limitMessage} diff --git a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs index d0f9ce0c8e1..47cc68b30a8 100644 --- a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs +++ b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs @@ -4,18 +4,15 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using Aspire.Dashboard.Configuration; -using Aspire.Dashboard.ConsoleLogs; using Aspire.Dashboard.Extensions; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Resources; using Aspire.Dashboard.Utils; -using Aspire.Hosting.ConsoleLogs; -using Humanizer; +using Aspire.Shared.ConsoleLogs; using Microsoft.Extensions.AI; using Microsoft.Extensions.Localization; @@ -25,16 +22,16 @@ internal static class AIHelpers { public const int TracesLimit = 200; public const int StructuredLogsLimit = 200; - public const int ConsoleLogsLimit = 500; + public const int ConsoleLogsLimit = SharedAIHelpers.ConsoleLogsLimit; // There is currently a 64K token limit in VS. // Limit the result from individual token calls to a smaller number so multiple results can live inside the context. - public const int MaximumListTokenLength = 8192; + public const int MaximumListTokenLength = SharedAIHelpers.MaximumListTokenLength; // This value is chosen to balance: // - Providing enough data to the model for it to provide accurate answers. // - Providing too much data and exceeding length limits. - public const int MaximumStringLength = 2048; + public const int MaximumStringLength = SharedAIHelpers.MaximumStringLength; // Always pass English translations to AI private static readonly IStringLocalizer s_columnsLoc = new InvariantStringLocalizer(); @@ -119,10 +116,11 @@ private static int ConvertToMilliseconds(TimeSpan duration) public static (string json, string limitMessage) GetTracesJson(List traces, IEnumerable outgoingPeerResolvers, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null) { var promptContext = new PromptContext(); - var (trimmedItems, limitMessage) = GetLimitFromEndWithSummary( + var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( traces, TracesLimit, "trace", + "traces", trace => GetTraceDto(trace, outgoingPeerResolvers, promptContext, options, includeDashboardUrl, getResourceName), EstimateSerializedJsonTokenSize); var tracesData = SerializeJson(trimmedItems); @@ -292,17 +290,10 @@ static List GetResourceRelationships(List allResource return new Uri(new Uri(frontendUrl), path).ToString(); } - public static int EstimateTokenCount(string text) - { - // This is a rough estimate of the number of tokens in the text. - // If the exact value is needed then use a library to calculate. - return text.Length / 4; - } - public static int EstimateSerializedJsonTokenSize(T value) { var json = SerializeJson(value); - return EstimateTokenCount(json); + return SharedAIHelpers.EstimateTokenCount(json); } private static string SerializeJson(T value) @@ -313,10 +304,11 @@ private static string SerializeJson(T value) public static (string json, string limitMessage) GetStructuredLogsJson(List errorLogs, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null) { var promptContext = new PromptContext(); - var (trimmedItems, limitMessage) = GetLimitFromEndWithSummary( + var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( errorLogs, StructuredLogsLimit, "log entry", + "log entries", i => GetLogEntryDto(i, promptContext, options, includeDashboardUrl, getResourceName), EstimateSerializedJsonTokenSize); var logsData = SerializeJson(trimmedItems); @@ -359,36 +351,6 @@ public static object GetLogEntryDto(OtlpLogEntry l, PromptContext context, Dashb return log; } - public static string SerializeConsoleLogs(IList logEntries) - { - var consoleLogsText = new StringBuilder(); - - foreach (var logEntry in logEntries) - { - consoleLogsText.AppendLine(logEntry); - } - - return consoleLogsText.ToString(); - } - - public static string SerializeLogEntry(LogEntry logEntry) - { - if (logEntry.RawContent is not null) - { - var content = logEntry.RawContent; - if (TimestampParser.TryParseConsoleTimestamp(content, out var timestampParseResult)) - { - content = timestampParseResult.Value.ModifiedText; - } - - return LimitLength(AnsiParser.StripControlSequences(content)); - } - else - { - return string.Empty; - } - } - public static bool TryGetSingleResult(IEnumerable source, Func predicate, [NotNullWhen(true)] out T? result) { result = default; @@ -558,67 +520,6 @@ public static bool IsMissingValue([NotNullWhen(false)] string? value) return string.IsNullOrWhiteSpace(value) || string.Equals(value, "null", StringComparison.OrdinalIgnoreCase); } - public static string LimitLength(string value) - { - if (value.Length <= MaximumStringLength) - { - return value; - } - - return - $""" - {value.AsSpan(0, MaximumStringLength)}...[TRUNCATED] - """; - } - - public static (List items, string message) GetLimitFromEndWithSummary(List values, int limit, string itemName, Func convertToDto, Func estimateTokenSize) - { - return GetLimitFromEndWithSummary(values, values.Count, limit, itemName, convertToDto, estimateTokenSize); - } - - public static (List items, string message) GetLimitFromEndWithSummary(List values, int totalValues, int limit, string itemName, Func convertToDto, Func estimateTokenSize) - { - Debug.Assert(totalValues >= values.Count, "Total values should be large or equal to the values passed into the method."); - - var trimmedItems = values.Count <= limit - ? values - : values[^limit..]; - - var currentTokenCount = 0; - var serializedValuesCount = 0; - var dtos = trimmedItems.Select(i => convertToDto(i)).ToList(); - - // Loop backwards to prioritize the latest items. - for (var i = dtos.Count - 1; i >= 0; i--) - { - var obj = dtos[i]; - var tokenCount = estimateTokenSize(obj); - - if (currentTokenCount + tokenCount > AIHelpers.MaximumListTokenLength) - { - break; - } - - serializedValuesCount++; - currentTokenCount += tokenCount; - } - - // Trim again with what fits in the token limit. - dtos = dtos[^serializedValuesCount..]; - - return (dtos, GetLimitSummary(totalValues, dtos.Count, itemName)); - } - - private static string GetLimitSummary(int totalValues, int returnedCount, string itemName) - { - if (totalValues == returnedCount) - { - return $"Returned {itemName.ToQuantity(totalValues, formatProvider: CultureInfo.InvariantCulture)}."; - } - - return $"Returned latest {itemName.ToQuantity(returnedCount, formatProvider: CultureInfo.InvariantCulture)}. Earlier {itemName.ToQuantity(totalValues - returnedCount, formatProvider: CultureInfo.InvariantCulture)} not returned because of size limits."; - } - public static bool IsResourceAIOptOut(ResourceViewModel r) { return r.Properties.TryGetValue(KnownProperties.Resource.ExcludeFromMcp, out var v) && v.Value.TryConvertToBool(out var b) && b; diff --git a/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs b/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs index 6881413773f..012ddbe4fed 100644 --- a/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs +++ b/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs @@ -5,12 +5,11 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using Aspire.Dashboard.Configuration; -using Aspire.Dashboard.ConsoleLogs; using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Resources; -using Aspire.Hosting.ConsoleLogs; +using Aspire.Shared.ConsoleLogs; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; @@ -265,14 +264,15 @@ public async Task GetConsoleLogsAsync( var entries = logEntries.GetEntries().ToList(); var totalLogsCount = entries.Count == 0 ? 0 : entries.Last().LineNumber; - var (trimmedItems, limitMessage) = AIHelpers.GetLimitFromEndWithSummary( + var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( entries, totalLogsCount, AIHelpers.ConsoleLogsLimit, "console log", - AIHelpers.SerializeLogEntry, - logEntry => AIHelpers.EstimateTokenCount((string) logEntry)); - var consoleLogsText = AIHelpers.SerializeConsoleLogs(trimmedItems.Cast().ToList()); + "console logs", + SharedAIHelpers.SerializeLogEntry, + logEntry => SharedAIHelpers.EstimateTokenCount((string) logEntry)); + var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems.Cast().ToList()); var consoleLogsData = $""" {limitMessage} diff --git a/src/Aspire.Dashboard/Model/Assistant/PromptContext.cs b/src/Aspire.Dashboard/Model/Assistant/PromptContext.cs index 1ff9cb54636..1840d140ea8 100644 --- a/src/Aspire.Dashboard/Model/Assistant/PromptContext.cs +++ b/src/Aspire.Dashboard/Model/Assistant/PromptContext.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 Aspire.Shared.ConsoleLogs; + namespace Aspire.Dashboard.Model.Assistant; internal sealed class PromptContext @@ -18,7 +20,7 @@ internal sealed class PromptContext } input = RemoveDuplicateLines(input); - input = AIHelpers.LimitLength(input); + input = SharedAIHelpers.LimitLength(input); if (!_promptValueMap.TryGetValue(input, out var reference)) { diff --git a/src/Aspire.Dashboard/Model/ConsoleLogsFetcher.cs b/src/Aspire.Dashboard/Model/ConsoleLogsFetcher.cs index af6e67f3a57..b6245c697a0 100644 --- a/src/Aspire.Dashboard/Model/ConsoleLogsFetcher.cs +++ b/src/Aspire.Dashboard/Model/ConsoleLogsFetcher.cs @@ -1,8 +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.Dashboard.ConsoleLogs; -using Aspire.Hosting.ConsoleLogs; +using Aspire.Shared.ConsoleLogs; namespace Aspire.Dashboard.Model; diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs index f51770b949b..e88d5dfb5bc 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceLoggerService.cs @@ -6,7 +6,7 @@ using System.Globalization; using System.Runtime.CompilerServices; using System.Threading.Channels; -using Aspire.Hosting.ConsoleLogs; +using Aspire.Shared.ConsoleLogs; using Microsoft.Extensions.Logging; namespace Aspire.Hosting.ApplicationModel; diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 0ea7ccd323c..8d714d230b6 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -11,13 +11,13 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using Aspire.Dashboard.ConsoleLogs; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp; using Aspire.Hosting.Devcontainers.Codespaces; using Aspire.Hosting.Eventing; using Aspire.Hosting.Lifecycle; using Aspire.Hosting.Utils; +using Aspire.Shared.ConsoleLogs; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 1d6b253359c..d6751adbeef 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -20,14 +20,13 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Channels; -using Aspire.Dashboard.ConsoleLogs; using Aspire.Dashboard.Model; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.ConsoleLogs; using Aspire.Hosting.Dashboard; using Aspire.Hosting.Dcp.Model; using Aspire.Hosting.Eventing; using Aspire.Hosting.Utils; +using Aspire.Shared.ConsoleLogs; using Json.Patch; using k8s; using k8s.Autorest; diff --git a/src/Aspire.Hosting/IConsoleLogsService.cs b/src/Aspire.Hosting/IConsoleLogsService.cs index 90abe92a7d4..a1b83bcd8ff 100644 --- a/src/Aspire.Hosting/IConsoleLogsService.cs +++ b/src/Aspire.Hosting/IConsoleLogsService.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Aspire.Hosting.ConsoleLogs; +using Aspire.Shared.ConsoleLogs; + +namespace Aspire.Hosting; internal interface IConsoleLogsService { diff --git a/src/Aspire.Dashboard/ConsoleLogs/AnsiParser.cs b/src/Shared/ConsoleLogs/AnsiParser.cs similarity index 99% rename from src/Aspire.Dashboard/ConsoleLogs/AnsiParser.cs rename to src/Shared/ConsoleLogs/AnsiParser.cs index 2d6e7b3f357..ae72fc79300 100644 --- a/src/Aspire.Dashboard/ConsoleLogs/AnsiParser.cs +++ b/src/Shared/ConsoleLogs/AnsiParser.cs @@ -3,7 +3,7 @@ using System.Text; -namespace Aspire.Dashboard.ConsoleLogs; +namespace Aspire.Shared.ConsoleLogs; public class AnsiParser { diff --git a/src/Shared/ConsoleLogs/LogEntries.cs b/src/Shared/ConsoleLogs/LogEntries.cs index 44d16c62754..ae1983025b2 100644 --- a/src/Shared/ConsoleLogs/LogEntries.cs +++ b/src/Shared/ConsoleLogs/LogEntries.cs @@ -3,7 +3,7 @@ using System.Diagnostics; -namespace Aspire.Hosting.ConsoleLogs; +namespace Aspire.Shared.ConsoleLogs; // Type is shared by dashboard and hosting. // It needs to be public in dashboard so it can be bound to a parameter. diff --git a/src/Shared/ConsoleLogs/LogEntry.cs b/src/Shared/ConsoleLogs/LogEntry.cs index c440e6519b0..3e8dc2a8799 100644 --- a/src/Shared/ConsoleLogs/LogEntry.cs +++ b/src/Shared/ConsoleLogs/LogEntry.cs @@ -3,7 +3,7 @@ using System.Diagnostics; -namespace Aspire.Hosting.ConsoleLogs; +namespace Aspire.Shared.ConsoleLogs; [DebuggerDisplay("LineNumber = {LineNumber}, Timestamp = {Timestamp}, ResourcePrefix = {ResourcePrefix}, Content = {Content}, Type = {Type}")] #if ASPIRE_DASHBOARD diff --git a/src/Aspire.Dashboard/ConsoleLogs/LogParser.cs b/src/Shared/ConsoleLogs/LogParser.cs similarity index 97% rename from src/Aspire.Dashboard/ConsoleLogs/LogParser.cs rename to src/Shared/ConsoleLogs/LogParser.cs index 1a9ef9dd7db..75521c6d06d 100644 --- a/src/Aspire.Dashboard/ConsoleLogs/LogParser.cs +++ b/src/Shared/ConsoleLogs/LogParser.cs @@ -2,9 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; -using Aspire.Hosting.ConsoleLogs; -namespace Aspire.Dashboard.ConsoleLogs; +namespace Aspire.Shared.ConsoleLogs; internal sealed class LogParser { diff --git a/src/Shared/ConsoleLogs/LogPauseViewModel.cs b/src/Shared/ConsoleLogs/LogPauseViewModel.cs index 23e88aae0f1..be89abeee7b 100644 --- a/src/Shared/ConsoleLogs/LogPauseViewModel.cs +++ b/src/Shared/ConsoleLogs/LogPauseViewModel.cs @@ -1,12 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#if ASPIRE_DASHBOARD using System.Globalization; using Aspire.Dashboard.Model; using Aspire.Dashboard.Utils; using Microsoft.Extensions.Localization; +#endif -namespace Aspire.Hosting.ConsoleLogs; +namespace Aspire.Shared.ConsoleLogs; #if ASPIRE_DASHBOARD public sealed class LogPauseViewModel diff --git a/src/Shared/ConsoleLogs/SharedAIHelpers.cs b/src/Shared/ConsoleLogs/SharedAIHelpers.cs new file mode 100644 index 00000000000..1716d428312 --- /dev/null +++ b/src/Shared/ConsoleLogs/SharedAIHelpers.cs @@ -0,0 +1,159 @@ +// 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.Globalization; +using System.Text; + +namespace Aspire.Shared.ConsoleLogs; + +/// +/// Shared AI helper methods for console log processing. +/// Used by both Dashboard and CLI. +/// +internal static class SharedAIHelpers +{ + public const int ConsoleLogsLimit = 500; + public const int MaximumListTokenLength = 8192; + public const int MaximumStringLength = 2048; + + /// + /// Estimates the token count for a string. + /// This is a rough estimate - use a library for exact calculation. + /// + public static int EstimateTokenCount(string text) + { + return text.Length / 4; + } + + /// + /// Serializes a log entry to a string, stripping timestamps and ANSI control sequences. + /// + public static string SerializeLogEntry(LogEntry logEntry) + { + if (logEntry.RawContent is not null) + { + var content = logEntry.RawContent; + if (TimestampParser.TryParseConsoleTimestamp(content, out var timestampParseResult)) + { + content = timestampParseResult.Value.ModifiedText; + } + + return LimitLength(AnsiParser.StripControlSequences(content)); + } + else + { + return string.Empty; + } + } + + /// + /// Serializes a list of log entry strings to a single string with newlines. + /// + public static string SerializeConsoleLogs(IList logEntries) + { + var consoleLogsText = new StringBuilder(); + + foreach (var logEntry in logEntries) + { + consoleLogsText.AppendLine(logEntry); + } + + return consoleLogsText.ToString(); + } + + /// + /// Limits a string to the maximum length, appending a truncation marker if needed. + /// + public static string LimitLength(string value) + { + if (value.Length <= MaximumStringLength) + { + return value; + } + + return + $""" + {value.AsSpan(0, MaximumStringLength)}...[TRUNCATED] + """; + } + + /// + /// Gets items from the end of a list with a summary message, applying count and token limits. + /// + public static (List items, string message) GetLimitFromEndWithSummary( + List values, + int limit, + string itemName, + string pluralItemName, + Func convertToDto, + Func estimateTokenSize) + { + return GetLimitFromEndWithSummary(values, values.Count, limit, itemName, pluralItemName, convertToDto, estimateTokenSize); + } + + /// + /// Gets items from the end of a list with a summary message, applying count and token limits. + /// + public static (List items, string message) GetLimitFromEndWithSummary( + List values, + int totalValues, + int limit, + string itemName, + string pluralItemName, + Func convertToDto, + Func estimateTokenSize) + { + Debug.Assert(totalValues >= values.Count, "Total values should be large or equal to the values passed into the method."); + + var trimmedItems = values.Count <= limit + ? values + : values[^limit..]; + + var currentTokenCount = 0; + var serializedValuesCount = 0; + var dtos = trimmedItems.Select(i => convertToDto(i)).ToList(); + + // Loop backwards to prioritize the latest items. + for (var i = dtos.Count - 1; i >= 0; i--) + { + var obj = dtos[i]; + var tokenCount = estimateTokenSize(obj); + + if (currentTokenCount + tokenCount > MaximumListTokenLength) + { + break; + } + + serializedValuesCount++; + currentTokenCount += tokenCount; + } + + // Trim again with what fits in the token limit. + dtos = dtos[^serializedValuesCount..]; + + return (dtos, GetLimitSummary(totalValues, dtos.Count, itemName, pluralItemName)); + } + + /// + /// Gets a summary message describing how many items were returned vs total. + /// + public static string GetLimitSummary(int totalValues, int returnedCount, string itemName, string pluralItemName) + { + if (totalValues == returnedCount) + { + return $"Returned {ToQuantity(returnedCount, itemName, pluralItemName)}."; + } + + return $"Returned latest {ToQuantity(returnedCount, itemName, pluralItemName)}. Earlier {ToQuantity(totalValues - returnedCount, itemName, pluralItemName)} not returned because of size limits."; + } + + /// + /// Formats an item name with quantity (e.g., "1 console log" or "5 console logs"). + /// + private static string ToQuantity(int count, string itemName, string pluralItemName) + { + var name = count == 1 ? itemName : pluralItemName; + return string.Create(CultureInfo.InvariantCulture, $"{count} {name}"); + } +} diff --git a/src/Shared/ConsoleLogs/TimestampParser.cs b/src/Shared/ConsoleLogs/TimestampParser.cs index de2ea0745e6..5d557ab6ece 100644 --- a/src/Shared/ConsoleLogs/TimestampParser.cs +++ b/src/Shared/ConsoleLogs/TimestampParser.cs @@ -5,7 +5,7 @@ using System.Globalization; using System.Text.RegularExpressions; -namespace Aspire.Dashboard.ConsoleLogs; +namespace Aspire.Shared.ConsoleLogs; internal static partial class TimestampParser { diff --git a/src/Aspire.Dashboard/ConsoleLogs/UrlParser.cs b/src/Shared/ConsoleLogs/UrlParser.cs similarity index 98% rename from src/Aspire.Dashboard/ConsoleLogs/UrlParser.cs rename to src/Shared/ConsoleLogs/UrlParser.cs index 242bfc9e503..fa955601d1c 100644 --- a/src/Aspire.Dashboard/ConsoleLogs/UrlParser.cs +++ b/src/Shared/ConsoleLogs/UrlParser.cs @@ -7,7 +7,7 @@ using System.Text; using System.Text.RegularExpressions; -namespace Aspire.Dashboard.ConsoleLogs; +namespace Aspire.Shared.ConsoleLogs; public static partial class UrlParser { diff --git a/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs new file mode 100644 index 00000000000..0cd6eba5427 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs @@ -0,0 +1,207 @@ +// 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.RegularExpressions; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Mcp.Tools; +using Aspire.Cli.Tests.TestServices; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Cli.Tests.Mcp; + +public class ListConsoleLogsToolTests +{ + [Fact] + public async Task ListConsoleLogsTool_ThrowsException_WhenNoAppHostRunning() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var tool = new ListConsoleLogsTool(monitor, NullLogger.Instance); + + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse("\"test-resource\"").RootElement + }; + + var exception = await Assert.ThrowsAsync( + () => tool.CallToolAsync(null!, arguments, CancellationToken.None).AsTask()).DefaultTimeout(); + + Assert.Contains("No Aspire AppHost", exception.Message); + } + + [Fact] + public async Task ListConsoleLogsTool_ThrowsException_WhenResourceNameNotProvided() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel(); + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListConsoleLogsTool(monitor, NullLogger.Instance); + + var exception = await Assert.ThrowsAsync( + () => tool.CallToolAsync(null!, null, CancellationToken.None).AsTask()).DefaultTimeout(); + + Assert.Contains("resourceName", exception.Message); + } + + [Fact] + public async Task ListConsoleLogsTool_ReturnsLogs_WhenResourceHasNoLogs() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + LogLines = [] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListConsoleLogsTool(monitor, NullLogger.Instance); + + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse("\"test-resource\"").RootElement + }; + + var result = await tool.CallToolAsync(null!, arguments, CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + + var codeBlockContent = ExtractCodeBlockContent(textContent.Text); + Assert.Equal("", codeBlockContent); + Assert.StartsWith("Returned 0 console logs.", textContent.Text); + } + + [Fact] + public async Task ListConsoleLogsTool_ReturnsLogs_ForSpecificResource() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + LogLines = + [ + new ResourceLogLine { ResourceName = "api-service", LineNumber = 1, Content = "Starting application...", IsError = false }, + new ResourceLogLine { ResourceName = "api-service", LineNumber = 2, Content = "Application started", IsError = false }, + new ResourceLogLine { ResourceName = "other-service", LineNumber = 1, Content = "Different service log", IsError = false } + ] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListConsoleLogsTool(monitor, NullLogger.Instance); + + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement + }; + + var result = await tool.CallToolAsync(null!, arguments, CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + + var codeBlockContent = ExtractCodeBlockContent(textContent.Text); + Assert.Equal( + """ + Starting application... + Application started + """, codeBlockContent); + } + + [Fact] + public async Task ListConsoleLogsTool_ReturnsPlainTextFormat() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + LogLines = + [ + new ResourceLogLine { ResourceName = "api-service", LineNumber = 1, Content = "Test log line", IsError = false } + ] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListConsoleLogsTool(monitor, NullLogger.Instance); + + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement + }; + + var result = await tool.CallToolAsync(null!, arguments, CancellationToken.None).DefaultTimeout(); + + var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + + var codeBlockContent = ExtractCodeBlockContent(textContent.Text); + Assert.Equal("Test log line", codeBlockContent); + Assert.StartsWith("Returned 1 console log.", textContent.Text); + } + + [Fact] + public async Task ListConsoleLogsTool_StripsTimestamps() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + LogLines = + [ + new ResourceLogLine { ResourceName = "api-service", LineNumber = 1, Content = "2024-01-15T10:30:00.123Z Log message after timestamp", IsError = false } + ] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListConsoleLogsTool(monitor, NullLogger.Instance); + + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement + }; + + var result = await tool.CallToolAsync(null!, arguments, CancellationToken.None).DefaultTimeout(); + + var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + + var codeBlockContent = ExtractCodeBlockContent(textContent.Text); + Assert.Equal("Log message after timestamp", codeBlockContent); + } + + [Fact] + public async Task ListConsoleLogsTool_StripsAnsiSequences() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + LogLines = + [ + new ResourceLogLine { ResourceName = "api-service", LineNumber = 1, Content = "\u001b[32mGreen text\u001b[0m normal text", IsError = false } + ] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = new ListConsoleLogsTool(monitor, NullLogger.Instance); + + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement + }; + + var result = await tool.CallToolAsync(null!, arguments, CancellationToken.None).DefaultTimeout(); + + var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; + Assert.NotNull(textContent); + + var codeBlockContent = ExtractCodeBlockContent(textContent.Text); + Assert.Equal("Green text normal text", codeBlockContent); + } + + private static string ExtractCodeBlockContent(string text) + { + var match = Regex.Match(text, @"```plaintext\s*(.*?)\s*```", RegexOptions.Singleline); + return match.Success ? match.Groups[1].Value : string.Empty; + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs index 2ce66136b78..a843ef20aea 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs @@ -8,7 +8,7 @@ using Aspire.Dashboard.Model; using Aspire.Dashboard.Tests.Shared; using Aspire.Dashboard.Utils; -using Aspire.Hosting.ConsoleLogs; +using Aspire.Shared.ConsoleLogs; using Aspire.Tests.Shared.DashboardModel; using Bunit; using Microsoft.AspNetCore.Components; diff --git a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/AnsiParserTests.cs b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/AnsiParserTests.cs index cc8a3a53c08..c65bd784280 100644 --- a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/AnsiParserTests.cs +++ b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/AnsiParserTests.cs @@ -1,7 +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.Dashboard.ConsoleLogs; +using Aspire.Shared.ConsoleLogs; using Xunit; namespace Aspire.Dashboard.Tests.ConsoleLogsTests; diff --git a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs index 01325b9ebdf..63e608d6c6c 100644 --- a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs +++ b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs @@ -1,8 +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.Dashboard.ConsoleLogs; -using Aspire.Hosting.ConsoleLogs; +using Aspire.Shared.ConsoleLogs; using Xunit; namespace Aspire.Dashboard.Tests.ConsoleLogsTests; diff --git a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/TimestampParserTests.cs b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/TimestampParserTests.cs index 4ddc2460e5a..edc0b19be24 100644 --- a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/TimestampParserTests.cs +++ b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/TimestampParserTests.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; -using Aspire.Dashboard.ConsoleLogs; +using Aspire.Shared.ConsoleLogs; using Xunit; namespace Aspire.Dashboard.Tests.ConsoleLogsTests; diff --git a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/UrlParserTests.cs b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/UrlParserTests.cs index d164600de4c..1f8342d7309 100644 --- a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/UrlParserTests.cs +++ b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/UrlParserTests.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; -using Aspire.Dashboard.ConsoleLogs; +using Aspire.Shared.ConsoleLogs; using Xunit; namespace Aspire.Dashboard.Tests.ConsoleLogsTests; diff --git a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs index 2e2a1819b08..438e150d0ea 100644 --- a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs @@ -3,6 +3,7 @@ using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model.Assistant; +using Aspire.Shared.ConsoleLogs; using Xunit; namespace Aspire.Dashboard.Tests.Model.AIAssistant; @@ -82,15 +83,15 @@ public void TryGetSingleResult_ReferenceType_MultipleMatches_ReturnsFalse() [Fact] public void LimitLength_UnderLimit_ReturnFullValue() { - var value = AIHelpers.LimitLength("How now brown cow?"); + var value = SharedAIHelpers.LimitLength("How now brown cow?"); Assert.Equal("How now brown cow?", value); } [Fact] public void LimitLength_OverLimit_ReturnTrimmedValue() { - var value = AIHelpers.LimitLength(new string('!', 10_000)); - Assert.Equal($"{new string('!', AIHelpers.MaximumStringLength)}...[TRUNCATED]", value); + var value = SharedAIHelpers.LimitLength(new string('!', 10_000)); + Assert.Equal($"{new string('!', SharedAIHelpers.MaximumStringLength)}...[TRUNCATED]", value); } [Fact] @@ -104,7 +105,7 @@ public void GetLimitFromEndWithSummary_UnderLimits_ReturnAll() } // Act - var (items, message) = AIHelpers.GetLimitFromEndWithSummary(values, totalValues: values.Count, limit: 20, "test item", s => s, s => ((string)s).Length); + var (items, message) = SharedAIHelpers.GetLimitFromEndWithSummary(values, totalValues: values.Count, limit: 20, "test item", "test items", s => s, s => ((string)s).Length); // Assert Assert.Equal(10, items.Count); @@ -122,7 +123,7 @@ public void GetLimitFromEndWithSummary_UnderTotal_ReturnPassedIn() } // Act - var (items, message) = AIHelpers.GetLimitFromEndWithSummary(values, totalValues: 100, limit: 20, "test item", s => s, s => ((string)s).Length); + var (items, message) = SharedAIHelpers.GetLimitFromEndWithSummary(values, totalValues: 100, limit: 20, "test item", "test items", s => s, s => ((string)s).Length); // Assert Assert.Equal(10, items.Count); @@ -140,7 +141,7 @@ public void GetLimitFromEndWithSummary_ExceedCountLimit_ReturnMostRecentItems() } // Act - var (items, message) = AIHelpers.GetLimitFromEndWithSummary(values, totalValues: 100, limit: 5, "test item", s => s, s => ((string)s).Length); + var (items, message) = SharedAIHelpers.GetLimitFromEndWithSummary(values, totalValues: 100, limit: 5, "test item", "test items", s => s, s => ((string)s).Length); // Assert Assert.Collection(items, @@ -165,7 +166,7 @@ public void GetLimitFromEndWithSummary_ExceedTokenLimit_ReturnMostRecentItems() } // Act - var (items, message) = AIHelpers.GetLimitFromEndWithSummary(values, limit: 10, "test item", s => s, s => ((string)s).Length); + var (items, message) = SharedAIHelpers.GetLimitFromEndWithSummary(values, limit: 10, "test item", "test items", s => s, s => ((string)s).Length); // Assert Assert.Collection(items, diff --git a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs index 219e9688ac8..2220e964a75 100644 --- a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs @@ -9,6 +9,7 @@ using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; using Aspire.Dashboard.Tests.Shared; +using Aspire.Shared.ConsoleLogs; using Aspire.Tests.Shared.DashboardModel; using Google.Protobuf.Collections; using OpenTelemetry.Proto.Logs.V1; @@ -119,7 +120,7 @@ public async Task GetConsoleLogs_ExceedTokenLimit_ReturnMostRecentItems() // Assert for (var i = 5; i < 20; i++) { - var line = AIHelpers.LimitLength(new string((char)('a' + i), 10_000)); + var line = SharedAIHelpers.LimitLength(new string((char)('a' + i), 10_000)); Assert.Contains(line, result); } Assert.Contains("Returned latest 15 console logs. Earlier 5 console logs not returned because of size limits.", result); diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs index 459128d2415..9f28ed4d74d 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs @@ -5,11 +5,11 @@ using System.Globalization; using System.Text.Json; using System.Threading.Channels; -using Aspire.Hosting.ConsoleLogs; using Aspire.Hosting.Dashboard; using Aspire.Hosting.Dcp; using Aspire.Hosting.Devcontainers.Codespaces; using Aspire.Hosting.Tests.Utils; +using Aspire.Shared.ConsoleLogs; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs index 6f27e61cc3d..dcb3c04b855 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardServiceTests.cs @@ -3,12 +3,12 @@ using System.Threading.Channels; using Aspire.DashboardService.Proto.V1; -using Aspire.Hosting.ConsoleLogs; using Aspire.Hosting.Dashboard; using Aspire.Hosting.Tests.Helpers; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Tests.Utils.Grpc; using Aspire.Hosting.Utils; +using Aspire.Shared.ConsoleLogs; using Google.Protobuf.WellKnownTypes; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; diff --git a/tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs b/tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs index a800d94299b..04e3acfb684 100644 --- a/tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceLoggerServiceTests.cs @@ -2,8 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Threading.Channels; -using Aspire.Hosting.ConsoleLogs; using Aspire.Hosting.Tests.Utils; +using Aspire.Shared.ConsoleLogs; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Logging; diff --git a/tests/Aspire.Hosting.Tests/Utils/TestConsoleLogsService.cs b/tests/Aspire.Hosting.Tests/Utils/TestConsoleLogsService.cs index 6f6cb66245c..c382314b697 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestConsoleLogsService.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestConsoleLogsService.cs @@ -3,7 +3,7 @@ using System.Runtime.CompilerServices; using System.Threading.Channels; -using Aspire.Hosting.ConsoleLogs; +using Aspire.Shared.ConsoleLogs; namespace Aspire.Hosting.Tests.Utils; From b7bfa7eb4ead53216d5675c1652d1bdb8f4d0b4a Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 3 Feb 2026 08:31:04 -0800 Subject: [PATCH 028/256] Add aspire docs command with list/search/get subcommands (#14315) * Add aspire docs command with list/search/get subcommands Adds CLI commands for browsing and searching Aspire documentation: - aspire docs list: List all available documentation pages - aspire docs search : Search documentation by keywords - aspire docs get : Get full content of a page Features: - Disk caching at ~/.aspire/cache/docs/ with ETag-based invalidation - Loading spinner for cold cache scenarios - Table and JSON output formats - --section filter for getting specific sections - -n/--limit option for search results Also includes unit tests for all docs commands. * Cache parsed docs index to disk for faster startup - Add GetIndexAsync/SetIndexAsync to IDocsCache for index caching - Serialize LlmsDocument[] to ~/.aspire/cache/docs/index.json - DocsIndexService now loads from cached index when available - Cold cache: ~1.4s (fetch + parse + save index) - Warm cache: ~0.8s (load index from disk, skip parsing) - Add [JsonSerializable] for LlmsDocument[] to support AOT * Use source-generated JSON instead of manual StringBuilder * Invalidate cached index when ETag is missing If etag.txt is deleted but index.json remains, treat the index as stale and refetch from the server. This ensures cache consistency. * Address PR review comments - Remove redundant null checks in DocsCommand constructor - Use base class InteractionService property instead of private fields - Use '-' instead of empty string for missing summary/section in tables - Add comment explaining why volatile is needed for double-checked locking - Add comment explaining F2 score formatting (two decimal places) - Use Path.GetInvalidFileNameChars() for safer filename sanitization - Add static helper methods in tests to avoid updating every test when ctor changes * Format markdown output for better terminal readability - Add newlines before markdown headings (##, ###) - Add newlines around code blocks (```) - Makes 'aspire docs get' output much more readable in terminal --- src/Aspire.Cli/Commands/DocsCommand.cs | 42 +++ src/Aspire.Cli/Commands/DocsGetCommand.cs | 127 +++++++ src/Aspire.Cli/Commands/DocsListCommand.cs | 95 ++++++ src/Aspire.Cli/Commands/DocsSearchCommand.cs | 112 ++++++ src/Aspire.Cli/Commands/RootCommand.cs | 2 + src/Aspire.Cli/JsonSourceGenerationContext.cs | 6 + src/Aspire.Cli/Mcp/Docs/DocsCache.cs | 321 ++++++++++++++++-- src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs | 22 +- src/Aspire.Cli/Mcp/Docs/IDocsCache.cs | 14 + src/Aspire.Cli/Program.cs | 4 + .../Resources/DocsCommandStrings.Designer.cs | 198 +++++++++++ .../Resources/DocsCommandStrings.resx | 165 +++++++++ .../Resources/xlf/DocsCommandStrings.cs.xlf | 82 +++++ .../Resources/xlf/DocsCommandStrings.de.xlf | 82 +++++ .../Resources/xlf/DocsCommandStrings.es.xlf | 82 +++++ .../Resources/xlf/DocsCommandStrings.fr.xlf | 82 +++++ .../Resources/xlf/DocsCommandStrings.it.xlf | 82 +++++ .../Resources/xlf/DocsCommandStrings.ja.xlf | 82 +++++ .../Resources/xlf/DocsCommandStrings.ko.xlf | 82 +++++ .../Resources/xlf/DocsCommandStrings.pl.xlf | 82 +++++ .../xlf/DocsCommandStrings.pt-BR.xlf | 82 +++++ .../Resources/xlf/DocsCommandStrings.ru.xlf | 82 +++++ .../Resources/xlf/DocsCommandStrings.tr.xlf | 82 +++++ .../xlf/DocsCommandStrings.zh-Hans.xlf | 82 +++++ .../xlf/DocsCommandStrings.zh-Hant.xlf | 82 +++++ .../Commands/DocsCommandTests.cs | 233 +++++++++++++ .../Mcp/Docs/DocsFetcherTests.cs | 12 + .../Mcp/Docs/DocsIndexServiceTests.cs | 113 +++--- .../Mcp/Docs/DocsSearchServiceTests.cs | 96 ++++-- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 16 +- 30 files changed, 2535 insertions(+), 109 deletions(-) create mode 100644 src/Aspire.Cli/Commands/DocsCommand.cs create mode 100644 src/Aspire.Cli/Commands/DocsGetCommand.cs create mode 100644 src/Aspire.Cli/Commands/DocsListCommand.cs create mode 100644 src/Aspire.Cli/Commands/DocsSearchCommand.cs create mode 100644 src/Aspire.Cli/Resources/DocsCommandStrings.Designer.cs create mode 100644 src/Aspire.Cli/Resources/DocsCommandStrings.resx create mode 100644 src/Aspire.Cli/Resources/xlf/DocsCommandStrings.cs.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DocsCommandStrings.de.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DocsCommandStrings.es.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DocsCommandStrings.fr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DocsCommandStrings.it.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ja.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ko.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pl.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pt-BR.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ru.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DocsCommandStrings.tr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hans.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hant.xlf create mode 100644 tests/Aspire.Cli.Tests/Commands/DocsCommandTests.cs diff --git a/src/Aspire.Cli/Commands/DocsCommand.cs b/src/Aspire.Cli/Commands/DocsCommand.cs new file mode 100644 index 00000000000..c79d1eaefc5 --- /dev/null +++ b/src/Aspire.Cli/Commands/DocsCommand.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Help; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Commands; + +/// +/// Parent command for documentation operations. Contains subcommands for listing, searching, and getting docs. +/// +internal sealed class DocsCommand : BaseCommand +{ + public DocsCommand( + DocsListCommand listCommand, + DocsSearchCommand searchCommand, + DocsGetCommand getCommand, + IInteractionService interactionService, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + AspireCliTelemetry telemetry) + : base("docs", DocsCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) + { + Subcommands.Add(listCommand); + Subcommands.Add(searchCommand); + Subcommands.Add(getCommand); + } + + protected override bool UpdateNotificationsEnabled => false; + + protected override Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + new HelpAction().Invoke(parseResult); + return Task.FromResult(ExitCodeConstants.InvalidCommand); + } +} diff --git a/src/Aspire.Cli/Commands/DocsGetCommand.cs b/src/Aspire.Cli/Commands/DocsGetCommand.cs new file mode 100644 index 00000000000..9924547c7ce --- /dev/null +++ b/src/Aspire.Cli/Commands/DocsGetCommand.cs @@ -0,0 +1,127 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Globalization; +using System.Text.Json; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Mcp.Docs; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Commands; + +/// +/// Command to get the full content of a documentation page by its slug. +/// +internal sealed partial class DocsGetCommand : BaseCommand +{ + private readonly IDocsIndexService _docsIndexService; + private readonly ILogger _logger; + + private static readonly Argument s_slugArgument = new("slug") + { + Description = DocsCommandStrings.SlugArgumentDescription + }; + + private static readonly Option s_sectionOption = new("--section") + { + Description = DocsCommandStrings.SectionOptionDescription + }; + + private static readonly Option s_formatOption = new("--format") + { + Description = DocsCommandStrings.FormatOptionDescription + }; + + public DocsGetCommand( + IInteractionService interactionService, + IDocsIndexService docsIndexService, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + AspireCliTelemetry telemetry, + ILogger logger) + : base("get", DocsCommandStrings.GetDescription, features, updateNotifier, executionContext, interactionService, telemetry) + { + _docsIndexService = docsIndexService; + _logger = logger; + + Arguments.Add(s_slugArgument); + Options.Add(s_sectionOption); + Options.Add(s_formatOption); + } + + protected override bool UpdateNotificationsEnabled => false; + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + using var activity = Telemetry.StartDiagnosticActivity(Name); + + var slug = parseResult.GetValue(s_slugArgument)!; + var section = parseResult.GetValue(s_sectionOption); + var format = parseResult.GetValue(s_formatOption); + + _logger.LogDebug("Getting documentation for slug '{Slug}' (section: {Section})", slug, section ?? "(all)"); + + // Get doc with status indicator + var doc = await InteractionService.ShowStatusAsync( + DocsCommandStrings.LoadingDocumentation, + async () => await _docsIndexService.GetDocumentAsync(slug, section, cancellationToken)); + + if (doc is null) + { + InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, DocsCommandStrings.DocumentNotFound, slug)); + return ExitCodeConstants.InvalidCommand; + } + + if (format is OutputFormat.Json) + { + var json = JsonSerializer.Serialize(doc, JsonSourceGenerationContext.RelaxedEscaping.DocsContent); + InteractionService.DisplayRawText(json); + } + else + { + // Format the markdown for better terminal readability + var formatted = FormatMarkdownForTerminal(doc.Content); + InteractionService.DisplayRawText(formatted); + } + + return ExitCodeConstants.Success; + } + + /// + /// Formats minified markdown content for better terminal readability by inserting line breaks. + /// + private static string FormatMarkdownForTerminal(string content) + { + // The llms.txt format has markdown on single lines - insert breaks for readability + // Add newline before headings (##, ###) + content = HeadingRegex().Replace(content, "\n\n$0"); + + // Add newlines around code blocks + content = CodeBlockStartRegex().Replace(content, "\n$0\n"); + content = CodeBlockEndRegex().Replace(content, "\n$0\n"); + + // Clean up excessive newlines + content = ExcessiveNewlinesRegex().Replace(content, "\n\n"); + + return content.Trim(); + } + + // Match markdown headings: ## or ### at start or after space (not C#) + [System.Text.RegularExpressions.GeneratedRegex(@"(?<=\s)(#{2,6}\s)")] + private static partial System.Text.RegularExpressions.Regex HeadingRegex(); + + [System.Text.RegularExpressions.GeneratedRegex(@"(? +/// Command to list all available Aspire documentation pages. +/// +internal sealed class DocsListCommand : BaseCommand +{ + private readonly IDocsIndexService _docsIndexService; + private readonly ILogger _logger; + + private static readonly Option s_formatOption = new("--format") + { + Description = DocsCommandStrings.FormatOptionDescription + }; + + public DocsListCommand( + IInteractionService interactionService, + IDocsIndexService docsIndexService, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + AspireCliTelemetry telemetry, + ILogger logger) + : base("list", DocsCommandStrings.ListDescription, features, updateNotifier, executionContext, interactionService, telemetry) + { + _docsIndexService = docsIndexService; + _logger = logger; + + Options.Add(s_formatOption); + } + + protected override bool UpdateNotificationsEnabled => false; + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + using var activity = Telemetry.StartDiagnosticActivity(Name); + + var format = parseResult.GetValue(s_formatOption); + + _logger.LogDebug("Listing documentation pages"); + + // Load docs with status indicator (only shows spinner if network fetch is needed) + var docs = await InteractionService.ShowStatusAsync( + DocsCommandStrings.LoadingDocumentation, + async () => await _docsIndexService.ListDocumentsAsync(cancellationToken)); + + if (docs.Count is 0) + { + InteractionService.DisplayError(DocsCommandStrings.NoDocumentationAvailable); + return ExitCodeConstants.InvalidCommand; + } + + if (format is OutputFormat.Json) + { + var json = JsonSerializer.Serialize(docs.ToArray(), JsonSourceGenerationContext.RelaxedEscaping.DocsListItemArray); + InteractionService.DisplayRawText(json); + } + else + { + InteractionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, DocsCommandStrings.FoundDocumentationPages, docs.Count)); + + var table = new Table(); + table.AddColumn("Title"); + table.AddColumn("Slug"); + table.AddColumn("Summary"); + + foreach (var doc in docs) + { + table.AddRow( + Markup.Escape(doc.Title), + Markup.Escape(doc.Slug), + Markup.Escape(doc.Summary ?? "-")); + } + + AnsiConsole.Write(table); + } + + return ExitCodeConstants.Success; + } +} diff --git a/src/Aspire.Cli/Commands/DocsSearchCommand.cs b/src/Aspire.Cli/Commands/DocsSearchCommand.cs new file mode 100644 index 00000000000..338fbd30476 --- /dev/null +++ b/src/Aspire.Cli/Commands/DocsSearchCommand.cs @@ -0,0 +1,112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Globalization; +using System.Text.Json; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Mcp.Docs; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace Aspire.Cli.Commands; + +/// +/// Command to search Aspire documentation by keywords. +/// +internal sealed class DocsSearchCommand : BaseCommand +{ + private readonly IDocsSearchService _docsSearchService; + private readonly ILogger _logger; + + private static readonly Argument s_queryArgument = new("query") + { + Description = DocsCommandStrings.QueryArgumentDescription + }; + + private static readonly Option s_formatOption = new("--format") + { + Description = DocsCommandStrings.FormatOptionDescription + }; + + private static readonly Option s_limitOption = new("--limit", "-n") + { + Description = DocsCommandStrings.LimitOptionDescription + }; + + public DocsSearchCommand( + IInteractionService interactionService, + IDocsSearchService docsSearchService, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + AspireCliTelemetry telemetry, + ILogger logger) + : base("search", DocsCommandStrings.SearchDescription, features, updateNotifier, executionContext, interactionService, telemetry) + { + _docsSearchService = docsSearchService; + _logger = logger; + + Arguments.Add(s_queryArgument); + Options.Add(s_formatOption); + Options.Add(s_limitOption); + } + + protected override bool UpdateNotificationsEnabled => false; + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + using var activity = Telemetry.StartDiagnosticActivity(Name); + + var query = parseResult.GetValue(s_queryArgument)!; + var format = parseResult.GetValue(s_formatOption); + var limit = Math.Clamp(parseResult.GetValue(s_limitOption) ?? 5, 1, 10); + + _logger.LogDebug("Searching documentation for '{Query}' (limit: {Limit})", query, limit); + + // Search docs with status indicator + var response = await InteractionService.ShowStatusAsync( + DocsCommandStrings.LoadingDocumentation, + async () => await _docsSearchService.SearchAsync(query, limit, cancellationToken)); + + if (response is null || response.Results.Count is 0) + { + InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, DocsCommandStrings.NoResultsFound, query)); + return ExitCodeConstants.Success; // Not an error, just no results + } + + if (format is OutputFormat.Json) + { + var json = JsonSerializer.Serialize(response.Results.ToArray(), JsonSourceGenerationContext.RelaxedEscaping.SearchResultArray); + InteractionService.DisplayRawText(json); + } + else + { + InteractionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, DocsCommandStrings.FoundSearchResults, response.Results.Count, query)); + + // Results are already sorted by score (highest first) from the search service + var table = new Table(); + table.AddColumn("Title"); + table.AddColumn("Slug"); + table.AddColumn("Section"); + table.AddColumn("Score"); + + foreach (var result in response.Results) + { + table.AddRow( + Markup.Escape(result.Title), + Markup.Escape(result.Slug), + Markup.Escape(result.Section ?? "-"), + result.Score.ToString("F2", CultureInfo.InvariantCulture)); // Two decimal places + } + + AnsiConsole.Write(table); + } + + return ExitCodeConstants.Success; + } +} diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 5699c62d2d6..4786e3f67ed 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -82,6 +82,7 @@ public RootCommand( McpCommand mcpCommand, AgentCommand agentCommand, TelemetryCommand telemetryCommand, + DocsCommand docsCommand, SdkCommand sdkCommand, ExtensionInternalCommand extensionInternalCommand, IFeatures featureFlags, @@ -151,6 +152,7 @@ public RootCommand( Subcommands.Add(mcpCommand); Subcommands.Add(agentCommand); Subcommands.Add(telemetryCommand); + Subcommands.Add(docsCommand); if (featureFlags.IsFeatureEnabled(KnownFeatures.ExecCommandEnabled, false)) { diff --git a/src/Aspire.Cli/JsonSourceGenerationContext.cs b/src/Aspire.Cli/JsonSourceGenerationContext.cs index 7b42bdde4ac..ced45b42cb0 100644 --- a/src/Aspire.Cli/JsonSourceGenerationContext.cs +++ b/src/Aspire.Cli/JsonSourceGenerationContext.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Certificates; using Aspire.Cli.Commands; using Aspire.Cli.Configuration; +using Aspire.Cli.Mcp.Docs; using Aspire.Cli.Mcp.Tools; using Aspire.Cli.Utils.EnvironmentChecker; @@ -28,6 +29,11 @@ namespace Aspire.Cli; [JsonSerializable(typeof(FeatureInfo))] [JsonSerializable(typeof(SettingsSchema))] [JsonSerializable(typeof(PropertyInfo))] +[JsonSerializable(typeof(LlmsDocument[]))] +[JsonSerializable(typeof(LlmsSection))] +[JsonSerializable(typeof(DocsListItem[]))] +[JsonSerializable(typeof(SearchResult[]))] +[JsonSerializable(typeof(DocsContent))] internal partial class JsonSourceGenerationContext : JsonSerializerContext { private static JsonSourceGenerationContext? s_relaxedEscaping; diff --git a/src/Aspire.Cli/Mcp/Docs/DocsCache.cs b/src/Aspire.Cli/Mcp/Docs/DocsCache.cs index 09fc3b706a2..f05f9ce2c2b 100644 --- a/src/Aspire.Cli/Mcp/Docs/DocsCache.cs +++ b/src/Aspire.Cli/Mcp/Docs/DocsCache.cs @@ -1,69 +1,104 @@ // 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.Caching.Memory; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Mcp.Docs; /// -/// In-memory cache for aspire.dev documentation content with ETag support. +/// Cache for aspire.dev documentation content with ETag support. +/// Uses both in-memory cache for fast access and disk cache for persistence across CLI invocations. /// -internal sealed class DocsCache(IMemoryCache memoryCache, ILogger logger) : IDocsCache +internal sealed class DocsCache : IDocsCache { - private readonly IMemoryCache _memoryCache = memoryCache; - private readonly ILogger _logger = logger; + private const string DocsCacheSubdirectory = "docs"; + private const string ETagFileName = "etag.txt"; + private const string IndexFileName = "index.json"; + private const string IndexCacheKey = "docs:index"; - public Task GetAsync(string key, CancellationToken cancellationToken = default) + private readonly IMemoryCache _memoryCache; + private readonly ILogger _logger; + private readonly DirectoryInfo _diskCacheDirectory; + + public DocsCache(IMemoryCache memoryCache, CliExecutionContext executionContext, ILogger logger) + { + _memoryCache = memoryCache; + _logger = logger; + _diskCacheDirectory = new DirectoryInfo(Path.Combine(executionContext.CacheDirectory.FullName, DocsCacheSubdirectory)); + } + + public async Task GetAsync(string key, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var cacheKey = GetCacheKey(key); + // Check memory cache first if (_memoryCache.TryGetValue(cacheKey, out string? content)) { - _logger.LogDebug("DocsCache hit for key: {Key}", key); + _logger.LogDebug("DocsCache memory hit for key: {Key}", key); + return content; + } - return Task.FromResult(content); + // Check disk cache + var diskContent = await GetFromDiskAsync(key, cancellationToken).ConfigureAwait(false); + if (diskContent is not null) + { + // Populate memory cache from disk + _memoryCache.Set(cacheKey, diskContent); + _logger.LogDebug("DocsCache disk hit for key: {Key}", key); + return diskContent; } _logger.LogDebug("DocsCache miss for key: {Key}", key); - - return Task.FromResult(null); + return null; } - public Task SetAsync(string key, string content, CancellationToken cancellationToken = default) + public async Task SetAsync(string key, string content, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var cacheKey = GetCacheKey(key); - // No expiration - content is invalidated when ETag changes + // Set in memory cache _memoryCache.Set(cacheKey, content); - _logger.LogDebug("DocsCache set for key: {Key}", key); - return Task.CompletedTask; + // Persist to disk + await SaveToDiskAsync(key, content, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug("DocsCache set for key: {Key}", key); } - public Task GetETagAsync(string url, CancellationToken cancellationToken = default) + public async Task GetETagAsync(string url, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var cacheKey = GetETagCacheKey(url); + // Check memory cache first if (_memoryCache.TryGetValue(cacheKey, out string? etag)) { - _logger.LogDebug("DocsCache ETag hit for url: {Url}", url); + _logger.LogDebug("DocsCache ETag memory hit for url: {Url}", url); + return etag; + } - return Task.FromResult(etag); + // Check disk cache + var diskETag = await GetETagFromDiskAsync(cancellationToken).ConfigureAwait(false); + if (diskETag is not null) + { + // Populate memory cache from disk + _memoryCache.Set(cacheKey, diskETag); + _logger.LogDebug("DocsCache ETag disk hit for url: {Url}", url); + return diskETag; } _logger.LogDebug("DocsCache ETag miss for url: {Url}", url); - - return Task.FromResult(null); + return null; } - public Task SetETagAsync(string url, string? etag, CancellationToken cancellationToken = default) + public async Task SetETagAsync(string url, string? etag, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -72,16 +107,53 @@ public Task SetETagAsync(string url, string? etag, CancellationToken cancellatio if (etag is null) { _memoryCache.Remove(cacheKey); + await DeleteETagFromDiskAsync(cancellationToken).ConfigureAwait(false); _logger.LogDebug("DocsCache cleared ETag for url: {Url}", url); } else { - // No expiration - ETag is used to validate content freshness _memoryCache.Set(cacheKey, etag); + await SaveETagToDiskAsync(etag, cancellationToken).ConfigureAwait(false); _logger.LogDebug("DocsCache set ETag for url: {Url}, ETag: {ETag}", url, etag); } + } + + public async Task GetIndexAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Check memory cache first + if (_memoryCache.TryGetValue(IndexCacheKey, out LlmsDocument[]? documents)) + { + _logger.LogDebug("DocsCache index memory hit"); + return documents; + } - return Task.CompletedTask; + // Check disk cache + var diskDocuments = await GetIndexFromDiskAsync(cancellationToken).ConfigureAwait(false); + if (diskDocuments is not null) + { + // Populate memory cache from disk + _memoryCache.Set(IndexCacheKey, diskDocuments); + _logger.LogDebug("DocsCache index disk hit, loaded {Count} documents", diskDocuments.Length); + return diskDocuments; + } + + _logger.LogDebug("DocsCache index miss"); + return null; + } + + public async Task SetIndexAsync(LlmsDocument[] documents, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Set in memory cache + _memoryCache.Set(IndexCacheKey, documents); + + // Persist to disk + await SaveIndexToDiskAsync(documents, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug("DocsCache set index with {Count} documents", documents.Length); } public Task InvalidateAsync(string key, CancellationToken cancellationToken = default) @@ -90,12 +162,217 @@ public Task InvalidateAsync(string key, CancellationToken cancellationToken = de var cacheKey = GetCacheKey(key); _memoryCache.Remove(cacheKey); - _logger.LogDebug("DocsCache invalidated key: {Key}", key); + // Also invalidate disk cache + try + { + var contentFile = GetContentFilePath(key); + if (File.Exists(contentFile)) + { + File.Delete(contentFile); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to delete disk cache for key: {Key}", key); + } + + _logger.LogDebug("DocsCache invalidated key: {Key}", key); return Task.CompletedTask; } private static string GetCacheKey(string key) => $"docs:{key}"; private static string GetETagCacheKey(string url) => $"docs:etag:{url}"; + + private string GetContentFilePath(string key) + { + // Use a simple sanitized filename for the key + var safeKey = SanitizeFileName(key); + return Path.Combine(_diskCacheDirectory.FullName, $"{safeKey}.txt"); + } + + private string GetETagFilePath() => Path.Combine(_diskCacheDirectory.FullName, ETagFileName); + + private static readonly char[] s_invalidFileNameChars = Path.GetInvalidFileNameChars(); + + private static string SanitizeFileName(string key) + { + // Replace invalid filename characters with underscore + var result = new char[key.Length]; + for (var i = 0; i < key.Length; i++) + { + var c = key[i]; + result[i] = Array.IndexOf(s_invalidFileNameChars, c) >= 0 ? '_' : c; + } + return new string(result); + } + + private async Task GetFromDiskAsync(string key, CancellationToken cancellationToken) + { + try + { + var filePath = GetContentFilePath(key); + if (File.Exists(filePath)) + { + return await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to read disk cache for key: {Key}", key); + } + + return null; + } + + private async Task SaveToDiskAsync(string key, string content, CancellationToken cancellationToken) + { + try + { + EnsureCacheDirectoryExists(); + + var filePath = GetContentFilePath(key); + var tempPath = filePath + ".tmp"; + + await File.WriteAllTextAsync(tempPath, content, cancellationToken).ConfigureAwait(false); + + // Atomic move (overwrite: true uses atomic rename on supported platforms) + File.Move(tempPath, filePath, overwrite: true); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to save disk cache for key: {Key}", key); + } + } + + private async Task GetETagFromDiskAsync(CancellationToken cancellationToken) + { + try + { + var filePath = GetETagFilePath(); + if (File.Exists(filePath)) + { + return (await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false)).Trim(); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to read ETag from disk"); + } + + return null; + } + + private async Task SaveETagToDiskAsync(string etag, CancellationToken cancellationToken) + { + try + { + EnsureCacheDirectoryExists(); + + var filePath = GetETagFilePath(); + await File.WriteAllTextAsync(filePath, etag, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to save ETag to disk"); + } + } + + private Task DeleteETagFromDiskAsync(CancellationToken cancellationToken) + { + _ = cancellationToken; + + try + { + var filePath = GetETagFilePath(); + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to delete ETag from disk"); + } + + return Task.CompletedTask; + } + + private void EnsureCacheDirectoryExists() + { + if (!_diskCacheDirectory.Exists) + { + try + { + _diskCacheDirectory.Create(); + _diskCacheDirectory.Refresh(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to create docs cache directory: {Directory}", _diskCacheDirectory.FullName); + } + } + } + + private string GetIndexFilePath() => Path.Combine(_diskCacheDirectory.FullName, IndexFileName); + + private async Task GetIndexFromDiskAsync(CancellationToken cancellationToken) + { + try + { + var filePath = GetIndexFilePath(); + var etagFilePath = GetETagFilePath(); + + // Only return cached index if ETag also exists (ensures consistency) + if (File.Exists(filePath) && File.Exists(etagFilePath)) + { + await using var stream = File.OpenRead(filePath); + return await JsonSerializer.DeserializeAsync(stream, JsonSourceGenerationContext.Default.LlmsDocumentArray, cancellationToken).ConfigureAwait(false); + } + + // If index exists but ETag is missing, delete the stale index + if (File.Exists(filePath) && !File.Exists(etagFilePath)) + { + _logger.LogDebug("Deleting stale index (ETag missing)"); + try + { + File.Delete(filePath); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to delete stale index"); + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to read index from disk"); + } + + return null; + } + + private async Task SaveIndexToDiskAsync(LlmsDocument[] documents, CancellationToken cancellationToken) + { + try + { + EnsureCacheDirectoryExists(); + + var filePath = GetIndexFilePath(); + var tempPath = filePath + ".tmp"; + + await using (var stream = File.Create(tempPath)) + { + await JsonSerializer.SerializeAsync(stream, documents, JsonSourceGenerationContext.Default.LlmsDocumentArray, cancellationToken).ConfigureAwait(false); + } + + // Atomic move + File.Move(tempPath, filePath, overwrite: true); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to save index to disk"); + } + } } diff --git a/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs b/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs index c5eb455b26e..c4a3218c879 100644 --- a/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs +++ b/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs @@ -87,7 +87,7 @@ internal sealed class DocsContent /// - Section-oriented ("configuration", "examples") /// - Name-exact ("Redis resource", "AddServiceDefaults") /// -internal sealed partial class DocsIndexService(IDocsFetcher docsFetcher, ILogger logger) : IDocsIndexService +internal sealed partial class DocsIndexService(IDocsFetcher docsFetcher, IDocsCache docsCache, ILogger logger) : IDocsIndexService { // Field weights for relevance scoring private const float TitleWeight = 10.0f; // H1 (page title) @@ -113,9 +113,12 @@ internal sealed partial class DocsIndexService(IDocsFetcher docsFetcher, ILogger private const float WhatsNewPenaltyMultiplier = 0.3f; // Apply 0.3x to whats-new pages private readonly IDocsFetcher _docsFetcher = docsFetcher; + private readonly IDocsCache _docsCache = docsCache; private readonly ILogger _logger = logger; - private List? _indexedDocuments; + // Volatile ensures the double-checked locking pattern works correctly by preventing + // instruction reordering that could expose a partially-constructed list to other threads. + private volatile List? _indexedDocuments; private readonly SemaphoreSlim _indexLock = new(1, 1); public async ValueTask EnsureIndexedAsync(CancellationToken cancellationToken = default) @@ -138,6 +141,18 @@ public async ValueTask EnsureIndexedAsync(CancellationToken cancellationToken = _logger.LogDebug("Loading aspire.dev documentation"); + // Try to load from disk cache first + var cachedDocuments = await _docsCache.GetIndexAsync(cancellationToken).ConfigureAwait(false); + if (cachedDocuments is not null) + { + _indexedDocuments = [.. cachedDocuments.Select(static d => new IndexedDocument(d))]; + + var cacheElapsedTime = Stopwatch.GetElapsedTime(startTimestamp); + _logger.LogInformation("Loaded {Count} documents from cache in {ElapsedTime:ss\\.fff} seconds.", _indexedDocuments.Count, cacheElapsedTime); + return; + } + + // Fetch and parse from network var content = await _docsFetcher.FetchDocsAsync(cancellationToken).ConfigureAwait(false); if (content is null) { @@ -151,6 +166,9 @@ public async ValueTask EnsureIndexedAsync(CancellationToken cancellationToken = // Pre-compute lowercase versions for faster searching _indexedDocuments = [.. documents.Select(static d => new IndexedDocument(d))]; + // Cache the parsed documents for next time + await _docsCache.SetIndexAsync([.. documents], cancellationToken).ConfigureAwait(false); + var elapsedTime = Stopwatch.GetElapsedTime(startTimestamp); _logger.LogInformation("Indexed {Count} documents from aspire.dev in {ElapsedTime:ss\\.fff} seconds.", _indexedDocuments.Count, elapsedTime); diff --git a/src/Aspire.Cli/Mcp/Docs/IDocsCache.cs b/src/Aspire.Cli/Mcp/Docs/IDocsCache.cs index e876c7f13a3..38f6702f954 100644 --- a/src/Aspire.Cli/Mcp/Docs/IDocsCache.cs +++ b/src/Aspire.Cli/Mcp/Docs/IDocsCache.cs @@ -40,6 +40,20 @@ internal interface IDocsCache /// The cancellation token. Task SetETagAsync(string url, string? etag, CancellationToken cancellationToken = default); + /// + /// Gets the cached parsed document index. + /// + /// The cancellation token. + /// The cached documents, or null if not found. + Task GetIndexAsync(CancellationToken cancellationToken = default); + + /// + /// Sets the parsed document index in the cache. + /// + /// The parsed documents to cache. + /// The cancellation token. + Task SetIndexAsync(LlmsDocument[] documents, CancellationToken cancellationToken = default); + /// /// Invalidates the cache for a key. /// diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 76640bd299b..65429a6f1b3 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -274,6 +274,10 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Resources/DocsCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DocsCommandStrings.Designer.cs new file mode 100644 index 00000000000..2e6b6e0794c --- /dev/null +++ b/src/Aspire.Cli/Resources/DocsCommandStrings.Designer.cs @@ -0,0 +1,198 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Aspire.Cli.Resources { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class DocsCommandStrings { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal DocsCommandStrings() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Aspire.Cli.Resources.DocsCommandStrings", typeof(DocsCommandStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Browse and search Aspire documentation from aspire.dev.. + /// + internal static string Description { + get { + return ResourceManager.GetString("Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to List all available Aspire documentation pages.. + /// + internal static string ListDescription { + get { + return ResourceManager.GetString("ListDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Search Aspire documentation by keywords.. + /// + internal static string SearchDescription { + get { + return ResourceManager.GetString("SearchDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Get the full content of a documentation page by its slug.. + /// + internal static string GetDescription { + get { + return ResourceManager.GetString("GetDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The search query.. + /// + internal static string QueryArgumentDescription { + get { + return ResourceManager.GetString("QueryArgumentDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The slug of the documentation page (e.g., 'redis-integration').. + /// + internal static string SlugArgumentDescription { + get { + return ResourceManager.GetString("SlugArgumentDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Output format (Table or Json).. + /// + internal static string FormatOptionDescription { + get { + return ResourceManager.GetString("FormatOptionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Maximum number of search results to return (default: 5, max: 10).. + /// + internal static string LimitOptionDescription { + get { + return ResourceManager.GetString("LimitOptionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Return only the specified section of the page.. + /// + internal static string SectionOptionDescription { + get { + return ResourceManager.GetString("SectionOptionDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Loading documentation.... + /// + internal static string LoadingDocumentation { + get { + return ResourceManager.GetString("LoadingDocumentation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No documentation available. The aspire.dev docs may not have loaded correctly.. + /// + internal static string NoDocumentationAvailable { + get { + return ResourceManager.GetString("NoDocumentationAvailable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No results found for '{0}'. Try different search terms.. + /// + internal static string NoResultsFound { + get { + return ResourceManager.GetString("NoResultsFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Documentation page '{0}' not found. Use 'aspire docs list' to see available pages.. + /// + internal static string DocumentNotFound { + get { + return ResourceManager.GetString("DocumentNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Found {0} documentation pages.. + /// + internal static string FoundDocumentationPages { + get { + return ResourceManager.GetString("FoundDocumentationPages", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Found {0} results for '{1}'.. + /// + internal static string FoundSearchResults { + get { + return ResourceManager.GetString("FoundSearchResults", resourceCulture); + } + } + } +} diff --git a/src/Aspire.Cli/Resources/DocsCommandStrings.resx b/src/Aspire.Cli/Resources/DocsCommandStrings.resx new file mode 100644 index 00000000000..f63c85ea9cd --- /dev/null +++ b/src/Aspire.Cli/Resources/DocsCommandStrings.resx @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Browse and search Aspire documentation from aspire.dev. + + + List all available Aspire documentation pages. + + + Search Aspire documentation by keywords. + + + Get the full content of a documentation page by its slug. + + + The search query. + + + The slug of the documentation page (e.g., 'redis-integration'). + + + Output format (Table or Json). + + + Maximum number of search results to return (default: 5, max: 10). + + + Return only the specified section of the page. + + + Loading documentation... + + + No documentation available. The aspire.dev docs may not have loaded correctly. + + + No results found for '{0}'. Try different search terms. + + + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + + + Found {0} documentation pages. + + + Found {0} results for '{1}'. + + diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.cs.xlf new file mode 100644 index 00000000000..10ef46f3340 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.cs.xlf @@ -0,0 +1,82 @@ + + + + + + Browse and search Aspire documentation from aspire.dev. + Browse and search Aspire documentation from aspire.dev. + + + + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Found {0} documentation pages. + Found {0} documentation pages. + + + + Found {0} results for '{1}'. + Found {0} results for '{1}'. + + + + Get the full content of a documentation page by its slug. + Get the full content of a documentation page by its slug. + + + + Maximum number of search results to return (default: 5, max: 10). + Maximum number of search results to return (default: 5, max: 10). + + + + List all available Aspire documentation pages. + List all available Aspire documentation pages. + + + + Loading documentation... + Loading documentation... + + + + No documentation available. The aspire.dev docs may not have loaded correctly. + No documentation available. The aspire.dev docs may not have loaded correctly. + + + + No results found for '{0}'. Try different search terms. + No results found for '{0}'. Try different search terms. + + + + The search query. + The search query. + + + + Search Aspire documentation by keywords. + Search Aspire documentation by keywords. + + + + Return only the specified section of the page. + Return only the specified section of the page. + + + + The slug of the documentation page (e.g., 'redis-integration'). + The slug of the documentation page (e.g., 'redis-integration'). + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.de.xlf new file mode 100644 index 00000000000..0b40d1219dc --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.de.xlf @@ -0,0 +1,82 @@ + + + + + + Browse and search Aspire documentation from aspire.dev. + Browse and search Aspire documentation from aspire.dev. + + + + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Found {0} documentation pages. + Found {0} documentation pages. + + + + Found {0} results for '{1}'. + Found {0} results for '{1}'. + + + + Get the full content of a documentation page by its slug. + Get the full content of a documentation page by its slug. + + + + Maximum number of search results to return (default: 5, max: 10). + Maximum number of search results to return (default: 5, max: 10). + + + + List all available Aspire documentation pages. + List all available Aspire documentation pages. + + + + Loading documentation... + Loading documentation... + + + + No documentation available. The aspire.dev docs may not have loaded correctly. + No documentation available. The aspire.dev docs may not have loaded correctly. + + + + No results found for '{0}'. Try different search terms. + No results found for '{0}'. Try different search terms. + + + + The search query. + The search query. + + + + Search Aspire documentation by keywords. + Search Aspire documentation by keywords. + + + + Return only the specified section of the page. + Return only the specified section of the page. + + + + The slug of the documentation page (e.g., 'redis-integration'). + The slug of the documentation page (e.g., 'redis-integration'). + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.es.xlf new file mode 100644 index 00000000000..36fa8a87e45 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.es.xlf @@ -0,0 +1,82 @@ + + + + + + Browse and search Aspire documentation from aspire.dev. + Browse and search Aspire documentation from aspire.dev. + + + + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Found {0} documentation pages. + Found {0} documentation pages. + + + + Found {0} results for '{1}'. + Found {0} results for '{1}'. + + + + Get the full content of a documentation page by its slug. + Get the full content of a documentation page by its slug. + + + + Maximum number of search results to return (default: 5, max: 10). + Maximum number of search results to return (default: 5, max: 10). + + + + List all available Aspire documentation pages. + List all available Aspire documentation pages. + + + + Loading documentation... + Loading documentation... + + + + No documentation available. The aspire.dev docs may not have loaded correctly. + No documentation available. The aspire.dev docs may not have loaded correctly. + + + + No results found for '{0}'. Try different search terms. + No results found for '{0}'. Try different search terms. + + + + The search query. + The search query. + + + + Search Aspire documentation by keywords. + Search Aspire documentation by keywords. + + + + Return only the specified section of the page. + Return only the specified section of the page. + + + + The slug of the documentation page (e.g., 'redis-integration'). + The slug of the documentation page (e.g., 'redis-integration'). + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.fr.xlf new file mode 100644 index 00000000000..3ed0b31a630 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.fr.xlf @@ -0,0 +1,82 @@ + + + + + + Browse and search Aspire documentation from aspire.dev. + Browse and search Aspire documentation from aspire.dev. + + + + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Found {0} documentation pages. + Found {0} documentation pages. + + + + Found {0} results for '{1}'. + Found {0} results for '{1}'. + + + + Get the full content of a documentation page by its slug. + Get the full content of a documentation page by its slug. + + + + Maximum number of search results to return (default: 5, max: 10). + Maximum number of search results to return (default: 5, max: 10). + + + + List all available Aspire documentation pages. + List all available Aspire documentation pages. + + + + Loading documentation... + Loading documentation... + + + + No documentation available. The aspire.dev docs may not have loaded correctly. + No documentation available. The aspire.dev docs may not have loaded correctly. + + + + No results found for '{0}'. Try different search terms. + No results found for '{0}'. Try different search terms. + + + + The search query. + The search query. + + + + Search Aspire documentation by keywords. + Search Aspire documentation by keywords. + + + + Return only the specified section of the page. + Return only the specified section of the page. + + + + The slug of the documentation page (e.g., 'redis-integration'). + The slug of the documentation page (e.g., 'redis-integration'). + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.it.xlf new file mode 100644 index 00000000000..1371df05b74 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.it.xlf @@ -0,0 +1,82 @@ + + + + + + Browse and search Aspire documentation from aspire.dev. + Browse and search Aspire documentation from aspire.dev. + + + + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Found {0} documentation pages. + Found {0} documentation pages. + + + + Found {0} results for '{1}'. + Found {0} results for '{1}'. + + + + Get the full content of a documentation page by its slug. + Get the full content of a documentation page by its slug. + + + + Maximum number of search results to return (default: 5, max: 10). + Maximum number of search results to return (default: 5, max: 10). + + + + List all available Aspire documentation pages. + List all available Aspire documentation pages. + + + + Loading documentation... + Loading documentation... + + + + No documentation available. The aspire.dev docs may not have loaded correctly. + No documentation available. The aspire.dev docs may not have loaded correctly. + + + + No results found for '{0}'. Try different search terms. + No results found for '{0}'. Try different search terms. + + + + The search query. + The search query. + + + + Search Aspire documentation by keywords. + Search Aspire documentation by keywords. + + + + Return only the specified section of the page. + Return only the specified section of the page. + + + + The slug of the documentation page (e.g., 'redis-integration'). + The slug of the documentation page (e.g., 'redis-integration'). + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ja.xlf new file mode 100644 index 00000000000..d8580346c21 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ja.xlf @@ -0,0 +1,82 @@ + + + + + + Browse and search Aspire documentation from aspire.dev. + Browse and search Aspire documentation from aspire.dev. + + + + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Found {0} documentation pages. + Found {0} documentation pages. + + + + Found {0} results for '{1}'. + Found {0} results for '{1}'. + + + + Get the full content of a documentation page by its slug. + Get the full content of a documentation page by its slug. + + + + Maximum number of search results to return (default: 5, max: 10). + Maximum number of search results to return (default: 5, max: 10). + + + + List all available Aspire documentation pages. + List all available Aspire documentation pages. + + + + Loading documentation... + Loading documentation... + + + + No documentation available. The aspire.dev docs may not have loaded correctly. + No documentation available. The aspire.dev docs may not have loaded correctly. + + + + No results found for '{0}'. Try different search terms. + No results found for '{0}'. Try different search terms. + + + + The search query. + The search query. + + + + Search Aspire documentation by keywords. + Search Aspire documentation by keywords. + + + + Return only the specified section of the page. + Return only the specified section of the page. + + + + The slug of the documentation page (e.g., 'redis-integration'). + The slug of the documentation page (e.g., 'redis-integration'). + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ko.xlf new file mode 100644 index 00000000000..9fb30f727db --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ko.xlf @@ -0,0 +1,82 @@ + + + + + + Browse and search Aspire documentation from aspire.dev. + Browse and search Aspire documentation from aspire.dev. + + + + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Found {0} documentation pages. + Found {0} documentation pages. + + + + Found {0} results for '{1}'. + Found {0} results for '{1}'. + + + + Get the full content of a documentation page by its slug. + Get the full content of a documentation page by its slug. + + + + Maximum number of search results to return (default: 5, max: 10). + Maximum number of search results to return (default: 5, max: 10). + + + + List all available Aspire documentation pages. + List all available Aspire documentation pages. + + + + Loading documentation... + Loading documentation... + + + + No documentation available. The aspire.dev docs may not have loaded correctly. + No documentation available. The aspire.dev docs may not have loaded correctly. + + + + No results found for '{0}'. Try different search terms. + No results found for '{0}'. Try different search terms. + + + + The search query. + The search query. + + + + Search Aspire documentation by keywords. + Search Aspire documentation by keywords. + + + + Return only the specified section of the page. + Return only the specified section of the page. + + + + The slug of the documentation page (e.g., 'redis-integration'). + The slug of the documentation page (e.g., 'redis-integration'). + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pl.xlf new file mode 100644 index 00000000000..75500400648 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pl.xlf @@ -0,0 +1,82 @@ + + + + + + Browse and search Aspire documentation from aspire.dev. + Browse and search Aspire documentation from aspire.dev. + + + + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Found {0} documentation pages. + Found {0} documentation pages. + + + + Found {0} results for '{1}'. + Found {0} results for '{1}'. + + + + Get the full content of a documentation page by its slug. + Get the full content of a documentation page by its slug. + + + + Maximum number of search results to return (default: 5, max: 10). + Maximum number of search results to return (default: 5, max: 10). + + + + List all available Aspire documentation pages. + List all available Aspire documentation pages. + + + + Loading documentation... + Loading documentation... + + + + No documentation available. The aspire.dev docs may not have loaded correctly. + No documentation available. The aspire.dev docs may not have loaded correctly. + + + + No results found for '{0}'. Try different search terms. + No results found for '{0}'. Try different search terms. + + + + The search query. + The search query. + + + + Search Aspire documentation by keywords. + Search Aspire documentation by keywords. + + + + Return only the specified section of the page. + Return only the specified section of the page. + + + + The slug of the documentation page (e.g., 'redis-integration'). + The slug of the documentation page (e.g., 'redis-integration'). + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pt-BR.xlf new file mode 100644 index 00000000000..cfad7c83523 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pt-BR.xlf @@ -0,0 +1,82 @@ + + + + + + Browse and search Aspire documentation from aspire.dev. + Browse and search Aspire documentation from aspire.dev. + + + + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Found {0} documentation pages. + Found {0} documentation pages. + + + + Found {0} results for '{1}'. + Found {0} results for '{1}'. + + + + Get the full content of a documentation page by its slug. + Get the full content of a documentation page by its slug. + + + + Maximum number of search results to return (default: 5, max: 10). + Maximum number of search results to return (default: 5, max: 10). + + + + List all available Aspire documentation pages. + List all available Aspire documentation pages. + + + + Loading documentation... + Loading documentation... + + + + No documentation available. The aspire.dev docs may not have loaded correctly. + No documentation available. The aspire.dev docs may not have loaded correctly. + + + + No results found for '{0}'. Try different search terms. + No results found for '{0}'. Try different search terms. + + + + The search query. + The search query. + + + + Search Aspire documentation by keywords. + Search Aspire documentation by keywords. + + + + Return only the specified section of the page. + Return only the specified section of the page. + + + + The slug of the documentation page (e.g., 'redis-integration'). + The slug of the documentation page (e.g., 'redis-integration'). + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ru.xlf new file mode 100644 index 00000000000..c0415dda9ef --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ru.xlf @@ -0,0 +1,82 @@ + + + + + + Browse and search Aspire documentation from aspire.dev. + Browse and search Aspire documentation from aspire.dev. + + + + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Found {0} documentation pages. + Found {0} documentation pages. + + + + Found {0} results for '{1}'. + Found {0} results for '{1}'. + + + + Get the full content of a documentation page by its slug. + Get the full content of a documentation page by its slug. + + + + Maximum number of search results to return (default: 5, max: 10). + Maximum number of search results to return (default: 5, max: 10). + + + + List all available Aspire documentation pages. + List all available Aspire documentation pages. + + + + Loading documentation... + Loading documentation... + + + + No documentation available. The aspire.dev docs may not have loaded correctly. + No documentation available. The aspire.dev docs may not have loaded correctly. + + + + No results found for '{0}'. Try different search terms. + No results found for '{0}'. Try different search terms. + + + + The search query. + The search query. + + + + Search Aspire documentation by keywords. + Search Aspire documentation by keywords. + + + + Return only the specified section of the page. + Return only the specified section of the page. + + + + The slug of the documentation page (e.g., 'redis-integration'). + The slug of the documentation page (e.g., 'redis-integration'). + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.tr.xlf new file mode 100644 index 00000000000..a61c34b032c --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.tr.xlf @@ -0,0 +1,82 @@ + + + + + + Browse and search Aspire documentation from aspire.dev. + Browse and search Aspire documentation from aspire.dev. + + + + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Found {0} documentation pages. + Found {0} documentation pages. + + + + Found {0} results for '{1}'. + Found {0} results for '{1}'. + + + + Get the full content of a documentation page by its slug. + Get the full content of a documentation page by its slug. + + + + Maximum number of search results to return (default: 5, max: 10). + Maximum number of search results to return (default: 5, max: 10). + + + + List all available Aspire documentation pages. + List all available Aspire documentation pages. + + + + Loading documentation... + Loading documentation... + + + + No documentation available. The aspire.dev docs may not have loaded correctly. + No documentation available. The aspire.dev docs may not have loaded correctly. + + + + No results found for '{0}'. Try different search terms. + No results found for '{0}'. Try different search terms. + + + + The search query. + The search query. + + + + Search Aspire documentation by keywords. + Search Aspire documentation by keywords. + + + + Return only the specified section of the page. + Return only the specified section of the page. + + + + The slug of the documentation page (e.g., 'redis-integration'). + The slug of the documentation page (e.g., 'redis-integration'). + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hans.xlf new file mode 100644 index 00000000000..089a1f27b74 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hans.xlf @@ -0,0 +1,82 @@ + + + + + + Browse and search Aspire documentation from aspire.dev. + Browse and search Aspire documentation from aspire.dev. + + + + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Found {0} documentation pages. + Found {0} documentation pages. + + + + Found {0} results for '{1}'. + Found {0} results for '{1}'. + + + + Get the full content of a documentation page by its slug. + Get the full content of a documentation page by its slug. + + + + Maximum number of search results to return (default: 5, max: 10). + Maximum number of search results to return (default: 5, max: 10). + + + + List all available Aspire documentation pages. + List all available Aspire documentation pages. + + + + Loading documentation... + Loading documentation... + + + + No documentation available. The aspire.dev docs may not have loaded correctly. + No documentation available. The aspire.dev docs may not have loaded correctly. + + + + No results found for '{0}'. Try different search terms. + No results found for '{0}'. Try different search terms. + + + + The search query. + The search query. + + + + Search Aspire documentation by keywords. + Search Aspire documentation by keywords. + + + + Return only the specified section of the page. + Return only the specified section of the page. + + + + The slug of the documentation page (e.g., 'redis-integration'). + The slug of the documentation page (e.g., 'redis-integration'). + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hant.xlf new file mode 100644 index 00000000000..ec689b24219 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hant.xlf @@ -0,0 +1,82 @@ + + + + + + Browse and search Aspire documentation from aspire.dev. + Browse and search Aspire documentation from aspire.dev. + + + + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + + + + Output format (Table or Json). + Output format (Table or Json). + + + + Found {0} documentation pages. + Found {0} documentation pages. + + + + Found {0} results for '{1}'. + Found {0} results for '{1}'. + + + + Get the full content of a documentation page by its slug. + Get the full content of a documentation page by its slug. + + + + Maximum number of search results to return (default: 5, max: 10). + Maximum number of search results to return (default: 5, max: 10). + + + + List all available Aspire documentation pages. + List all available Aspire documentation pages. + + + + Loading documentation... + Loading documentation... + + + + No documentation available. The aspire.dev docs may not have loaded correctly. + No documentation available. The aspire.dev docs may not have loaded correctly. + + + + No results found for '{0}'. Try different search terms. + No results found for '{0}'. Try different search terms. + + + + The search query. + The search query. + + + + Search Aspire documentation by keywords. + Search Aspire documentation by keywords. + + + + Return only the specified section of the page. + Return only the specified section of the page. + + + + The slug of the documentation page (e.g., 'redis-integration'). + The slug of the documentation page (e.g., 'redis-integration'). + + + + + \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/Commands/DocsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DocsCommandTests.cs new file mode 100644 index 00000000000..ad9791b6ab6 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/DocsCommandTests.cs @@ -0,0 +1,233 @@ +// 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.Mcp.Docs; +using Aspire.Cli.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class DocsCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task DocsCommand_WithNoSubcommand_ShowsHelp() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.DocsIndexServiceFactory = _ => new TestDocsIndexService(); + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("docs"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + // Returns InvalidCommand exit code when no subcommand is provided (shows help) + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public async Task DocsListCommand_ReturnsDocuments() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.DocsIndexServiceFactory = _ => new TestDocsIndexService(); + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("docs list"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task DocsListCommand_WithJsonFormat_ReturnsJson() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.DocsIndexServiceFactory = _ => new TestDocsIndexService(); + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("docs list --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task DocsSearchCommand_WithQuery_ReturnsResults() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.DocsIndexServiceFactory = _ => new TestDocsIndexService(); + options.DocsSearchServiceFactory = _ => new TestDocsSearchService(); + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("docs search redis"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task DocsSearchCommand_WithLimit_RespectsLimit() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.DocsIndexServiceFactory = _ => new TestDocsIndexService(); + options.DocsSearchServiceFactory = _ => new TestDocsSearchService(); + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("docs search redis -n 3"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task DocsSearchCommand_WithJsonFormat_ReturnsJson() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.DocsIndexServiceFactory = _ => new TestDocsIndexService(); + options.DocsSearchServiceFactory = _ => new TestDocsSearchService(); + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("docs search redis --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task DocsGetCommand_WithValidSlug_ReturnsContent() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.DocsIndexServiceFactory = _ => new TestDocsIndexService(); + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("docs get redis-integration"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task DocsGetCommand_WithSection_ReturnsSection() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.DocsIndexServiceFactory = _ => new TestDocsIndexService(); + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("docs get redis-integration --section \"Getting Started\""); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task DocsGetCommand_WithInvalidSlug_ReturnsError() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.DocsIndexServiceFactory = _ => new TestDocsIndexService(); + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("docs get nonexistent-page"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.NotEqual(0, exitCode); + } +} + +internal sealed class TestDocsIndexService : IDocsIndexService +{ + public ValueTask EnsureIndexedAsync(CancellationToken cancellationToken = default) + { + return ValueTask.CompletedTask; + } + + public ValueTask> ListDocumentsAsync(CancellationToken cancellationToken = default) + { + var docs = new List + { + new() { Title = "Redis Integration", Slug = "redis-integration", Summary = "Learn how to use Redis" }, + new() { Title = "PostgreSQL Integration", Slug = "postgresql-integration", Summary = "Learn how to use PostgreSQL" }, + new() { Title = "Getting Started", Slug = "getting-started", Summary = "Get started with Aspire" } + }; + return ValueTask.FromResult>(docs); + } + + public ValueTask> SearchAsync(string query, int topK = 10, CancellationToken cancellationToken = default) + { + var results = new List + { + new() { Title = "Redis Integration", Slug = "redis-integration", Summary = "Learn how to use Redis", Score = 100.0f, MatchedSection = "Hosting integration" }, + new() { Title = "Azure Cache for Redis", Slug = "azure-cache-redis", Summary = "Azure Redis integration", Score = 80.0f, MatchedSection = "Client integration" } + }; + return ValueTask.FromResult>(results.Take(topK).ToList() as IReadOnlyList); + } + + public ValueTask GetDocumentAsync(string slug, string? section = null, CancellationToken cancellationToken = default) + { + if (slug == "redis-integration") + { + return ValueTask.FromResult(new DocsContent + { + Title = "Redis Integration", + Slug = "redis-integration", + Summary = "Learn how to use Redis", + Content = "# Redis Integration\n\nThis is the Redis integration documentation.", + Sections = new[] { "Getting Started", "Hosting integration", "Client integration" } + }); + } + + return ValueTask.FromResult(null); + } +} + +internal sealed class TestDocsSearchService : IDocsSearchService +{ + public Task SearchAsync(string query, int topK = 5, CancellationToken cancellationToken = default) + { + var results = new List + { + new() { Title = "Redis Integration", Slug = "redis-integration", Content = "Learn how to use Redis", Score = 100.0f, Section = "Hosting integration" }, + new() { Title = "Azure Cache for Redis", Slug = "azure-cache-redis", Content = "Azure Redis integration", Score = 80.0f, Section = "Client integration" } + }; + + return Task.FromResult(new DocsSearchResponse + { + Query = query, + Results = results.Take(topK).ToList() + }); + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/Docs/DocsFetcherTests.cs b/tests/Aspire.Cli.Tests/Mcp/Docs/DocsFetcherTests.cs index 76a1fbf27ec..217d6c89759 100644 --- a/tests/Aspire.Cli.Tests/Mcp/Docs/DocsFetcherTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/Docs/DocsFetcherTests.cs @@ -430,6 +430,7 @@ private sealed class MockDocsCache : IDocsCache { private readonly Dictionary _content = []; private readonly Dictionary _etags = []; + private LlmsDocument[]? _index; public Task GetAsync(string key, CancellationToken cancellationToken = default) { @@ -462,6 +463,17 @@ public Task SetETagAsync(string url, string? etag, CancellationToken cancellatio return Task.CompletedTask; } + public Task GetIndexAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(_index); + } + + public Task SetIndexAsync(LlmsDocument[] documents, CancellationToken cancellationToken = default) + { + _index = documents; + return Task.CompletedTask; + } + public Task InvalidateAsync(string key, CancellationToken cancellationToken = default) { _content.Remove(key); diff --git a/tests/Aspire.Cli.Tests/Mcp/Docs/DocsIndexServiceTests.cs b/tests/Aspire.Cli.Tests/Mcp/Docs/DocsIndexServiceTests.cs index 022869a2c9d..053d830045f 100644 --- a/tests/Aspire.Cli.Tests/Mcp/Docs/DocsIndexServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/Docs/DocsIndexServiceTests.cs @@ -13,6 +13,14 @@ private static IDocsFetcher CreateMockFetcher(string? content) return new MockDocsFetcher(content); } + private static DocsIndexService CreateService(IDocsFetcher? fetcher = null, IDocsCache? cache = null) + { + return new DocsIndexService( + fetcher ?? new MockDocsFetcher(null), + cache ?? new NullDocsCache(), + NullLogger.Instance); + } + [Fact] public async Task ListDocumentsAsync_ReturnsAllDocuments() { @@ -29,7 +37,7 @@ PostgreSQL content. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var docs = await service.ListDocumentsAsync(); @@ -42,7 +50,7 @@ PostgreSQL content. public async Task ListDocumentsAsync_WhenFetchFails_ReturnsEmptyList() { var fetcher = CreateMockFetcher(null); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var docs = await service.ListDocumentsAsync(); @@ -65,7 +73,7 @@ PostgreSQL content. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("Redis"); @@ -84,7 +92,7 @@ Some content here. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("caching"); @@ -107,7 +115,7 @@ Deploy to Azure Container Apps. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("Configuration"); @@ -132,7 +140,7 @@ PostgreSQL and MySQL are popular database options. Redis is sometimes mentioned. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("Redis"); @@ -158,7 +166,7 @@ public async Task SearchAsync_FindsCodeIdentifiers() """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("AddRedis"); @@ -197,7 +205,7 @@ Redis everywhere here. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("Redis", topK: 3); @@ -213,7 +221,7 @@ Content here. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync(""); @@ -229,7 +237,7 @@ Content here. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync(" "); @@ -252,7 +260,7 @@ Simple memory cache implementation. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("Redis caching"); @@ -272,7 +280,7 @@ Redis content. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var doc = await service.GetDocumentAsync("redis-integration"); @@ -292,7 +300,7 @@ Redis content. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var doc = await service.GetDocumentAsync("REDIS-INTEGRATION"); @@ -311,7 +319,7 @@ Redis content. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var doc = await service.GetDocumentAsync("nonexistent-doc"); @@ -335,7 +343,7 @@ Configure connection strings. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var doc = await service.GetDocumentAsync("redis-integration", "Installation"); @@ -356,7 +364,7 @@ Quick start content. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var doc = await service.GetDocumentAsync("redis-integration", "Getting Started"); @@ -382,7 +390,7 @@ Usage content. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var doc = await service.GetDocumentAsync("redis-integration"); @@ -402,7 +410,7 @@ public async Task EnsureIndexedAsync_OnlyFetchesOnce() callCount++; return "# Doc\nContent."; }); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); await service.EnsureIndexedAsync(); await service.EnsureIndexedAsync(); @@ -432,7 +440,7 @@ Learn about Redis publish/subscribe. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("Redis"); @@ -455,7 +463,7 @@ Content here. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync(null!); @@ -473,7 +481,7 @@ Redis content. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var doc = await service.GetDocumentAsync(null!); @@ -491,7 +499,7 @@ Redis content. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var doc = await service.GetDocumentAsync(""); @@ -509,7 +517,7 @@ Redis content. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var doc = await service.GetDocumentAsync(" "); @@ -520,7 +528,7 @@ Redis content. public async Task ListDocumentsAsync_WhenFetchReturnsEmpty_ReturnsEmptyList() { var fetcher = CreateMockFetcher(""); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var docs = await service.ListDocumentsAsync(); @@ -531,7 +539,7 @@ public async Task ListDocumentsAsync_WhenFetchReturnsEmpty_ReturnsEmptyList() public async Task ListDocumentsAsync_WhenFetchReturnsWhitespace_ReturnsEmptyList() { var fetcher = CreateMockFetcher(" \n\t\n "); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var docs = await service.ListDocumentsAsync(); @@ -542,7 +550,7 @@ public async Task ListDocumentsAsync_WhenFetchReturnsWhitespace_ReturnsEmptyList public async Task SearchAsync_WhenNoDocsIndexed_ReturnsEmptyResults() { var fetcher = CreateMockFetcher(null); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("Redis"); @@ -553,7 +561,7 @@ public async Task SearchAsync_WhenNoDocsIndexed_ReturnsEmptyResults() public async Task GetDocumentAsync_WhenNoDocsIndexed_ReturnsNull() { var fetcher = CreateMockFetcher(null); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var doc = await service.GetDocumentAsync("any-slug"); @@ -564,7 +572,7 @@ public async Task GetDocumentAsync_WhenNoDocsIndexed_ReturnsNull() public async Task ListDocumentsAsync_WhenFetcherThrows_PropagatesException() { var fetcher = new ThrowingDocsFetcher(new InvalidOperationException("Fetch failed")); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); await Assert.ThrowsAsync(() => service.ListDocumentsAsync().AsTask()); } @@ -573,7 +581,7 @@ public async Task ListDocumentsAsync_WhenFetcherThrows_PropagatesException() public async Task SearchAsync_WhenFetcherThrows_PropagatesException() { var fetcher = new ThrowingDocsFetcher(new HttpRequestException("Network error")); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); await Assert.ThrowsAsync(() => service.SearchAsync("Redis").AsTask()); } @@ -582,7 +590,7 @@ public async Task SearchAsync_WhenFetcherThrows_PropagatesException() public async Task GetDocumentAsync_WhenFetcherThrows_PropagatesException() { var fetcher = new ThrowingDocsFetcher(new TimeoutException("Request timed out")); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); await Assert.ThrowsAsync(() => service.GetDocumentAsync("redis-integration").AsTask()); } @@ -591,7 +599,7 @@ public async Task GetDocumentAsync_WhenFetcherThrows_PropagatesException() public async Task EnsureIndexedAsync_WhenCancelled_ThrowsOperationCanceledException() { var fetcher = new DelayingDocsFetcher("# Doc\nContent.", TimeSpan.FromSeconds(10)); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); using var cts = new CancellationTokenSource(); cts.Cancel(); @@ -604,7 +612,7 @@ await Assert.ThrowsAnyAsync( public async Task EnsureIndexedAsync_WhenFetcherThrows_PropagatesException() { var fetcher = new ThrowingDocsFetcher(new InvalidOperationException("Critical error")); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); await Assert.ThrowsAsync(() => service.EnsureIndexedAsync().AsTask()); } @@ -620,7 +628,7 @@ Redis content. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); // Should not throw var results = await service.SearchAsync("Redis!@#$%^&*()"); @@ -640,7 +648,7 @@ Redis content. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var longQuery = new string('a', 10000); @@ -664,7 +672,7 @@ Install content. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var doc = await service.GetDocumentAsync("redis-integration", "NonExistentSection"); @@ -684,7 +692,7 @@ Redis content. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("Redis", topK: 0); @@ -702,7 +710,7 @@ Redis content. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("Redis", topK: -1); @@ -727,7 +735,7 @@ Azure Service Bus has a service name. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("service-discovery"); @@ -754,7 +762,7 @@ Service is mentioned multiple times. Service again. And service. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("service discovery"); @@ -780,7 +788,7 @@ JavaScript JavaScript JavaScript. We love JavaScript! """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("javascript"); @@ -806,7 +814,7 @@ The dashboard has MCP options in settings. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("mcp"); @@ -832,7 +840,7 @@ Redis support was added. Redis improvements. More Redis features. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("redis"); @@ -858,7 +866,7 @@ Overview includes Cosmos DB mention. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("azure cosmos"); @@ -886,7 +894,7 @@ Redis integration details. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("service"); @@ -914,7 +922,7 @@ Overview of Azure services. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("service-bus"); @@ -939,7 +947,7 @@ Changelog mentioned once. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("changelog"); @@ -965,7 +973,7 @@ New features and improvements. """; var fetcher = CreateMockFetcher(content); - var service = new DocsIndexService(fetcher, NullLogger.Instance); + var service = CreateService(fetcher); var results = await service.SearchAsync("whats new"); @@ -1006,4 +1014,15 @@ private sealed class DelayingDocsFetcher(string? content, TimeSpan delay) : IDoc return content; } } + + private sealed class NullDocsCache : IDocsCache + { + public Task GetAsync(string key, CancellationToken cancellationToken = default) => Task.FromResult(null); + public Task SetAsync(string key, string content, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task GetETagAsync(string url, CancellationToken cancellationToken = default) => Task.FromResult(null); + public Task SetETagAsync(string url, string? etag, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task GetIndexAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); + public Task SetIndexAsync(LlmsDocument[] documents, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task InvalidateAsync(string key, CancellationToken cancellationToken = default) => Task.CompletedTask; + } } diff --git a/tests/Aspire.Cli.Tests/Mcp/Docs/DocsSearchServiceTests.cs b/tests/Aspire.Cli.Tests/Mcp/Docs/DocsSearchServiceTests.cs index 7ff25752cd5..82078c24e77 100644 --- a/tests/Aspire.Cli.Tests/Mcp/Docs/DocsSearchServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/Docs/DocsSearchServiceTests.cs @@ -8,6 +8,19 @@ namespace Aspire.Cli.Tests.Mcp.Docs; public class DocsSearchServiceTests { + private static DocsSearchService CreateService(IDocsIndexService indexService) + { + return new DocsSearchService(indexService, NullLogger.Instance); + } + + private static DocsIndexService CreateIndexService(IDocsFetcher? fetcher = null, IDocsCache? cache = null) + { + return new DocsIndexService( + fetcher ?? new MockDocsFetcher(null), + cache ?? new NullDocsCache(), + NullLogger.Instance); + } + [Fact] public async Task SearchAsync_ReturnsFormattedResponse() { @@ -19,8 +32,8 @@ Redis content with details. """; var fetcher = new MockDocsFetcher(content); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("Redis"); @@ -40,8 +53,8 @@ PostgreSQL content. """; var fetcher = new MockDocsFetcher(content); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("nonexistent-term-xyz"); @@ -60,8 +73,8 @@ Redis content with details. """; var fetcher = new MockDocsFetcher(content); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("Redis"); Assert.NotNull(response); @@ -84,8 +97,8 @@ Redis content. """; var fetcher = new MockDocsFetcher(content); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("Redis"); Assert.NotNull(response); @@ -106,8 +119,8 @@ PostgreSQL content. """; var fetcher = new MockDocsFetcher(content); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("xyz-not-found"); Assert.NotNull(response); @@ -139,8 +152,8 @@ Redis again here. """; var fetcher = new MockDocsFetcher(content); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("Redis", topK: 2); @@ -163,8 +176,8 @@ Configure PostgreSQL. """; var fetcher = new MockDocsFetcher(content); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("Redis"); Assert.NotNull(response); @@ -188,8 +201,8 @@ Redis content. """; var fetcher = new MockDocsFetcher(content); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync(query!); @@ -201,8 +214,8 @@ Redis content. public async Task SearchAsync_WhenNoDocsAvailable_ReturnsResponseWithEmptyResults() { var fetcher = new MockDocsFetcher(null); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("Redis"); @@ -214,8 +227,8 @@ public async Task SearchAsync_WhenNoDocsAvailable_ReturnsResponseWithEmptyResult public async Task SearchAsync_WhenDocsEmpty_ReturnsResponseWithEmptyResults() { var fetcher = new MockDocsFetcher(""); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("Redis"); @@ -234,8 +247,8 @@ Redis content. """; var fetcher = new MockDocsFetcher(content); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("Redis"); Assert.NotNull(response); @@ -257,8 +270,8 @@ Redis content. """; var fetcher = new MockDocsFetcher(content); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("Redis"); Assert.NotNull(response); @@ -282,8 +295,8 @@ Redis content. """; var fetcher = new MockDocsFetcher(content); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("Redis", topK: 0); @@ -302,8 +315,8 @@ Redis content. """; var fetcher = new MockDocsFetcher(content); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("Redis", topK: -5); @@ -327,8 +340,8 @@ More Redis. """; var fetcher = new MockDocsFetcher(content); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("Redis", topK: 1000); @@ -340,8 +353,8 @@ More Redis. public async Task SearchAsync_WhenIndexerThrows_PropagatesException() { var fetcher = new ThrowingDocsFetcher(new InvalidOperationException("Index failed")); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); await Assert.ThrowsAsync(() => searchService.SearchAsync("Redis")); } @@ -357,8 +370,8 @@ Redis content. """; var fetcher = new MockDocsFetcher(content); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("Redis!@#$%^&*()"); @@ -377,8 +390,8 @@ Redis content. """; var fetcher = new MockDocsFetcher(content); - var indexService = new DocsIndexService(fetcher, NullLogger.Instance); - var searchService = new DocsSearchService(indexService, NullLogger.Instance); + var indexService = CreateIndexService(fetcher); + var searchService = CreateService(indexService); var response = await searchService.SearchAsync("Rédis 日本語 🚀"); @@ -401,4 +414,15 @@ private sealed class ThrowingDocsFetcher(Exception exception) : IDocsFetcher throw exception; } } + + private sealed class NullDocsCache : IDocsCache + { + public Task GetAsync(string key, CancellationToken cancellationToken = default) => Task.FromResult(null); + public Task SetAsync(string key, string content, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task GetETagAsync(string url, CancellationToken cancellationToken = default) => Task.FromResult(null); + public Task SetETagAsync(string url, string? etag, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task GetIndexAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); + public Task SetIndexAsync(LlmsDocument[] documents, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task InvalidateAsync(string key, CancellationToken cancellationToken = default) => Task.CompletedTask; + } } diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 5cd76143d51..81bf85fed3e 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -142,7 +142,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(); services.AddSingleton(); services.AddSingleton(options.DocsIndexServiceFactory); - services.AddSingleton(); + services.AddSingleton(options.DocsSearchServiceFactory); services.AddTransient(); services.AddTransient(); @@ -178,6 +178,10 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(options.AppHostBackchannelFactory); return services; @@ -474,8 +478,16 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser public Func DocsIndexServiceFactory { get; set; } = (IServiceProvider serviceProvider) => { var fetcher = serviceProvider.GetRequiredService(); + var cache = serviceProvider.GetRequiredService(); var logger = serviceProvider.GetRequiredService>(); - return new DocsIndexService(fetcher, logger); + return new DocsIndexService(fetcher, cache, logger); + }; + + public Func DocsSearchServiceFactory { get; set; } = (IServiceProvider serviceProvider) => + { + var indexService = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + return new DocsSearchService(indexService, logger); }; } From dc5a0d11df0a4330fd5d5c1ce34fd21ab81caa48 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 09:39:10 -0800 Subject: [PATCH 029/256] Make HTTPS developer certificate opt-in for Redis (#14306) * Initial plan * Make HTTPS developer certificate opt-in for Redis to avoid connection string timing issues Co-authored-by: danegsta <50252651+danegsta@users.noreply.github.com> * Remove redundant test AddRedisDisablesTlsConfigurationByDefault Co-authored-by: danegsta <50252651+danegsta@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> --- src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs index dbbe0716d8c..ec8e8638435 100644 --- a/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs +++ b/src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs @@ -213,6 +213,9 @@ public static IResourceBuilder AddRedis( }); } + // Disable HTTPS developer certificate by default to avoid connection string timing issues + redisBuilder.WithoutHttpsCertificate(); + return redisBuilder; } From 8ac0039722c7e8187dbf53a1f49b05b5e9e141e6 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 4 Feb 2026 05:53:18 +1100 Subject: [PATCH 030/256] Fix SKILL.md header and add update detection for aspire agent init (#14314) * Fix SKILL.md header and add update detection - Add proper YAML front matter header (name, description) to SkillFileContent so the skill can be properly picked up by agent environments - Modify TryAddSkillFileApplicator to detect when existing file differs from current content and offer to update it - Add UpdateSkillFileAsync method to handle file replacement This allows running 'aspire agent init' again to update the skill file when the content has changed, since our skills capability is constantly evolving. * Update tests for skill file update detection - Update TryAddSkillFileApplicator tests to cover new behavior: - Same content: no applicator added - Different content: update applicator added - Update applicator correctly replaces content - Fix CopilotCliAgentEnvironmentScannerTests to use full SkillFileContent so no update applicator is triggered * Normalize line endings when comparing skill file content Files may have different line endings depending on platform (CRLF on Windows, LF on Linux/macOS). Normalize to LF before comparison to avoid unnecessary update prompts when the content is semantically the same. Added test to verify line ending normalization works correctly. * Apply suggestions from code review --------- Co-authored-by: Mitch Denny Co-authored-by: David Fowler --- .../Agents/CommonAgentApplicators.cs | 43 ++++++++- .../Agents/CommonAgentApplicatorsTests.cs | 90 ++++++++++++++++++- .../CopilotCliAgentEnvironmentScannerTests.cs | 6 +- 3 files changed, 130 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs index 3b751536ec8..a417b5e067e 100644 --- a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs +++ b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs @@ -20,7 +20,7 @@ internal static class CommonAgentApplicators /// The workspace root directory. /// The relative path to the skill file from workspace root (e.g., ".github/skills/aspire/SKILL.md"). /// The description to show in the applicator prompt. - /// True if the applicator was added, false if it was already added or the file exists. + /// True if the applicator was added, false if it was already added. public static bool TryAddSkillFileApplicator( AgentEnvironmentScanContext context, DirectoryInfo workspaceRoot, @@ -38,9 +38,27 @@ public static bool TryAddSkillFileApplicator( // Mark this skill path as having an applicator (whether file exists or not) context.MarkSkillFileApplicatorAdded(skillRelativePath); - // Don't add applicator if the skill file already exists + // Check if the skill file already exists if (File.Exists(skillFilePath)) { + // Read existing content and check if it differs from current content + // Normalize line endings for comparison to handle cross-platform differences + var existingContent = File.ReadAllText(skillFilePath); + var normalizedExisting = NormalizeLineEndings(existingContent); + var normalizedExpected = NormalizeLineEndings(SkillFileContent); + + if (!string.Equals(normalizedExisting, normalizedExpected, StringComparison.Ordinal)) + { + // Content differs, offer to update + context.AddApplicator(new AgentEnvironmentApplicator( + $"{description} (update - content has changed)", + ct => UpdateSkillFileAsync(skillFilePath, ct), + promptGroup: McpInitPromptGroup.AdditionalOptions, + priority: 0)); + return true; + } + + // File exists and content is the same, nothing to do return false; } @@ -107,11 +125,32 @@ private static async Task CreateSkillFileAsync(string skillFilePath, Cancellatio } } + /// + /// Updates an existing skill file at the specified path with the latest content. + /// + private static async Task UpdateSkillFileAsync(string skillFilePath, CancellationToken cancellationToken) + { + await File.WriteAllTextAsync(skillFilePath, SkillFileContent, cancellationToken); + } + + /// + /// Normalizes line endings to LF for consistent comparison across platforms. + /// + private static string NormalizeLineEndings(string content) + { + return content.ReplaceLineEndings("\n"); + } + /// /// Gets the content for the Aspire skill file. /// internal const string SkillFileContent = """ + --- + name: aspire + description: Skills for working with the Aspire CLI. Review this for running and testing Aspire applications. + --- + # Aspire Skill This repository is set up to use Aspire. Aspire is an orchestrator for the entire application and will take care of configuring dependencies, building, and running the application. The resources that make up the application are defined in `apphost.cs` including application code and external dependencies. diff --git a/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs b/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs index ea0abb88cc2..4d9c42c482c 100644 --- a/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/CommonAgentApplicatorsTests.cs @@ -53,17 +53,17 @@ public void TryAddSkillFileApplicator_WhenAlreadyAdded_DoesNotAddAgainAndReturns } [Fact] - public void TryAddSkillFileApplicator_WhenSkillFileExists_DoesNotAddApplicator() + public void TryAddSkillFileApplicator_WhenSkillFileExistsWithSameContent_DoesNotAddApplicator() { // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); var context = CreateScanContext(workspace.WorkspaceRoot); - // Create the skill file with any content + // Create the skill file with the SAME content as SkillFileContent var skillFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, TestSkillRelativePath); var skillDirectory = Path.GetDirectoryName(skillFilePath)!; Directory.CreateDirectory(skillDirectory); - File.WriteAllText(skillFilePath, "# Existing Content\n\nThis already exists."); + File.WriteAllText(skillFilePath, CommonAgentApplicators.SkillFileContent); // Act var result = CommonAgentApplicators.TryAddSkillFileApplicator( @@ -72,7 +72,89 @@ public void TryAddSkillFileApplicator_WhenSkillFileExists_DoesNotAddApplicator() TestSkillRelativePath, TestDescription); - // Assert - should not add applicator since skill file already exists + // Assert - should not add applicator since skill file already exists with same content + Assert.False(result); + Assert.True(context.HasSkillFileApplicator(TestSkillRelativePath)); + Assert.Empty(context.Applicators); + } + + [Fact] + public void TryAddSkillFileApplicator_WhenSkillFileExistsWithDifferentContent_AddsUpdateApplicator() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var context = CreateScanContext(workspace.WorkspaceRoot); + + // Create the skill file with DIFFERENT content + var skillFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, TestSkillRelativePath); + var skillDirectory = Path.GetDirectoryName(skillFilePath)!; + Directory.CreateDirectory(skillDirectory); + File.WriteAllText(skillFilePath, "# Old Aspire Skill\n\nThis is outdated content."); + + // Act + var result = CommonAgentApplicators.TryAddSkillFileApplicator( + context, + workspace.WorkspaceRoot, + TestSkillRelativePath, + TestDescription); + + // Assert - should add an update applicator since content differs + Assert.True(result); + Assert.True(context.HasSkillFileApplicator(TestSkillRelativePath)); + Assert.Single(context.Applicators); + Assert.Contains("update", context.Applicators[0].Description); + } + + [Fact] + public async Task TryAddSkillFileApplicator_UpdateApplicator_ReplacesContent() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var context = CreateScanContext(workspace.WorkspaceRoot); + + // Create the skill file with old content + var skillFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, TestSkillRelativePath); + var skillDirectory = Path.GetDirectoryName(skillFilePath)!; + Directory.CreateDirectory(skillDirectory); + var oldContent = "# Old Aspire Skill\n\nThis is outdated content."; + File.WriteAllText(skillFilePath, oldContent); + + // Act + CommonAgentApplicators.TryAddSkillFileApplicator( + context, + workspace.WorkspaceRoot, + TestSkillRelativePath, + TestDescription); + await context.Applicators[0].ApplyAsync(CancellationToken.None).DefaultTimeout(); + + // Assert - content should be replaced with new content + var newContent = await File.ReadAllTextAsync(skillFilePath); + Assert.NotEqual(oldContent, newContent); + Assert.Equal(CommonAgentApplicators.SkillFileContent, newContent); + } + + [Fact] + public void TryAddSkillFileApplicator_WhenSkillFileExistsWithDifferentLineEndings_DoesNotAddApplicator() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var context = CreateScanContext(workspace.WorkspaceRoot); + + // Create the skill file with CRLF line endings (Windows style) + var skillFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, TestSkillRelativePath); + var skillDirectory = Path.GetDirectoryName(skillFilePath)!; + Directory.CreateDirectory(skillDirectory); + var contentWithCrlf = CommonAgentApplicators.SkillFileContent.ReplaceLineEndings("\r\n"); + File.WriteAllText(skillFilePath, contentWithCrlf); + + // Act + var result = CommonAgentApplicators.TryAddSkillFileApplicator( + context, + workspace.WorkspaceRoot, + TestSkillRelativePath, + TestDescription); + + // Assert - should not add applicator since content is the same (just different line endings) Assert.False(result); Assert.True(context.HasSkillFileApplicator(TestSkillRelativePath)); Assert.Empty(context.Applicators); diff --git a/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs index abfa1ca0490..e4489ecc2fc 100644 --- a/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs @@ -151,10 +151,10 @@ public async Task ScanAsync_WhenAspireAlreadyConfigured_ReturnsNoApplicator() var mcpConfigPath = Path.Combine(copilotFolder.FullName, "mcp-config.json"); await File.WriteAllTextAsync(mcpConfigPath, existingConfig.ToJsonString()); - // Also create the skill file to prevent that applicator + // Also create the skill file with the SAME content as SkillFileContent to prevent update applicator var skillFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, ".github", "skills", "aspire", "SKILL.md"); Directory.CreateDirectory(Path.GetDirectoryName(skillFilePath)!); - await File.WriteAllTextAsync(skillFilePath, "# Aspire Skill"); + await File.WriteAllTextAsync(skillFilePath, CommonAgentApplicators.SkillFileContent); var copilotCliRunner = new FakeCopilotCliRunner(new SemVersion(1, 0, 0)); var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); @@ -163,7 +163,7 @@ public async Task ScanAsync_WhenAspireAlreadyConfigured_ReturnsNoApplicator() await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); - // No applicators should be returned since Aspire MCP, Playwright MCP are configured and skill file exists + // No applicators should be returned since Aspire MCP, Playwright MCP are configured and skill file exists with same content Assert.Empty(context.Applicators); } From 9da0f628a73493b732fa19f59e58fb48bcec4b31 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:16:18 -0500 Subject: [PATCH 031/256] [automated] Disable Aspire.Cli.Tests.Commands.RunCommandTests.RunCommand_SkipsBuild_WhenExtensionDevKitCapabilityIsAvailable (#14322) * [automated] Disable Aspire.Cli.Tests.Commands.RunCommandTests.RunCommand_SkipsBuild_WhenExtensionDevKitCapabilityIsAvailable * Update tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs --------- Co-authored-by: github-actions Co-authored-by: David Fowler --- tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 39e7d71292e..ed971058056 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.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 System.Runtime.CompilerServices; @@ -403,6 +403,7 @@ public async Task AppHostHelper_BuildAppHostAsync_IncludesRelativePathInStatusMe } [Fact] + [ActiveIssue("https://github.com/dotnet/aspire/issues/14321")] public async Task RunCommand_SkipsBuild_WhenExtensionDevKitCapabilityIsAvailable() { var buildCalled = false; From a27607056cae5c6bf89863f4096b303203939194 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:46:32 -0800 Subject: [PATCH 032/256] [Automated] Update AI Foundry Models (#14246) Co-authored-by: sebastienros --- src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs b/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs index 9b0583097a3..0dad5597109 100644 --- a/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs +++ b/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs @@ -190,7 +190,7 @@ public static partial class Meta /// /// Llama 4 Maverick 17B 128E Instruct FP8 is great at precise image understanding and creative writing, offering high quality at a lower price compared to Llama 3.3 70B /// - public static readonly AIFoundryModel Llama4Maverick17B128EInstructFP8 = new() { Name = "Llama-4-Maverick-17B-128E-Instruct-FP8", Version = "2", Format = "Meta" }; + public static readonly AIFoundryModel Llama4Maverick17B128EInstructFP8 = new() { Name = "Llama-4-Maverick-17B-128E-Instruct-FP8", Version = "3", Format = "Meta" }; /// /// Llama 4 Scout 17B 16E Instruct is great at multi-document summarization, parsing extensive user activity for personalized tasks, and reasoning over vast codebases. From 59014dddab21a5f7a7f3d5dc7b527c8425c10c6c Mon Sep 17 00:00:00 2001 From: Alex Crome Date: Tue, 3 Feb 2026 19:47:49 +0000 Subject: [PATCH 033/256] Make `EndpointReferenceExpression.GetValueAsync()` context aware (#14278) * Refactor endpoint resolution logic so that `EndpointReferenceExpression.GetValueAsync()` can be context aware. To make this work, DCP now always allocates a `DefaultAspireContainerNetwork` endpoint when a container is created alongside the existing `LocalhostNetwork` endpoint. * PR Review + fix broken test. * Fix `WithEnvironmentTests.EnvironmentVariableExpressions()` * - Fix Broken Tests - Add some DebuggerDisplay attributes to `ValueSnapshot`, and `NetworkEndpointSnapshotList` to make debugging easier - Explcitly use `*.dev.internal` alias for container networks - Don't allocate endpoints on custom networks * Fix test changed by previous commit removing support for custom networks. * One more failing test * Remove redundant comment. --- .../ApplicationModel/EndpointAnnotation.cs | 2 + .../ApplicationModel/ExpressionResolver.cs | 29 ----- .../ApplicationModel/ValueSnapshot.cs | 4 + src/Aspire.Hosting/Dcp/DcpExecutor.cs | 24 +++++ .../AzurePostgresExtensionsTests.cs | 8 +- .../ContainerResourceTests.cs | 4 +- .../AddMilvusTests.cs | 8 +- .../PostgresMcpBuilderTests.cs | 8 +- .../AddQdrantTests.cs | 16 ++- .../AddRedisTests.cs | 32 +++++- .../Dcp/DcpExecutorTests.cs | 101 +++++++++++++++++- .../EndpointReferenceTests.cs | 84 +++++++++++++++ .../ExpressionResolverTests.cs | 42 +++++--- .../WithEnvironmentTests.cs | 7 +- 14 files changed, 312 insertions(+), 57 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs index a4009d690bc..8ee659e3040 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs @@ -244,6 +244,7 @@ public AllocatedEndpoint? AllocatedEndpoint /// /// AllocatedEndpoint snapshot /// The ID of the network that is associated with the AllocatedEndpoint snapshot. +[DebuggerDisplay("NetworkID = {NetworkID}, Endpoint = {Snapshot}")] public record class NetworkEndpointSnapshot(ValueSnapshot Snapshot, NetworkIdentifier NetworkID); /// @@ -251,6 +252,7 @@ public record class NetworkEndpointSnapshot(ValueSnapshot Sna /// public class NetworkEndpointSnapshotList : IEnumerable { + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] private readonly ConcurrentBag _snapshots = new(); /// diff --git a/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs b/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs index b430a9c7b47..c483a8a2360 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs @@ -8,32 +8,6 @@ namespace Aspire.Hosting.ApplicationModel; internal class ExpressionResolver(CancellationToken cancellationToken) { - - async Task ResolveInContainerContextAsync(EndpointReference endpointReference, EndpointProperty property, ValueProviderContext context) - { - // We need to use the root resource, e.g. AzureStorageResource instead of AzureBlobResource - // Otherwise, we get the wrong values for IsContainer and Name - var target = endpointReference.Resource.GetRootResource(); - - return (property, target.IsContainer()) switch - { - // If Container -> Container, we use .dev.internal as host, and target port as port - // This assumes both containers are on the same container network. - // Different networks will require addtional routing/tunneling that we do not support today. - (EndpointProperty.Host or EndpointProperty.IPV4Host, true) => $"{target.Name}.dev.internal", - (EndpointProperty.Port, true) => await endpointReference.Property(EndpointProperty.TargetPort).GetValueAsync(context, cancellationToken).ConfigureAwait(false), - - (EndpointProperty.Url, _) => string.Format(CultureInfo.InvariantCulture, "{0}://{1}:{2}", - endpointReference.Scheme, - await ResolveInContainerContextAsync(endpointReference, EndpointProperty.Host, context).ConfigureAwait(false), - await ResolveInContainerContextAsync(endpointReference, EndpointProperty.Port, context).ConfigureAwait(false)), - (EndpointProperty.HostAndPort, _) => string.Format(CultureInfo.InvariantCulture, "{0}:{1}", - await ResolveInContainerContextAsync(endpointReference, EndpointProperty.Host, context).ConfigureAwait(false), - await ResolveInContainerContextAsync(endpointReference, EndpointProperty.Port, context).ConfigureAwait(false)), - _ => await endpointReference.Property(property).GetValueAsync(context, cancellationToken).ConfigureAwait(false) - }; - } - async Task EvalExpressionAsync(ReferenceExpression expr, ValueProviderContext context) { // This logic is similar to ReferenceExpression.GetValueAsync, except that we recurse on @@ -95,14 +69,11 @@ async Task ResolveConnectionStringReferenceAsync(ConnectionString /// async ValueTask ResolveInternalAsync(object? value, ValueProviderContext context) { - var networkContext = context.GetNetworkIdentifier(); return value switch { ConnectionStringReference cs => await ResolveConnectionStringReferenceAsync(cs, context).ConfigureAwait(false), IResourceWithConnectionString cs and not ConnectionStringParameterResource => await ResolveInternalAsync(cs.ConnectionStringExpression, context).ConfigureAwait(false), ReferenceExpression ex => await EvalExpressionAsync(ex, context).ConfigureAwait(false), - EndpointReference er when er.ContextNetworkID == KnownNetworkIdentifiers.DefaultAspireContainerNetwork || (er.ContextNetworkID == null && networkContext == KnownNetworkIdentifiers.DefaultAspireContainerNetwork) => new ResolvedValue(await ResolveInContainerContextAsync(er, EndpointProperty.Url, context).ConfigureAwait(false), false), - EndpointReferenceExpression ep when ep.Endpoint.ContextNetworkID == KnownNetworkIdentifiers.DefaultAspireContainerNetwork || (ep.Endpoint.ContextNetworkID == null && networkContext == KnownNetworkIdentifiers.DefaultAspireContainerNetwork) => new ResolvedValue(await ResolveInContainerContextAsync(ep.Endpoint, ep.Property, context).ConfigureAwait(false), false), IValueProvider vp => await EvalValueProvider(vp, context).ConfigureAwait(false), _ => throw new NotImplementedException() }; diff --git a/src/Aspire.Hosting/ApplicationModel/ValueSnapshot.cs b/src/Aspire.Hosting/ApplicationModel/ValueSnapshot.cs index 47081560cfe..003137933e9 100644 --- a/src/Aspire.Hosting/ApplicationModel/ValueSnapshot.cs +++ b/src/Aspire.Hosting/ApplicationModel/ValueSnapshot.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.Diagnostics; + namespace Aspire.Hosting.ApplicationModel; /// @@ -12,6 +14,7 @@ namespace Aspire.Hosting.ApplicationModel; /// /// Thread-safe for concurrent SetValue / SetException / GetValueAsync calls. /// +[DebuggerDisplay("{Value= {DebuggerValue()}")] public sealed class ValueSnapshot where T : notnull { private readonly TaskCompletionSource _firstValueTcs = @@ -73,4 +76,5 @@ public void SetException(Exception exception) } } } + private T? DebuggerValue() => IsValueSet ? _firstValueTcs.Task.Result : default; } diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index d6751adbeef..b2298e56954 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -976,6 +976,29 @@ private void AddAllocatedEndpointInfo(IEnumerable resourc bindingMode, targetPortExpression: $$$"""{{- portForServing "{{{svc.Metadata.Name}}}" -}}""", KnownNetworkIdentifiers.LocalhostNetwork); + + if (appResource.DcpResource is Container ctr && ctr.Spec.Networks is not null) + { + // Once container networks are fully supported, this should allocate endpoints on those networks + var containerNetwork = ctr.Spec.Networks.FirstOrDefault(n => n.Name == KnownNetworkIdentifiers.DefaultAspireContainerNetwork.Value); + + if (containerNetwork is not null) + { + var port = sp.EndpointAnnotation.TargetPort!; + + var allocatedEndpoint = new AllocatedEndpoint( + sp.EndpointAnnotation, + $"{sp.ModelResource.Name}.dev.internal", + (int)port, + EndpointBindingMode.SingleAddress, + targetPortExpression: $$$"""{{- portForServing "{{{svc.Metadata.Name}}}" -}}""", + KnownNetworkIdentifiers.DefaultAspireContainerNetwork + ); + var snapshot = new ValueSnapshot(); + snapshot.SetValue(allocatedEndpoint); + sp.EndpointAnnotation.AllAllocatedEndpoints.TryAdd(allocatedEndpoint.NetworkID, snapshot); + } + } } } @@ -1039,6 +1062,7 @@ ts.Service is not null && } } } + } private void PrepareContainerNetworks() diff --git a/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs index 53fe1bb9528..a7f55cc7a7a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs @@ -368,7 +368,13 @@ public async Task WithPostgresMcpOnAzureDatabaseRunAsContainerAddsMcpResource() .WithPasswordAuthentication(userName: user, password: pass) .RunAsContainer(c => { - c.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5432)); + c.WithEndpoint("tcp", e => + { + e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5432); + var snapshot = new ValueSnapshot(); + snapshot.SetValue(new AllocatedEndpoint(e, "postgres.dev.internal", 5432, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + }); }); var db = postgres.AddDatabase("db") diff --git a/tests/Aspire.Hosting.Containers.Tests/ContainerResourceTests.cs b/tests/Aspire.Hosting.Containers.Tests/ContainerResourceTests.cs index 335bbcac08b..06495623fc2 100644 --- a/tests/Aspire.Hosting.Containers.Tests/ContainerResourceTests.cs +++ b/tests/Aspire.Hosting.Containers.Tests/ContainerResourceTests.cs @@ -102,7 +102,7 @@ public async Task AddContainerWithArgs() e.AllocatedEndpoint = new(e, "localhost", 1234, targetPortExpression: "1234"); // For container-container lookup we need to add an AllocatedEndpoint on the container network side - var ccae = new AllocatedEndpoint(e, KnownHostNames.DefaultContainerTunnelHostName, 2234, EndpointBindingMode.SingleAddress, targetPortExpression: "2234", KnownNetworkIdentifiers.DefaultAspireContainerNetwork); + var ccae = new AllocatedEndpoint(e, "c1.dev.internal", 2234, EndpointBindingMode.SingleAddress, targetPortExpression: "2234", KnownNetworkIdentifiers.DefaultAspireContainerNetwork); var snapshot = new ValueSnapshot(); snapshot.SetValue(ccae); e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); @@ -114,7 +114,7 @@ public async Task AddContainerWithArgs() e.UriScheme = "http"; // We only care about the container-side endpoint for this test var snapshot = new ValueSnapshot(); - var ae = new AllocatedEndpoint(e, "localhost", 5678, EndpointBindingMode.SingleAddress, targetPortExpression: "5678", KnownNetworkIdentifiers.DefaultAspireContainerNetwork); + var ae = new AllocatedEndpoint(e, "container.dev.internal", 5678, EndpointBindingMode.SingleAddress, targetPortExpression: "5678", KnownNetworkIdentifiers.DefaultAspireContainerNetwork); snapshot.SetValue(ae); e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); }) diff --git a/tests/Aspire.Hosting.Milvus.Tests/AddMilvusTests.cs b/tests/Aspire.Hosting.Milvus.Tests/AddMilvusTests.cs index 474e5458d2c..433b15b1a46 100644 --- a/tests/Aspire.Hosting.Milvus.Tests/AddMilvusTests.cs +++ b/tests/Aspire.Hosting.Milvus.Tests/AddMilvusTests.cs @@ -96,7 +96,13 @@ public async Task MilvusClientAppWithReferenceContainsConnectionStrings() var pass = appBuilder.AddParameter("apikey", "pass"); var milvus = appBuilder.AddMilvus("my-milvus", pass) - .WithEndpoint("grpc", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", MilvusPortGrpc)); + .WithEndpoint("grpc", e => + { + e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", MilvusPortGrpc); + var snapshot = new ValueSnapshot(); + snapshot.SetValue(new AllocatedEndpoint(e, "my-milvus.dev.internal", MilvusPortGrpc, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + }); var projectA = appBuilder.AddProject("projecta", o => o.ExcludeLaunchProfile = true) .WithReference(milvus); diff --git a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresMcpBuilderTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresMcpBuilderTests.cs index 42272057861..e2eb3b0de6d 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresMcpBuilderTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresMcpBuilderTests.cs @@ -75,7 +75,13 @@ public async Task WithPostgresMcpOnDatabaseSetsDatabaseUriEnvironmentVariable() var pass = appBuilder.AddParameter("pass", "p@ssw0rd1"); appBuilder.AddPostgres("postgres", password: pass) - .WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5432)) + .WithEndpoint("tcp", e => + { + e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5432); + var snapshot = new ValueSnapshot(); + snapshot.SetValue(new AllocatedEndpoint(e, "postgres.dev.internal", 5432, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + }) .AddDatabase("db") .WithPostgresMcp(); diff --git a/tests/Aspire.Hosting.Qdrant.Tests/AddQdrantTests.cs b/tests/Aspire.Hosting.Qdrant.Tests/AddQdrantTests.cs index c9d2a819290..4d50b6207c1 100644 --- a/tests/Aspire.Hosting.Qdrant.Tests/AddQdrantTests.cs +++ b/tests/Aspire.Hosting.Qdrant.Tests/AddQdrantTests.cs @@ -169,8 +169,20 @@ public async Task QdrantClientAppWithReferenceContainsConnectionStrings() var pass = appBuilder.AddParameter("pass", "pass"); var qdrant = appBuilder.AddQdrant("my-qdrant", pass) - .WithEndpoint("grpc", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6334)) - .WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6333)); + .WithEndpoint("grpc", e => + { + e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6334); + var snapshot = new ValueSnapshot(); + snapshot.SetValue(new AllocatedEndpoint(e, "my-qdrant.dev.internal", 6334, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + }) + .WithEndpoint("http", e => + { + e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6333); + var snapshot = new ValueSnapshot(); + snapshot.SetValue(new AllocatedEndpoint(e, "my-qdrant.dev.internal", 6333, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + }); var projectA = appBuilder.AddProject("projecta", o => o.ExcludeLaunchProfile = true) .WithReference(qdrant); diff --git a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs index 362b9079ea7..cace9589d26 100644 --- a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs @@ -301,8 +301,27 @@ public async Task WithRedisInsightProducesCorrectEnvironmentVariables() using var app = builder.Build(); // Add fake allocated endpoints. - redis1.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001)); - redis2.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5002)); + redis1.WithEndpoint("tcp", e => + { + e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001); + var snapshot = new ValueSnapshot(); + snapshot.SetValue(new AllocatedEndpoint(e, "myredis1.dev.internal", 5001, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + }); + redis2.WithEndpoint("tcp", e => + { + e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5002); + var snapshot = new ValueSnapshot(); + snapshot.SetValue(new AllocatedEndpoint(e, "myredis2.dev.internal", 5002, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + }); + redis3.WithEndpoint("tcp", e => + { + e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5003); + var snapshot = new ValueSnapshot(); + snapshot.SetValue(new AllocatedEndpoint(e, "myredis3.dev.internal", 5003, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + }); var redisInsight = Assert.Single(builder.Resources.OfType()); var envs = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(redisInsight); @@ -367,7 +386,6 @@ public async Task WithRedisInsightProducesCorrectEnvironmentVariables() Assert.Equal("RI_REDIS_ALIAS3", item.Key); Assert.Equal(redis3.Resource.Name, item.Value); }); - } [Fact] @@ -713,7 +731,13 @@ public async Task RedisInsightEnvironmentCallbackIsIdempotent() using var appBuilder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); var redis = appBuilder.AddRedis("redis") - .WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6379)) + .WithEndpoint("tcp", e => + { + e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6379); + var snapshot = new ValueSnapshot(); + snapshot.SetValue(new AllocatedEndpoint(e, "redis.dev.internal", 6379, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + }) .WithRedisInsight(); using var app = appBuilder.Build(); diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 8840c964dd1..8ccc1004794 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -1000,7 +1000,7 @@ public async Task EndpointPortsConainerProxiedNoPortTargetPortSet() } [Fact] - public async Task EndpointPortsConainerProxiedPortAndTargetPortSet() + public async Task EndpointPortsContainerProxiedPortAndTargetPortSet() { var builder = DistributedApplication.CreateBuilder(); @@ -2175,6 +2175,105 @@ public async Task ProjectExecutable_NoSupportsDebuggingAnnotation_RunsInProcessM Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType); } + [Theory] + [InlineData(true, null, "aspire.dev.internal")] + [InlineData(false, null, "host.docker.internal")] + [InlineData(true, "super.star", "aspire.dev.internal")] + [InlineData(false, "mega.mushroom", "mega.mushroom")] + public async Task EndpointsAllocatedCorrectly(bool useTunnel, string? containerHostName, string expectedContainerHost) + { + var builder = DistributedApplication.CreateBuilder(); + var executable = builder.AddExecutable("anExecutable", "command", "") + .WithEndpoint(name: "proxied", targetPort: 1234, port: 5678, isProxied: true) + .WithEndpoint(name: "notProxied", port: 8765, isProxied: false); + + var container = builder.AddContainer("aContainer", "image") + .WithEndpoint(name: "proxied", port: 15678, targetPort: 11234, isProxied: true) + .WithEndpoint(name: "notProxied", port: 18765, isProxied: false); + + var containerWithAlias = builder.AddContainer("containerWithAlias", "image") + .WithEndpoint(name: "proxied", port: 25678, targetPort: 21234, isProxied: true) + .WithEndpoint(name: "notProxied", port: 28765, isProxied: false) + .WithContainerNetworkAlias("custom.alias"); + + var kubernetesService = new TestKubernetesService(); + using var app = builder.Build(); + var distributedAppModel = app.Services.GetRequiredService(); + + var configDict = new Dictionary + { + ["AppHost:ContainerHostname"] = containerHostName + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configDict).Build(); + + var dcpOptions = new DcpOptions + { + EnableAspireContainerTunnel = useTunnel, + }; + + var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, configuration: configuration, dcpOptions: dcpOptions); + + await appExecutor.RunApplicationAsync(); + + await AssertEndpoint(executable.Resource, "proxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 5678); + await AssertEndpoint(executable.Resource, "notProxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 8765); + + if (useTunnel) + { + await AssertTunneledPort(executable.Resource, "proxied"); + await AssertTunneledPort(executable.Resource, "notProxied"); + + async ValueTask AssertTunneledPort(IResourceWithEndpoints resource, string endpointName) + { + var svcs = kubernetesService.CreatedResources + .OfType() + .Where(x => x.AppModelResourceName == resource.Name + && x.EndpointName == endpointName + && x.Metadata.Annotations.ContainsKey(CustomResource.ContainerTunnelInstanceName)) + .ToList(); + + var svc = svcs.Single(); + + int port = svc.AllocatedPort!.Value; + await AssertEndpoint(executable.Resource, endpointName, KnownNetworkIdentifiers.DefaultAspireContainerNetwork, expectedContainerHost, port); + } + } + else + { + await AssertEndpoint(executable.Resource, "proxied", KnownNetworkIdentifiers.DefaultAspireContainerNetwork, expectedContainerHost, 5678); + await AssertEndpoint(executable.Resource, "notProxied", KnownNetworkIdentifiers.DefaultAspireContainerNetwork, expectedContainerHost, 8765); + } + + await AssertEndpoint(container.Resource, "proxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 15678); + await AssertEndpoint(container.Resource, "notProxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 18765); + + await AssertEndpoint(container.Resource, "proxied", KnownNetworkIdentifiers.DefaultAspireContainerNetwork, $"{container.Resource.Name}.dev.internal", 11234); + await AssertEndpoint(container.Resource, "notProxied", KnownNetworkIdentifiers.DefaultAspireContainerNetwork, $"{container.Resource.Name}.dev.internal", 18765); + + await AssertEndpoint(containerWithAlias.Resource, "proxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 25678); + await AssertEndpoint(containerWithAlias.Resource, "notProxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 28765); + + await AssertEndpoint(containerWithAlias.Resource, "proxied", KnownNetworkIdentifiers.DefaultAspireContainerNetwork, $"{containerWithAlias.Resource.Name}.dev.internal", 21234); + await AssertEndpoint(containerWithAlias.Resource, "notProxied", KnownNetworkIdentifiers.DefaultAspireContainerNetwork, $"{containerWithAlias.Resource.Name}.dev.internal", 28765); + + async ValueTask AssertEndpoint(IResourceWithEndpoints resource, string name, NetworkIdentifier network, string address, int port) + { + var endpoint = resource.GetEndpoint(name).EndpointAnnotation; + var allocatedEndpoints = endpoint.AllAllocatedEndpoints; + + Assert.Contains(allocatedEndpoints, a => a.NetworkID == network); + + var allocatedEndpoint = await endpoint.AllAllocatedEndpoints.Single(x => x.NetworkID == network).Snapshot.GetValueAsync().DefaultTimeout(); + + Assert.Equal(endpoint, allocatedEndpoint.Endpoint); + Assert.Equal(address, allocatedEndpoint.Address); + Assert.Equal(EndpointBindingMode.SingleAddress, allocatedEndpoint.BindingMode); + Assert.Equal(port, allocatedEndpoint.Port); + Assert.Equal(endpoint.UriScheme, allocatedEndpoint.UriScheme); + Assert.Equal($"{address}:{port}", allocatedEndpoint.EndPointString); + } + } + private static void HasKnownCommandAnnotations(IResource resource) { var commandAnnotations = resource.Annotations.OfType().ToList(); diff --git a/tests/Aspire.Hosting.Tests/EndpointReferenceTests.cs b/tests/Aspire.Hosting.Tests/EndpointReferenceTests.cs index f7fd6771288..11e3e3d7de6 100644 --- a/tests/Aspire.Hosting.Tests/EndpointReferenceTests.cs +++ b/tests/Aspire.Hosting.Tests/EndpointReferenceTests.cs @@ -285,6 +285,90 @@ public void TargetPort_ReturnsNullWhenNotDefined() Assert.Null(targetPort); } + [Theory] + [InlineData(EndpointProperty.Url, ResourceKind.Host, ResourceKind.Host, "blah://localhost:1234")] + [InlineData(EndpointProperty.Url, ResourceKind.Host, ResourceKind.Container, "blah://localhost:1234")] + [InlineData(EndpointProperty.Url, ResourceKind.Container, ResourceKind.Host, "blah://host.docker.internal:1234")] + [InlineData(EndpointProperty.Url, ResourceKind.Container, ResourceKind.Container, "blah://destination.dev.internal:4567")] + [InlineData(EndpointProperty.Host, ResourceKind.Host, ResourceKind.Host, "localhost")] + [InlineData(EndpointProperty.Host, ResourceKind.Host, ResourceKind.Container, "localhost")] + [InlineData(EndpointProperty.Host, ResourceKind.Container, ResourceKind.Host, "host.docker.internal")] + [InlineData(EndpointProperty.Host, ResourceKind.Container, ResourceKind.Container, "destination.dev.internal")] + [InlineData(EndpointProperty.IPV4Host, ResourceKind.Host, ResourceKind.Host, "127.0.0.1")] + [InlineData(EndpointProperty.IPV4Host, ResourceKind.Host, ResourceKind.Container, "127.0.0.1")] + [InlineData(EndpointProperty.IPV4Host, ResourceKind.Container, ResourceKind.Host, "host.docker.internal")] + [InlineData(EndpointProperty.IPV4Host, ResourceKind.Container, ResourceKind.Container, "destination.dev.internal")] + [InlineData(EndpointProperty.Port, ResourceKind.Host, ResourceKind.Host, "1234")] + [InlineData(EndpointProperty.Port, ResourceKind.Host, ResourceKind.Container, "1234")] + [InlineData(EndpointProperty.Port, ResourceKind.Container, ResourceKind.Host, "1234")] + [InlineData(EndpointProperty.Port, ResourceKind.Container, ResourceKind.Container, "4567")] + [InlineData(EndpointProperty.Scheme, ResourceKind.Host, ResourceKind.Host, "blah")] + [InlineData(EndpointProperty.Scheme, ResourceKind.Host, ResourceKind.Container, "blah")] + [InlineData(EndpointProperty.Scheme, ResourceKind.Container, ResourceKind.Host, "blah")] + [InlineData(EndpointProperty.Scheme, ResourceKind.Container, ResourceKind.Container, "blah")] + [InlineData(EndpointProperty.HostAndPort, ResourceKind.Host, ResourceKind.Host, "localhost:1234")] + [InlineData(EndpointProperty.HostAndPort, ResourceKind.Host, ResourceKind.Container, "localhost:1234")] + [InlineData(EndpointProperty.HostAndPort, ResourceKind.Container, ResourceKind.Host, "host.docker.internal:1234")] + [InlineData(EndpointProperty.HostAndPort, ResourceKind.Container, ResourceKind.Container, "destination.dev.internal:4567")] + public async Task PropertyResolutionTest(EndpointProperty property, ResourceKind sourceKind, ResourceKind destinationKind, object expectedResult) + { + int port = 1234; + int targetPort = 4567; + + var source = CreateResource("caller", sourceKind); + var destination = CreateResource("destination", destinationKind); + + var network = source.GetDefaultResourceNetwork(); + + // This logic is tightly coupled to how `DcpExecutor` allocates endpoints + var annotation = new EndpointAnnotation(ProtocolType.Tcp, uriScheme: "blah", name: "http"); + annotation.AllocatedEndpoint = new(annotation, "localhost", port); + destination.Annotations.Add(annotation); + + (string containerHost, int containerPort) = destination.IsContainer() + ? ("destination.dev.internal", targetPort) + : ("host.docker.internal", port); + + var containerEndpoint = new AllocatedEndpoint(annotation, containerHost, containerPort, EndpointBindingMode.SingleAddress, targetPortExpression: targetPort.ToString(), KnownNetworkIdentifiers.DefaultAspireContainerNetwork); + var snapshot = new ValueSnapshot(); + snapshot.SetValue(containerEndpoint); + annotation.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + + var expression = destination.GetEndpoint(annotation.Name).Property(property); + + var resultFromCaller = await expression.GetValueAsync(new ValueProviderContext + { + Caller = source + }); + Assert.Equal(expectedResult, resultFromCaller); + + var resultFromNetwork = await expression.GetValueAsync(new ValueProviderContext + { + Network = network + }); + Assert.Equal(expectedResult, resultFromNetwork); + + static IResourceWithEndpoints CreateResource(string name, ResourceKind kind) + { + if (kind == ResourceKind.Container) + { + var resource = new TestResource(name); + resource.Annotations.Add(new ContainerImageAnnotation { Image = "test-image" }); + return resource; + } + else + { + return new TestResource(name); + } + } + } + + public enum ResourceKind + { + Host, + Container + } + private sealed class TestResource(string name) : Resource(name), IResourceWithEndpoints { } diff --git a/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs b/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs index 88bbcdd48fd..429757d98a8 100644 --- a/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs +++ b/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs @@ -84,6 +84,10 @@ public async Task ExpressionResolverGeneratesCorrectEndpointStrings(string exprN { var builder = DistributedApplication.CreateBuilder(); + var containerHost = targetIsContainer + ? "testresource.dev.internal" + : KnownHostNames.DefaultContainerTunnelHostName; + var target = builder.AddResource(new TestExpressionResolverResource(exprName)) .WithEndpoint("endpoint1", e => { @@ -92,31 +96,31 @@ public async Task ExpressionResolverGeneratesCorrectEndpointStrings(string exprN if (sourceIsContainer) { // Note: on the container network side the port and target port are always the same for AllocatedEndpoint. - var ae = new AllocatedEndpoint(e, KnownHostNames.DefaultContainerTunnelHostName, 22345, EndpointBindingMode.SingleAddress, targetPortExpression: "22345", KnownNetworkIdentifiers.DefaultAspireContainerNetwork); + var ae = new AllocatedEndpoint(e, containerHost, 22345, EndpointBindingMode.SingleAddress, targetPortExpression: "22345", KnownNetworkIdentifiers.DefaultAspireContainerNetwork); var snapshot = new ValueSnapshot(); snapshot.SetValue(ae); e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); } }) .WithEndpoint("endpoint2", e => - { - e.UriScheme = "https"; - e.AllocatedEndpoint = new(e, "localhost", 12346, targetPortExpression: "10001"); - if (sourceIsContainer) - { - var ae = new AllocatedEndpoint(e, KnownHostNames.DefaultContainerTunnelHostName, 22346, EndpointBindingMode.SingleAddress, targetPortExpression: "22346", KnownNetworkIdentifiers.DefaultAspireContainerNetwork); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(ae); - e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); - } - }) + { + e.UriScheme = "https"; + e.AllocatedEndpoint = new(e, "localhost", 12346, targetPortExpression: "10001"); + if (sourceIsContainer) + { + var ae = new AllocatedEndpoint(e, containerHost, 22346, EndpointBindingMode.SingleAddress, targetPortExpression: "22346", KnownNetworkIdentifiers.DefaultAspireContainerNetwork); + var snapshot = new ValueSnapshot(); + snapshot.SetValue(ae); + e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + } + }) .WithEndpoint("endpoint3", e => { e.UriScheme = "https"; e.AllocatedEndpoint = new(e, "host with space", 12347); if (sourceIsContainer) { - var ae = new AllocatedEndpoint(e, KnownHostNames.DefaultContainerTunnelHostName, 22347, EndpointBindingMode.SingleAddress, targetPortExpression: "22346", KnownNetworkIdentifiers.DefaultAspireContainerNetwork); + var ae = new AllocatedEndpoint(e, containerHost, 22347, EndpointBindingMode.SingleAddress, targetPortExpression: "22346", KnownNetworkIdentifiers.DefaultAspireContainerNetwork); var snapshot = new ValueSnapshot(); snapshot.SetValue(ae); e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); @@ -217,10 +221,18 @@ public async Task ContainerToContainerEndpointShouldResolve() { var builder = DistributedApplication.CreateBuilder(); + var endpoint = new EndpointAnnotation(System.Net.Sockets.ProtocolType.Tcp, KnownNetworkIdentifiers.DefaultAspireContainerNetwork) + { + Name = "http", + UriScheme = "http", + Port = 8001, + TargetPort = 8080, + }; + endpoint.AllocatedEndpoint = new(endpoint, "myContainer.dev.internal", (int)endpoint.TargetPort, EndpointBindingMode.SingleAddress, "{{ targetPort }}"); + var connectionStringResource = builder.AddResource(new MyContainerResource("myContainer")) .WithImage("redis") - .WithHttpEndpoint(port: 8001, targetPort: 8080) - .WithEndpoint("http", ep => ep.AllocatedEndpoint = new(ep, "localhost", 8001, EndpointBindingMode.SingleAddress, "{{ targetPort }}", KnownNetworkIdentifiers.LocalhostNetwork)); + .WithAnnotation(endpoint); var dep = builder.AddContainer("container", "redis") .WithReference(connectionStringResource) diff --git a/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs b/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs index dd40af66a63..c6b9b0a1070 100644 --- a/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs +++ b/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs @@ -227,7 +227,12 @@ public async Task EnvironmentVariableExpressions() .WithHttpEndpoint(name: "primary", targetPort: 10005) .WithEndpoint("primary", ep => { - ep.AllocatedEndpoint = new AllocatedEndpoint(ep, "localhost", 90); + ep.AllocatedEndpoint = new AllocatedEndpoint(ep, "localhost", 17454); + + var ae = new AllocatedEndpoint(ep, "container1.dev.internal", 10005, EndpointBindingMode.SingleAddress, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork); + var snapshot = new ValueSnapshot(); + snapshot.SetValue(ae); + ep.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); }); var endpoint = container.GetEndpoint("primary"); From ad038ce2403962dc9a7867f222dec307b7ff67eb Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 23:32:16 +0000 Subject: [PATCH 034/256] [main] Update dependencies from microsoft/dcp (#14323) * Update dependencies from https://github.com/microsoft/dcp build 0.22.3 On relative base path root Microsoft.DeveloperControlPlane.darwin-amd64 , Microsoft.DeveloperControlPlane.darwin-arm64 , Microsoft.DeveloperControlPlane.linux-amd64 , Microsoft.DeveloperControlPlane.linux-arm64 , Microsoft.DeveloperControlPlane.linux-musl-amd64 , Microsoft.DeveloperControlPlane.windows-amd64 , Microsoft.DeveloperControlPlane.windows-arm64 From Version 0.22.2 -> To Version 0.22.3 * Quarantine the tunnel test --------- Co-authored-by: dotnet-maestro[bot] Co-authored-by: Karol Zadora-Przylecki --- eng/Version.Details.xml | 28 +++++++++---------- eng/Versions.props | 14 +++++----- .../ContainerTunnelTests.cs | 1 + 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 2b5b136f5a0..5821c84095a 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,33 +1,33 @@ - + https://github.com/microsoft/dcp - 4808f6b49ebf5feeffe79034cf2b140f5d7c548c + cf09d5dd3b0c3229f220944f0391e857dab0049b - + https://github.com/microsoft/dcp - 4808f6b49ebf5feeffe79034cf2b140f5d7c548c + cf09d5dd3b0c3229f220944f0391e857dab0049b - + https://github.com/microsoft/dcp - 4808f6b49ebf5feeffe79034cf2b140f5d7c548c + cf09d5dd3b0c3229f220944f0391e857dab0049b - + https://github.com/microsoft/dcp - 4808f6b49ebf5feeffe79034cf2b140f5d7c548c + cf09d5dd3b0c3229f220944f0391e857dab0049b - + https://github.com/microsoft/dcp - 4808f6b49ebf5feeffe79034cf2b140f5d7c548c + cf09d5dd3b0c3229f220944f0391e857dab0049b - + https://github.com/microsoft/dcp - 4808f6b49ebf5feeffe79034cf2b140f5d7c548c + cf09d5dd3b0c3229f220944f0391e857dab0049b - + https://github.com/microsoft/dcp - 4808f6b49ebf5feeffe79034cf2b140f5d7c548c + cf09d5dd3b0c3229f220944f0391e857dab0049b https://dev.azure.com/dnceng/internal/_git/dotnet-extensions diff --git a/eng/Versions.props b/eng/Versions.props index e99c5e131df..a4816ceb51a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -28,13 +28,13 @@ 8.0.100-rtm.23512.16 - 0.22.2 - 0.22.2 - 0.22.2 - 0.22.2 - 0.22.2 - 0.22.2 - 0.22.2 + 0.22.3 + 0.22.3 + 0.22.3 + 0.22.3 + 0.22.3 + 0.22.3 + 0.22.3 11.0.0-beta.25610.3 11.0.0-beta.25610.3 diff --git a/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs b/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs index b35b2459fb0..2e8de82ccf0 100644 --- a/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs +++ b/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs @@ -13,6 +13,7 @@ public class ContainerTunnelTests(ITestOutputHelper testOutputHelper) { [Fact] [RequiresFeature(TestFeature.Docker)] + [QuarantinedTest("https://github.com/dotnet/aspire/issues/14325")] public async Task ContainerTunnelWorksWithYarp() { const string testName = "container-tunnel-works-with-yarp"; From 797eabf33db04379db724d0d6329153350063fae Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 3 Feb 2026 21:13:22 -0800 Subject: [PATCH 035/256] CLI fixes: MCP error message and help display (#14328) * Update MCP error to suggest 'aspire run --detach' * Add test assertions for --detach in MCP error message * Show help when aspire is invoked without arguments * Return InvalidCommand exit code for consistency with other parent commands * Add tests for parent command exit codes to prevent regression --- src/Aspire.Cli/Commands/RootCommand.cs | 16 ++++++-- src/Aspire.Cli/Mcp/McpErrorMessages.cs | 2 +- .../Commands/CacheCommandTests.cs | 41 +++++++++++++++++++ .../Commands/McpCommandTests.cs | 30 ++++++++++++++ .../Commands/SdkCommandTests.cs | 41 +++++++++++++++++++ .../Mcp/ExecuteResourceCommandToolTests.cs | 1 + .../Mcp/ListConsoleLogsToolTests.cs | 1 + .../Mcp/ListResourcesToolTests.cs | 1 + 8 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Commands/CacheCommandTests.cs create mode 100644 tests/Aspire.Cli.Tests/Commands/SdkCommandTests.cs diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 4786e3f67ed..2d03b6e6920 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.CommandLine.Help; #if DEBUG using System.Globalization; @@ -121,13 +122,20 @@ public RootCommand( Options.Add(WaitForDebuggerOption); Options.Add(CliWaitForDebuggerOption); - // Handle standalone 'aspire --banner' (no subcommand) + // Handle standalone 'aspire' or 'aspire --banner' (no subcommand) this.SetAction((context, cancellationToken) => { var bannerRequested = context.GetValue(BannerOption); - // If --banner was passed, we've already shown it in Main, just exit successfully - // Otherwise, show the standard "no command" error - return Task.FromResult(bannerRequested ? 0 : 1); + if (bannerRequested) + { + // If --banner was passed, we've already shown it in Main, just exit successfully + return Task.FromResult(ExitCodeConstants.Success); + } + + // No subcommand provided - show help but return InvalidCommand to signal usage error + // This is consistent with other parent commands (DocsCommand, SdkCommand, etc.) + new HelpAction().Invoke(context); + return Task.FromResult(ExitCodeConstants.InvalidCommand); }); Subcommands.Add(newCommand); diff --git a/src/Aspire.Cli/Mcp/McpErrorMessages.cs b/src/Aspire.Cli/Mcp/McpErrorMessages.cs index 3f722db771a..e43d1cba90a 100644 --- a/src/Aspire.Cli/Mcp/McpErrorMessages.cs +++ b/src/Aspire.Cli/Mcp/McpErrorMessages.cs @@ -13,7 +13,7 @@ internal static class McpErrorMessages /// public const string NoAppHostRunning = "No Aspire AppHost is currently running. " + - "To use Aspire MCP tools, you must first start an Aspire application by running 'aspire run' in your AppHost project directory. " + + "To use Aspire MCP tools, you must first start an Aspire application by running 'aspire run --detach' in your AppHost project directory. " + "Once the application is running, the MCP tools will be able to connect to the dashboard and execute commands."; /// diff --git a/tests/Aspire.Cli.Tests/Commands/CacheCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/CacheCommandTests.cs new file mode 100644 index 00000000000..53929cf7da7 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/CacheCommandTests.cs @@ -0,0 +1,41 @@ +// 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.Commands; +using Aspire.Cli.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class CacheCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task CacheCommand_WithoutSubcommand_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("cache"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public async Task CacheCommandWithHelpArgumentReturnsZero() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("cache --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs index ee4354cd54a..61a8dd0397d 100644 --- a/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/McpCommandTests.cs @@ -10,6 +10,21 @@ namespace Aspire.Cli.Tests.Commands; public class McpCommandTests(ITestOutputHelper outputHelper) { + [Fact] + public async Task McpCommand_WithoutSubcommand_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("mcp"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + [Fact] public async Task McpCommandWithHelpArgumentReturnsZero() { @@ -82,6 +97,21 @@ public async Task McpCommandIsHidden() Assert.True(mcpCommand.Hidden, "The mcp command should be hidden for backward compatibility"); } + [Fact] + public async Task AgentCommand_WithoutSubcommand_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("agent"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + [Fact] public async Task AgentCommandWithHelpArgumentReturnsZero() { diff --git a/tests/Aspire.Cli.Tests/Commands/SdkCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/SdkCommandTests.cs new file mode 100644 index 00000000000..e39d7afb833 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/SdkCommandTests.cs @@ -0,0 +1,41 @@ +// 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.Commands; +using Aspire.Cli.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class SdkCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task SdkCommand_WithoutSubcommand_ReturnsInvalidCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("sdk"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public async Task SdkCommandWithHelpArgumentReturnsZero() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("sdk --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs index d941afded2c..a83f51ff414 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs @@ -34,6 +34,7 @@ public async Task ExecuteResourceCommandTool_ThrowsException_WhenNoAppHostRunnin () => tool.CallToolAsync(null!, CreateArguments("test-resource", "resource-start"), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("No Aspire AppHost", exception.Message); + Assert.Contains("--detach", exception.Message); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs index 0cd6eba5427..7f7b23bf0cb 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs @@ -28,6 +28,7 @@ public async Task ListConsoleLogsTool_ThrowsException_WhenNoAppHostRunning() () => tool.CallToolAsync(null!, arguments, CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("No Aspire AppHost", exception.Message); + Assert.Contains("--detach", exception.Message); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs index 3dffdbf757f..26384f0af29 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs @@ -22,6 +22,7 @@ public async Task ListResourcesTool_ThrowsException_WhenNoAppHostRunning() () => tool.CallToolAsync(null!, null, CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("No Aspire AppHost", exception.Message); + Assert.Contains("--detach", exception.Message); } [Fact] From 236a444fcaaf521801b6b2768e4b4a881c60a19e Mon Sep 17 00:00:00 2001 From: Shilpi Rachna Date: Tue, 3 Feb 2026 21:17:07 -0800 Subject: [PATCH 036/256] Updated AppService as stable for Aspire 13.2. (#14316) Co-authored-by: Shilpi Rachna --- .../Aspire.Hosting.Azure.AppService.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 a3530c516bc..4b84efde8dd 100644 --- a/src/Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AppService.csproj +++ b/src/Aspire.Hosting.Azure.AppService/Aspire.Hosting.Azure.AppService.csproj @@ -6,7 +6,8 @@ aspire integration hosting azure cloud appservice Azure app service resource types for Aspire. $(SharedDir)Azure_256x.png - true + + false From 5cefb4354cee86a0a55330813f2f8af644fa08c5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:53:57 -0600 Subject: [PATCH 037/256] Change GetHostAddressExpression from explicit interface implementation to public method (#14319) * Initial plan * Change GetHostAddressExpression implementations to public methods Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> * Add Experimental attribute to GetHostAddressExpression methods Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --- .../AzureContainerAppEnvironmentResource.cs | 5 ++++- .../AzureAppServiceEnvironmentResource.cs | 5 ++++- .../DockerComposeEnvironmentResource.cs | 10 ++++------ .../KubernetesEnvironmentResource.cs | 10 ++++------ .../Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs | 2 +- .../AzureContainerAppsTests.cs | 2 +- .../Aspire.Hosting.Docker.Tests/DockerComposeTests.cs | 2 +- .../KubernetesEnvironmentResourceTests.cs | 2 +- 8 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs index ccd76135e29..e3c48d0e18b 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs @@ -4,6 +4,7 @@ #pragma warning disable ASPIREPIPELINES001 #pragma warning disable ASPIREAZURE001 +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Pipelines; using Azure.Provisioning; @@ -231,7 +232,9 @@ public AzureContainerRegistryResource? ContainerRegistry ReferenceExpression IAzureContainerRegistry.ManagedIdentityId => ReferenceExpression.Create($"{ContainerRegistryManagedIdentityId}"); #pragma warning restore CS0618 // Type or member is obsolete - ReferenceExpression IComputeEnvironmentResource.GetHostAddressExpression(EndpointReference endpointReference) + /// + [Experimental("ASPIRECOMPUTE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public ReferenceExpression GetHostAddressExpression(EndpointReference endpointReference) { var resource = endpointReference.Resource; diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs index 16c815a74f1..59a433772f6 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs @@ -4,6 +4,7 @@ #pragma warning disable ASPIREPIPELINES001 #pragma warning disable ASPIREAZURE001 +using System.Diagnostics.CodeAnalysis; using System.Text; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure.AppService; @@ -381,7 +382,9 @@ public AzureContainerRegistryResource? ContainerRegistry ReferenceExpression IAzureContainerRegistry.ManagedIdentityId => ReferenceExpression.Create($"{ContainerRegistryManagedIdentityId}"); #pragma warning restore CS0618 // Type or member is obsolete - ReferenceExpression IComputeEnvironmentResource.GetHostAddressExpression(EndpointReference endpointReference) + /// + [Experimental("ASPIRECOMPUTE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public ReferenceExpression GetHostAddressExpression(EndpointReference endpointReference) { var resource = endpointReference.Resource; return ReferenceExpression.Create($"{resource.Name.ToLowerInvariant()}-{WebSiteSuffix}.azurewebsites.net"); diff --git a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs index 4f7fa6dd05a..06ec7223398 100644 --- a/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs +++ b/src/Aspire.Hosting.Docker/DockerComposeEnvironmentResource.cs @@ -4,6 +4,7 @@ #pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable ASPIREPIPELINES003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp.Process; using Aspire.Hosting.Docker.Resources; @@ -185,12 +186,9 @@ public DockerComposeEnvironmentResource(string name) : base(name) })); } - /// - /// Computes the host URL for the given . - /// - /// The endpoint reference to compute the host address for. - /// A representing the host address. - ReferenceExpression IComputeEnvironmentResource.GetHostAddressExpression(EndpointReference endpointReference) + /// + [Experimental("ASPIRECOMPUTE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public ReferenceExpression GetHostAddressExpression(EndpointReference endpointReference) { var resource = endpointReference.Resource; diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs index 36871ca8d96..edb0357720f 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Kubernetes.Extensions; using Aspire.Hosting.Pipelines; @@ -99,12 +100,9 @@ public KubernetesEnvironmentResource(string name) : base(name) })); } - /// - /// Computes the host URL for the given . - /// - /// The endpoint reference to compute the host address for. - /// A representing the host address. - ReferenceExpression IComputeEnvironmentResource.GetHostAddressExpression(EndpointReference endpointReference) + /// + [Experimental("ASPIRECOMPUTE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public ReferenceExpression GetHostAddressExpression(EndpointReference endpointReference) { var resource = endpointReference.Resource; diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs index 23fc6d8c489..67041919ce8 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureAppServiceTests.cs @@ -754,7 +754,7 @@ public async Task GetHostAddressExpression() .AddProject("project1", launchProfileName: null) .WithHttpEndpoint(); - var endpointReferenceEx = ((IComputeEnvironmentResource)env.Resource).GetHostAddressExpression(project.GetEndpoint("http")); + var endpointReferenceEx = env.Resource.GetHostAddressExpression(project.GetEndpoint("http")); Assert.NotNull(endpointReferenceEx); Assert.Equal("project1-{0}.azurewebsites.net", endpointReferenceEx.Format); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index a0fe44a8a1a..2ffe5d25d2f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -2099,7 +2099,7 @@ public async Task GetHostAddressExpression() .AddProject("project1", launchProfileName: null) .WithHttpEndpoint(); - var endpointReferenceEx = ((IComputeEnvironmentResource)env.Resource).GetHostAddressExpression(project.GetEndpoint("http")); + var endpointReferenceEx = env.Resource.GetHostAddressExpression(project.GetEndpoint("http")); Assert.NotNull(endpointReferenceEx); Assert.Equal("project1.internal.{0}", endpointReferenceEx.Format); diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs index b144728cc69..15034bb96c0 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs @@ -221,7 +221,7 @@ public async Task GetHostAddressExpression() .AddProject("Project1", launchProfileName: null) .WithHttpEndpoint(); - var endpointReferenceEx = ((IComputeEnvironmentResource)env.Resource).GetHostAddressExpression(project.GetEndpoint("http")); + var endpointReferenceEx = env.Resource.GetHostAddressExpression(project.GetEndpoint("http")); Assert.NotNull(endpointReferenceEx); Assert.Equal("project1", endpointReferenceEx.Format); diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesEnvironmentResourceTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesEnvironmentResourceTests.cs index 7d061268233..68b23573eca 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesEnvironmentResourceTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesEnvironmentResourceTests.cs @@ -105,7 +105,7 @@ public async Task GetHostAddressExpression() .AddProject("project1", launchProfileName: null) .WithHttpEndpoint(); - var endpointReferenceEx = ((IComputeEnvironmentResource)env.Resource).GetHostAddressExpression(project.GetEndpoint("http")); + var endpointReferenceEx = env.Resource.GetHostAddressExpression(project.GetEndpoint("http")); Assert.NotNull(endpointReferenceEx); Assert.Equal("project1-service", endpointReferenceEx.Format); From d74b9502cdcbcb61ed8b46405db611b1154c9c52 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 5 Feb 2026 03:38:03 +1100 Subject: [PATCH 038/256] Add E2E deployment test for React + ASP.NET Core to Azure App Service (#14326) * Add E2E deployment test for React + ASP.NET Core to Azure App Service This adds a new deployment test that: - Creates a project using the Starter App (ASP.NET Core/React) template - Deploys to Azure App Service (instead of Container Apps) - Verifies the deployed endpoints are accessible The test follows the established patterns from AcaStarterDeploymentTests and PythonFastApiDeploymentTests. * Fix prompt sequence for React template The React template (aspire-ts-cs-starter) has different prompts than the Blazor starter template: - Does NOT have a 'test project' prompt - Output path prompt includes the default path Updated to match the working JsReactTemplateTests pattern. * Increase deployment timeouts to 30 min and fix cleanup workflow - Increase pipeline wait timeout from 10-20 min to 30 min across all deployment tests - Increase overall test timeout to 40 min to accommodate longer deployments - Fix cleanup workflow to target both e2e-* (current) and rg-aspire-* (legacy) prefixes * Add endpoint verification retry logic to deployment tests Each endpoint is now retried up to 18 times (10 second intervals) for a total of ~3 minutes before failing. This handles cases where deployed apps need time to become responsive after deployment completes. * Fix deployment test command to link to actual workflow run Move the 'starting' comment from deployment-test-command.yml to deployment-tests.yml where it has direct access to github.run_id. The notify-start job only runs when pr_number is provided, matching the existing post_pr_comment job pattern. * Add Azure quota requirements to deployment test README Document the required Azure subscription quotas for running deployment E2E tests, including Container Apps managed environments (150+) and App Service PremiumV3 vCPUs (10+). Also updated timeout documentation and cleanup commands. * Add Python FastAPI to Azure App Service deployment test New test case AppServicePythonDeploymentTests that deploys the FastAPI/React template to Azure App Service instead of Container Apps. This mirrors PythonFastApiDeploymentTests but targets App Service. * Enhance PR comment to show passed/failed tests separately - Query individual matrix job results to determine per-test outcomes - Display passed, failed, and cancelled tests in separate sections - Show summary counts (X passed, Y failed, Z cancelled) - Include recordings only for tests that have them * Add Azure resource deployment E2E tests (Phase 1) Add 7 new E2E deployment tests for Azure resources using aspire init: Simple/Low-cost resources: - AzureStorageDeploymentTests - Azure Storage Account - AzureKeyVaultDeploymentTests - Azure Key Vault - AzureAppConfigDeploymentTests - Azure App Configuration - AzureLogAnalyticsDeploymentTests - Azure Log Analytics Workspace - AzureContainerRegistryDeploymentTests - Azure Container Registry Messaging resources: - AzureServiceBusDeploymentTests - Azure Service Bus - AzureEventHubsDeploymentTests - Azure Event Hubs Each test: 1. Uses 'aspire init' to create a single-file AppHost 2. Adds the Azure hosting package via 'aspire add' 3. Injects resource code into apphost.cs 4. Deploys to Azure using 'aspire deploy' 5. Verifies the resource was created 6. Cleans up the resource group * Fix duplicate heading in deployment tests README * Fix Azure location for deployment tests - use westus3 The subscription has quota limits in eastus (only 1 Container App environment). We have expanded quota in westus3, so change the workflow to deploy there. * Fix Azure resource tests to handle NuGet.config prompt The aspire init command now prompts for 'Create NuGet.config for selected channels?' before creating the AppHost. Updated all 7 Azure resource tests to: 1. Handle the new NuGet.config prompt (press Enter for Yes) 2. Remove language selection handling (only appears with polyglot enabled) * Fix aspire init pattern matching for CI environment In CI, aspire init: 1. Auto-creates NuGet.config without prompting (no interactive prompt) 2. Shows 'Aspire initialization complete' instead of 'Created apphost.cs' Updated all 7 Azure resource tests to: - Wait for 'Aspire initialization complete' pattern - Remove the NuGet.config prompt handling (not needed in CI) * Handle NuGet.config prompt that may or may not appear in CI The NuGet.config prompt behavior is inconsistent in CI - sometimes it appears interactively, sometimes it auto-accepts. Updated tests to: 1. Wait 5 seconds after running aspire init 2. Press Enter (dismisses prompt if present, no-op if auto-accepted) 3. Then wait for 'Aspire initialization complete' This handles both interactive and non-interactive cases. * Add Azure Container App Environment for role assignment support Azure resources that require role assignments (Storage, KeyVault, AppConfig, ServiceBus, EventHubs) need a managed identity principal. The Container App Environment provides this. Updated 5 failing tests to include: builder.AddAzureContainerAppEnvironment("env") This provides the principal required for provisioning role assignments. * Add Aspire.Hosting.Azure.ContainerApps package for CAE support AddAzureContainerAppEnvironment() requires the ContainerApps hosting package. Updated 5 tests to add this package before adding the specific Azure resource package. Flow is now: 1. aspire init 2. aspire add Aspire.Hosting.Azure.ContainerApps 3. aspire add Aspire.Hosting.Azure.{Resource} 4. modify apphost.cs to use AddAzureContainerAppEnvironment + AddAzure{Resource} * Fix aspire add integration selection prompt handling Add pattern searcher for 'Select an integration to add:' prompt and wait for it before pressing Enter to select the first match. This handles the fuzzy matching that shows multiple options. * Fix aspire add prompt handling - use combined pattern searcher aspire add may show either: - Integration selection prompt if multiple matches - Version selection prompt if unique match The combined searcher waits for either prompt type. * Fix aspire add to handle TWO prompts for ContainerApps aspire add Aspire.Hosting.Azure.ContainerApps triggers: 1. Integration selection prompt (matches multiple Azure packages) 2. Version selection prompt (in CI) Need to wait for and handle both prompts sequentially. * Address code review feedback - Use discard pattern (_) instead of unused variable for AddAzureContainerAppEnvironment - Update README.md Test Structure section to include new Azure resource test files * Skip App Service deployment tests due to infrastructure timeouts App Service provisioning takes longer than 30 minutes, causing test timeouts. Skipped until infrastructure issues are resolved. --------- Co-authored-by: Mitch Denny --- .github/workflows/deployment-cleanup.yml | 14 +- .github/workflows/deployment-test-command.yml | 15 +- .github/workflows/deployment-tests.yml | 163 +++++++-- .../AcaStarterDeploymentTests.cs | 22 +- .../AppServicePythonDeploymentTests.cs | 306 +++++++++++++++++ .../AppServiceReactDeploymentTests.cs | 325 ++++++++++++++++++ .../AzureAppConfigDeploymentTests.cs | 269 +++++++++++++++ .../AzureContainerRegistryDeploymentTests.cs | 238 +++++++++++++ .../AzureEventHubsDeploymentTests.cs | 269 +++++++++++++++ .../AzureKeyVaultDeploymentTests.cs | 269 +++++++++++++++ .../AzureLogAnalyticsDeploymentTests.cs | 238 +++++++++++++ .../AzureServiceBusDeploymentTests.cs | 269 +++++++++++++++ .../AzureStorageDeploymentTests.cs | 274 +++++++++++++++ .../PythonFastApiDeploymentTests.cs | 21 +- .../README.md | 76 +++- 15 files changed, 2695 insertions(+), 73 deletions(-) create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs diff --git a/.github/workflows/deployment-cleanup.yml b/.github/workflows/deployment-cleanup.yml index edd0ae60274..08dc4f72429 100644 --- a/.github/workflows/deployment-cleanup.yml +++ b/.github/workflows/deployment-cleanup.yml @@ -1,9 +1,13 @@ # Cleanup workflow for deployment test resources # # Uses a mark-and-sweep approach: -# 1. Mark: Tag untagged rg-aspire-* RGs with 'deployment-test-first-seen' timestamp +# 1. Mark: Tag untagged deployment test RGs with 'deployment-test-first-seen' timestamp # 2. Sweep: Delete RGs where 'deployment-test-first-seen' is older than threshold # +# Targets resource groups with prefixes: +# - rg-aspire-* (legacy naming) +# - e2e-* (current naming) +# # This ensures RGs are only deleted after they've been seen for the full retention period. # name: Deployment Environment Cleanup @@ -82,11 +86,13 @@ jobs: NOW_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ) MAX_AGE_SECONDS=$((MAX_AGE_HOURS * 3600)) - # List all resource groups matching rg-aspire-* prefix - RG_LIST=$(az group list --query "[?starts_with(name, 'rg-aspire-')].name" -o tsv || echo "") + # List all resource groups matching deployment test prefixes + # Current naming: e2e-* (e.g., e2e-starter-12345678-1) + # Legacy naming: rg-aspire-* (may still exist from older tests) + RG_LIST=$(az group list --query "[?starts_with(name, 'e2e-') || starts_with(name, 'rg-aspire-')].name" -o tsv || echo "") if [ -z "$RG_LIST" ]; then - echo "No resource groups found matching 'rg-aspire-*' prefix." + echo "No resource groups found matching deployment test prefixes (e2e-* or rg-aspire-*)." echo "✅ No resource groups found." >> $GITHUB_STEP_SUMMARY echo "marked_count=0" >> $GITHUB_OUTPUT echo "deleted_count=0" >> $GITHUB_OUTPUT diff --git a/.github/workflows/deployment-test-command.yml b/.github/workflows/deployment-test-command.yml index 2e860d6a7a0..3f5f13be858 100644 --- a/.github/workflows/deployment-test-command.yml +++ b/.github/workflows/deployment-test-command.yml @@ -80,20 +80,6 @@ jobs: core.setOutput('head_sha', pr.head.sha); core.setOutput('head_ref', pr.head.ref); - - name: Acknowledge command - if: steps.check_membership.outputs.is_member == 'true' - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - script: | - const prNumber = ${{ steps.pr.outputs.number }}; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `🚀 Starting deployment tests on PR #${prNumber}...\n\nThis will deploy to real Azure infrastructure. Results will be posted here when complete.\n\n[View workflow run](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/workflows/deployment-tests.yml)` - }); - - name: Trigger deployment tests if: steps.check_membership.outputs.is_member == 'true' uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 @@ -102,6 +88,7 @@ jobs: // Dispatch from the PR's head ref to test the PR's code changes. // Security: Org membership check is the security boundary - only trusted // dotnet org members can trigger this workflow. + // Note: The triggered workflow posts its own "starting" comment with the run URL. await github.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/deployment-tests.yml b/.github/workflows/deployment-tests.yml index e780e567389..ade7167cf17 100644 --- a/.github/workflows/deployment-tests.yml +++ b/.github/workflows/deployment-tests.yml @@ -31,6 +31,28 @@ concurrency: cancel-in-progress: true jobs: + # Post "starting" comment to PR when triggered via /deployment-test command + notify-start: + name: Notify PR + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'dotnet' && inputs.pr_number != '' }} + permissions: + pull-requests: write + steps: + - name: Post starting comment + env: + GH_TOKEN: ${{ github.token }} + run: | + PR_NUMBER="${{ inputs.pr_number }}" + RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + gh pr comment "${PR_NUMBER}" --repo "${{ github.repository }}" --body \ + "🚀 **Deployment tests starting** on PR #${PR_NUMBER}... + + This will deploy to real Azure infrastructure. Results will be posted here when complete. + + [View workflow run](${RUN_URL})" + # Enumerate test classes to build the matrix enumerate: name: Enumerate Tests @@ -214,7 +236,7 @@ jobs: AZURE_TENANT_ID: ${{ secrets.AZURE_DEPLOYMENT_TEST_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_DEPLOYMENT_TEST_CLIENT_ID }} Azure__SubscriptionId: ${{ secrets.AZURE_DEPLOYMENT_TEST_SUBSCRIPTION_ID }} - Azure__Location: eastus + Azure__Location: westus3 GH_TOKEN: ${{ github.token }} run: | ./dotnet.sh test tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj \ @@ -332,13 +354,58 @@ jobs: pull-requests: write actions: read steps: - - name: Download recording artifacts + - name: Get job results and download recording artifacts + id: get_results uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | const fs = require('fs'); const path = require('path'); + // Get all jobs for this workflow run to determine per-test results + const jobs = await github.paginate( + github.rest.actions.listJobsForWorkflowRun, + { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId, + per_page: 100 + } + ); + + console.log(`Total jobs found: ${jobs.length}`); + + // Filter for deploy-test matrix jobs (format: "Deploy (TestClassName)") + const deployJobs = jobs.filter(job => job.name.startsWith('Deploy (')); + + const passedTests = []; + const failedTests = []; + const cancelledTests = []; + + for (const job of deployJobs) { + // Extract test name from job name "Deploy (TestClassName)" + const match = job.name.match(/^Deploy \((.+)\)$/); + const testName = match ? match[1] : job.name; + + console.log(`Job "${job.name}" - conclusion: ${job.conclusion}, status: ${job.status}`); + + if (job.conclusion === 'success') { + passedTests.push(testName); + } else if (job.conclusion === 'failure') { + failedTests.push(testName); + } else if (job.conclusion === 'cancelled') { + cancelledTests.push(testName); + } + } + + console.log(`Passed: ${passedTests.length}, Failed: ${failedTests.length}, Cancelled: ${cancelledTests.length}`); + + // Output results for later steps + core.setOutput('passed_tests', JSON.stringify(passedTests)); + core.setOutput('failed_tests', JSON.stringify(failedTests)); + core.setOutput('cancelled_tests', JSON.stringify(cancelledTests)); + core.setOutput('total_tests', passedTests.length + failedTests.length + cancelledTests.length); + // List all artifacts for the current workflow run const allArtifacts = await github.paginate( github.rest.actions.listWorkflowRunArtifacts, @@ -402,6 +469,10 @@ jobs: - name: Upload recordings to asciinema and post comment env: GH_TOKEN: ${{ github.token }} + PASSED_TESTS: ${{ steps.get_results.outputs.passed_tests }} + FAILED_TESTS: ${{ steps.get_results.outputs.failed_tests }} + CANCELLED_TESTS: ${{ steps.get_results.outputs.cancelled_tests }} + TOTAL_TESTS: ${{ steps.get_results.outputs.total_tests }} shell: bash run: | PR_NUMBER="${{ inputs.pr_number }}" @@ -409,43 +480,61 @@ jobs: RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${RUN_ID}" TEST_RESULT="${{ needs.deploy-test.result }}" - # Determine status emoji and message - case "$TEST_RESULT" in - success) - EMOJI="✅" - STATUS="passed" - DETAILS="All deployment tests completed successfully." - ;; - failure) - EMOJI="❌" - STATUS="failed" - DETAILS="One or more deployment tests failed. Check the workflow run for details." - ;; - cancelled) - EMOJI="⚠️" - STATUS="cancelled" - DETAILS="The deployment tests were cancelled." - ;; - skipped) - EMOJI="⏭️" - STATUS="skipped" - DETAILS="The deployment tests were skipped (no tests to run or prerequisites not met)." - ;; - *) - EMOJI="❓" - STATUS="${TEST_RESULT:-unknown}" - DETAILS="The deployment test result could not be determined." - ;; - esac + # Parse the test results from JSON + PASSED_COUNT=$(echo "$PASSED_TESTS" | jq 'length') + FAILED_COUNT=$(echo "$FAILED_TESTS" | jq 'length') + CANCELLED_COUNT=$(echo "$CANCELLED_TESTS" | jq 'length') - # Build the comment body + # Determine overall status + if [ "$FAILED_COUNT" -gt 0 ]; then + EMOJI="❌" + STATUS="failed" + elif [ "$CANCELLED_COUNT" -gt 0 ] && [ "$PASSED_COUNT" -eq 0 ]; then + EMOJI="⚠️" + STATUS="cancelled" + elif [ "$PASSED_COUNT" -gt 0 ]; then + EMOJI="✅" + STATUS="passed" + else + EMOJI="❓" + STATUS="unknown" + fi + + # Build the comment header COMMENT_BODY="${EMOJI} **Deployment E2E Tests ${STATUS}** - - ${DETAILS} - + + **Summary:** ${PASSED_COUNT} passed, ${FAILED_COUNT} failed, ${CANCELLED_COUNT} cancelled + [View workflow run](${RUN_URL})" - # Check for recordings and upload them + # Add passed tests section if any + if [ "$PASSED_COUNT" -gt 0 ]; then + PASSED_LIST=$(echo "$PASSED_TESTS" | jq -r '.[]' | while read test; do echo "- ✅ ${test}"; done) + COMMENT_BODY="${COMMENT_BODY} + + ### Passed Tests + ${PASSED_LIST}" + fi + + # Add failed tests section if any + if [ "$FAILED_COUNT" -gt 0 ]; then + FAILED_LIST=$(echo "$FAILED_TESTS" | jq -r '.[]' | while read test; do echo "- ❌ ${test}"; done) + COMMENT_BODY="${COMMENT_BODY} + + ### Failed Tests + ${FAILED_LIST}" + fi + + # Add cancelled tests section if any + if [ "$CANCELLED_COUNT" -gt 0 ]; then + CANCELLED_LIST=$(echo "$CANCELLED_TESTS" | jq -r '.[]' | while read test; do echo "- ⚠️ ${test}"; done) + COMMENT_BODY="${COMMENT_BODY} + + ### Cancelled Tests + ${CANCELLED_LIST}" + fi + + # Check for recordings and upload them (only for failed tests) RECORDINGS_DIR="cast_files" if [ -d "$RECORDINGS_DIR" ] && compgen -G "$RECORDINGS_DIR"/*.cast > /dev/null; then @@ -453,9 +542,9 @@ jobs: pip install --quiet asciinema RECORDING_TABLE=" - + ### 🎬 Terminal Recordings - + | Test | Recording | |------|-----------|" diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs index a9fecb3c94f..6f7488c993f 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs @@ -14,9 +14,9 @@ namespace Aspire.Deployment.EndToEnd.Tests; /// public sealed class AcaStarterDeploymentTests(ITestOutputHelper output) { - // Timeout set to 15 minutes to allow for Azure provisioning. - // Full deployments can take 10-20+ minutes. Increase if needed. - private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(15); + // Timeout set to 40 minutes to allow for Azure provisioning. + // Full deployments can take up to 30 minutes if Azure infrastructure is backed up. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); [Fact] public async Task DeployStarterTemplateToAzureContainerApps() @@ -211,10 +211,11 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok .Type("aspire deploy --clear-cache") .Enter() // Wait for pipeline to complete successfully - .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(10)) + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); - // Step 10: Extract deployment URLs and verify endpoints + // Step 10: Extract deployment URLs and verify endpoints with retry + // Retry each endpoint for up to 3 minutes (18 attempts * 10 seconds) output.WriteLine("Step 8: Verifying deployed endpoints..."); sequenceBuilder .Type($"RG_NAME=\"{resourceGroupName}\" && " + @@ -225,13 +226,18 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok "if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " + "failed=0 && " + "for url in $urls; do " + - "echo -n \"Checking https://$url... \"; " + + "echo \"Checking https://$url...\"; " + + "success=0; " + + "for i in $(seq 1 18); do " + "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 10 2>/dev/null); " + - "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \"✅ $STATUS\"; else echo \"❌ $STATUS\"; failed=1; fi; " + + "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " + + "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " + + "done; " + + "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " + "done && " + "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi") .Enter() - .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); // Step 11: Exit terminal sequenceBuilder diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs new file mode 100644 index 00000000000..009b5cb5b4a --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs @@ -0,0 +1,306 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Python FastAPI Aspire applications to Azure App Service. +/// +public sealed class AppServicePythonDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 40 minutes to allow for Azure provisioning and Python environment setup. + // Full deployments can take up to 30 minutes if Azure infrastructure is backed up. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); + + [Fact(Skip = "App Service provisioning takes longer than 30 minutes, causing timeouts. Skipped until infrastructure issues are resolved.")] + public async Task DeployPythonFastApiTemplateToAzureAppService() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployPythonFastApiTemplateToAzureAppServiceCore(cancellationToken); + } + + private async Task DeployPythonFastApiTemplateToAzureAppServiceCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployPythonFastApiTemplateToAzureAppService)); + var startTime = DateTime.UtcNow; + var deploymentUrls = new Dictionary(); + // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt] + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("python-appsvc"); + // Project name can be simpler since resource group is explicitly set + var projectName = "PyAppSvc"; + + output.WriteLine($"Test: {nameof(DeployPythonFastApiTemplateToAzureAppService)}"); + output.WriteLine($"Project Name: {projectName}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + // Pattern searchers for aspire new interactive prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + // Wait for the FastAPI/React template to be highlighted (after pressing Down twice) + // Use Find() instead of FindPattern() because parentheses and slashes are regex special characters + var waitingForPythonReactTemplateSelected = new CellPatternSearcher() + .Find("> Starter App (FastAPI/React)"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find("Enter the output path:"); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create Python FastAPI project using aspire new with interactive prompts + // Navigate down to select Starter App (FastAPI/React) which is the 3rd option + output.WriteLine("Step 3: Creating Python FastAPI project..."); + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + // Navigate to Starter App (FastAPI/React) - it's the 3rd option (after ASP.NET and JS) + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .WaitUntil(s => waitingForPythonReactTemplateSelected.Search(s).Count > 0, TimeSpan.FromSeconds(5)) + .Enter() // Select Starter App (FastAPI/React) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Type(projectName) + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Accept default output path + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for localhost URLs (default) + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + // For Redis prompt, default is "Yes" so we need to select "No" by pressing Down + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .Enter() // Select "No" for Redis Cache + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 4: Navigate to project directory + output.WriteLine("Step 4: Navigating to project directory..."); + sequenceBuilder + .Type($"cd {projectName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5: Add Aspire.Hosting.Azure.AppService package (instead of AppContainers) + output.WriteLine("Step 5: Adding Azure App Service hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppService") + .Enter(); + + // In CI, aspire add shows a version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 6: Modify apphost.cs to add Azure App Service Environment + // Note: Python template uses single-file AppHost (apphost.cs in project root) + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + // Single-file AppHost is in the project root, not a subdirectory + var appHostFilePath = Path.Combine(projectDir, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + // Insert the Azure App Service Environment before builder.Build().Run(); + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure App Service Environment for deployment +builder.AddAzureAppServiceEnvironment("infra"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs at: {appHostFilePath}"); + }); + + // Step 7: Set environment for deployment + // - Unset ASPIRE_PLAYGROUND to avoid conflicts + // - Set Azure location to westus3 (same as other tests to use region with capacity) + // - Set AZURE__RESOURCEGROUP to use our unique resource group name + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 8: Deploy to Azure App Service using aspire deploy + output.WriteLine("Step 7: Starting Azure App Service deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + // Wait for pipeline to complete successfully (App Service can take longer) + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 9: Extract deployment URLs and verify endpoints with retry + // For App Service, we use az webapp list instead of az containerapp list + // Retry each endpoint for up to 3 minutes (18 attempts * 10 seconds) + output.WriteLine("Step 8: Verifying deployed endpoints..."); + sequenceBuilder + .Type($"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 && " + + // Get App Service hostnames (defaultHostName for each web app) + "urls=$(az webapp list -g \"$RG_NAME\" --query \"[].defaultHostName\" -o tsv 2>/dev/null) && " + + "if [ -z \"$urls\" ]; then echo \"❌ No App Service endpoints found\"; exit 1; fi && " + + "failed=0 && " + + "for url in $urls; do " + + "echo \"Checking https://$url...\"; " + + "success=0; " + + "for i in $(seq 1 18); do " + + "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 30 2>/dev/null); " + + "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " + + "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " + + "done; " + + "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " + + "done && " + + "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 10: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + // Report success + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployPythonFastApiTemplateToAzureAppService), + resourceGroupName, + deploymentUrls, + duration); + + output.WriteLine("✅ Test passed!"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployPythonFastApiTemplateToAzureAppService), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + // Clean up the resource group we created + output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName, output); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + } + } + + private static void TriggerCleanupResourceGroup(string resourceGroupName, ITestOutputHelper output) + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs new file mode 100644 index 00000000000..c74eb0ee30b --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs @@ -0,0 +1,325 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Aspire applications to Azure App Service. +/// +public sealed class AppServiceReactDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 40 minutes to allow for Azure App Service provisioning. + // Full deployments can take up to 30 minutes if Azure infrastructure is backed up. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); + + [Fact(Skip = "App Service provisioning takes longer than 30 minutes, causing timeouts. Skipped until infrastructure issues are resolved.")] + public async Task DeployReactTemplateToAzureAppService() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployReactTemplateToAzureAppServiceCore(cancellationToken); + } + + private async Task DeployReactTemplateToAzureAppServiceCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployReactTemplateToAzureAppService)); + var startTime = DateTime.UtcNow; + var deploymentUrls = new Dictionary(); + // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt] + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("appservice"); + // Project name can be simpler since resource group is explicitly set + var projectName = "ReactAppSvc"; + + output.WriteLine($"Test: {nameof(DeployReactTemplateToAzureAppService)}"); + output.WriteLine($"Project Name: {projectName}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + // Pattern searchers for aspire new interactive prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + // Wait for the ASP.NET Core/React template to be highlighted (after pressing Down once) + // Use Find() instead of FindPattern() because parentheses and slashes are regex special characters + var waitingForReactTemplateSelected = new CellPatternSearcher() + .Find("> Starter App (ASP.NET Core/React)"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find($"Enter the output path: (./{projectName}): "); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + // Note: React template (aspire-ts-cs-starter) does NOT have the "test project" prompt + // unlike the Blazor starter template. It only has localhost URLs and Redis prompts. + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + // The workflow builds and installs the CLI to ~/.aspire/bin before running tests + // We just need to source it in the bash session + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + // Source the CLI environment (sets PATH and other env vars) + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create React + ASP.NET Core project using aspire new with interactive prompts + // Navigate down to select Starter App (ASP.NET Core/React) - it's the 2nd option + output.WriteLine("Step 3: Creating React + ASP.NET Core project..."); + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + // Navigate to Starter App (ASP.NET Core/React) - it's the 2nd option (after Blazor) + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .WaitUntil(s => waitingForReactTemplateSelected.Search(s).Count > 0, TimeSpan.FromSeconds(5)) + .Enter() // Select Starter App (ASP.NET Core/React) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Type(projectName) + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Accept default output path + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for localhost URLs (default) + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + // For Redis prompt, default is "Yes" so we need to select "No" by pressing Down + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .Enter() // Select "No" for Redis Cache + // Note: React template does NOT have a test project prompt (unlike Blazor starter) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 4: Navigate to project directory + output.WriteLine("Step 4: Navigating to project directory..."); + sequenceBuilder + .Type($"cd {projectName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5: Add Aspire.Hosting.Azure.AppService package (instead of AppContainers) + output.WriteLine("Step 5: Adding Azure App Service hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppService") + .Enter(); + + // In CI, aspire add shows a version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 6: Modify AppHost.cs to add Azure App Service Environment + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost"); + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + + output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + // Insert the Azure App Service Environment before builder.Build().Run(); + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure App Service Environment for deployment +builder.AddAzureAppServiceEnvironment("infra"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}"); + }); + + // Step 7: Navigate to AppHost project directory + output.WriteLine("Step 6: Navigating to AppHost directory..."); + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 8: Set environment variables for deployment + // - Unset ASPIRE_PLAYGROUND to avoid conflicts + // - Set Azure location + // - Set AZURE__RESOURCEGROUP to use our unique resource group name + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 9: Deploy to Azure App Service using aspire deploy + // Use --clear-cache to ensure fresh deployment without cached location from previous runs + output.WriteLine("Step 7: Starting Azure App Service deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + // Wait for pipeline to complete successfully (App Service can take longer) + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 10: Extract deployment URLs and verify endpoints with retry + // For App Service, we use az webapp list instead of az containerapp list + // Retry each endpoint for up to 3 minutes (18 attempts * 10 seconds) + output.WriteLine("Step 8: Verifying deployed endpoints..."); + sequenceBuilder + .Type($"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 && " + + // Get App Service hostnames (defaultHostName for each web app) + "urls=$(az webapp list -g \"$RG_NAME\" --query \"[].defaultHostName\" -o tsv 2>/dev/null) && " + + "if [ -z \"$urls\" ]; then echo \"❌ No App Service endpoints found\"; exit 1; fi && " + + "failed=0 && " + + "for url in $urls; do " + + "echo \"Checking https://$url...\"; " + + "success=0; " + + "for i in $(seq 1 18); do " + + "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 30 2>/dev/null); " + + "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " + + "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " + + "done; " + + "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " + + "done && " + + "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 11: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + // Report success + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployReactTemplateToAzureAppService), + resourceGroupName, + deploymentUrls, + duration); + + output.WriteLine("✅ Test passed!"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployReactTemplateToAzureAppService), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + // Clean up the resource group we created + output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName, output); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + } + } + + /// + /// Triggers cleanup of a specific resource group. + /// This is fire-and-forget - the hourly cleanup workflow handles any missed resources. + /// + private static void TriggerCleanupResourceGroup(string resourceGroupName, ITestOutputHelper output) + { + // Fire and forget - trigger deletion of the specific resource group created by this test + // The cleanup workflow will handle any that don't get deleted + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs new file mode 100644 index 00000000000..c89e886df2f --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs @@ -0,0 +1,269 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Azure App Configuration resources via Aspire. +/// Tests the Aspire.Hosting.Azure.AppConfiguration integration package. +/// +public sealed class AzureAppConfigDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 30 minutes for Azure resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task DeployAzureAppConfigResource() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployAzureAppConfigResourceCore(cancellationToken); + } + + private async Task DeployAzureAppConfigResourceCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureAppConfigResource)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("appconfig"); + + output.WriteLine($"Test: {nameof(DeployAzureAppConfigResource)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + // Pattern searchers for aspire init + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + // Pattern searchers for aspire add prompts + // Integration selection prompt appears when multiple packages match the search term + var waitingForIntegrationSelectionPrompt = new CellPatternSearcher() + .Find("Select an integration to add:"); + + // Version selection prompt appears when selecting a package version in CI + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + // NuGet.config prompt may or may not appear depending on environment. + // Wait a moment then press Enter to dismiss if present, then wait for completion. + .Wait(TimeSpan.FromSeconds(5)) + .Enter() // Dismiss NuGet.config prompt if present (no-op if already auto-accepted) + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4a: Add Aspire.Hosting.Azure.ContainerApps package (for managed identity support) + // This command triggers TWO prompts in sequence: + // 1. Integration selection prompt (because "ContainerApps" matches multiple Azure packages) + // 2. Version selection prompt (in CI, to select package version) + output.WriteLine("Step 4a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerApps") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + // First, handle integration selection prompt + sequenceBuilder + .WaitUntil(s => waitingForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first integration (azure-appcontainers) + // Then, handle version selection prompt + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4b: Add Aspire.Hosting.Azure.AppConfiguration package + // This command may only show version selection prompt (unique match) + output.WriteLine("Step 4b: Adding Azure App Configuration hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppConfiguration") + .Enter(); + + // In CI, aspire add shows version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add Azure App Configuration resource + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Container App Environment for managed identity support +_ = builder.AddAzureContainerAppEnvironment("env"); + +// Add Azure App Configuration resource for deployment testing +builder.AddAzureAppConfiguration("appconfig"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs to add Azure App Configuration resource"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure using aspire deploy + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify the Azure App Configuration store was created + output.WriteLine("Step 8: Verifying Azure App Configuration store..."); + sequenceBuilder + .Type($"az appconfig list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployAzureAppConfigResource), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployAzureAppConfigResource), + resourceGroupName, + ex.Message); + + throw; + } + finally + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs new file mode 100644 index 00000000000..b27221ad519 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs @@ -0,0 +1,238 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Azure Container Registry resources via Aspire. +/// Tests the Aspire.Hosting.Azure.ContainerRegistry integration package. +/// +public sealed class AzureContainerRegistryDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 30 minutes for Azure resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task DeployAzureContainerRegistryResource() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployAzureContainerRegistryResourceCore(cancellationToken); + } + + private async Task DeployAzureContainerRegistryResourceCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureContainerRegistryResource)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("acr"); + + output.WriteLine($"Test: {nameof(DeployAzureContainerRegistryResource)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + // Pattern searchers for aspire init + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + // NuGet.config prompt may or may not appear depending on environment. + // Wait a moment then press Enter to dismiss if present, then wait for completion. + .Wait(TimeSpan.FromSeconds(5)) + .Enter() // Dismiss NuGet.config prompt if present (no-op if already auto-accepted) + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4: Add Aspire.Hosting.Azure.ContainerRegistry package + output.WriteLine("Step 4: Adding Azure Container Registry hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerRegistry") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add Azure Container Registry resource + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Container Registry resource for deployment testing +builder.AddAzureContainerRegistry("acr"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs to add Azure Container Registry resource"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure using aspire deploy + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify the Azure Container Registry was created + output.WriteLine("Step 8: Verifying Azure Container Registry..."); + sequenceBuilder + .Type($"az acr list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployAzureContainerRegistryResource), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployAzureContainerRegistryResource), + resourceGroupName, + ex.Message); + + throw; + } + finally + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs new file mode 100644 index 00000000000..f551407bf2f --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs @@ -0,0 +1,269 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Azure Event Hubs resources via Aspire. +/// Tests the Aspire.Hosting.Azure.EventHubs integration package. +/// +public sealed class AzureEventHubsDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 30 minutes for Azure resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task DeployAzureEventHubsResource() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployAzureEventHubsResourceCore(cancellationToken); + } + + private async Task DeployAzureEventHubsResourceCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureEventHubsResource)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("eventhubs"); + + output.WriteLine($"Test: {nameof(DeployAzureEventHubsResource)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + // Pattern searchers for aspire init + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + // Pattern searchers for aspire add prompts + // Integration selection prompt appears when multiple packages match the search term + var waitingForIntegrationSelectionPrompt = new CellPatternSearcher() + .Find("Select an integration to add:"); + + // Version selection prompt appears when selecting a package version in CI + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + // NuGet.config prompt may or may not appear depending on environment. + // Wait a moment then press Enter to dismiss if present, then wait for completion. + .Wait(TimeSpan.FromSeconds(5)) + .Enter() // Dismiss NuGet.config prompt if present (no-op if already auto-accepted) + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4a: Add Aspire.Hosting.Azure.ContainerApps package (for managed identity support) + // This command triggers TWO prompts in sequence: + // 1. Integration selection prompt (because "ContainerApps" matches multiple Azure packages) + // 2. Version selection prompt (in CI, to select package version) + output.WriteLine("Step 4a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerApps") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + // First, handle integration selection prompt + sequenceBuilder + .WaitUntil(s => waitingForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first integration (azure-appcontainers) + // Then, handle version selection prompt + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4b: Add Aspire.Hosting.Azure.EventHubs package + // This command may only show version selection prompt (unique match) + output.WriteLine("Step 4b: Adding Azure Event Hubs hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.EventHubs") + .Enter(); + + // In CI, aspire add shows version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add Azure Event Hubs resource + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Container App Environment for managed identity support +_ = builder.AddAzureContainerAppEnvironment("env"); + +// Add Azure Event Hubs resource for deployment testing +builder.AddAzureEventHubs("eventhubs"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs to add Azure Event Hubs resource"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure using aspire deploy + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify the Azure Event Hubs namespace was created + output.WriteLine("Step 8: Verifying Azure Event Hubs namespace..."); + sequenceBuilder + .Type($"az eventhubs namespace list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployAzureEventHubsResource), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployAzureEventHubsResource), + resourceGroupName, + ex.Message); + + throw; + } + finally + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs new file mode 100644 index 00000000000..035b280a7df --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs @@ -0,0 +1,269 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Azure Key Vault resources via Aspire. +/// Tests the Aspire.Hosting.Azure.KeyVault integration package. +/// +public sealed class AzureKeyVaultDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 30 minutes for Azure resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task DeployAzureKeyVaultResource() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployAzureKeyVaultResourceCore(cancellationToken); + } + + private async Task DeployAzureKeyVaultResourceCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureKeyVaultResource)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("keyvault"); + + output.WriteLine($"Test: {nameof(DeployAzureKeyVaultResource)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + // Pattern searchers for aspire init + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + // Pattern searchers for aspire add prompts + // Integration selection prompt appears when multiple packages match the search term + var waitingForIntegrationSelectionPrompt = new CellPatternSearcher() + .Find("Select an integration to add:"); + + // Version selection prompt appears when selecting a package version in CI + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + // NuGet.config prompt may or may not appear depending on environment. + // Wait a moment then press Enter to dismiss if present, then wait for completion. + .Wait(TimeSpan.FromSeconds(5)) + .Enter() // Dismiss NuGet.config prompt if present (no-op if already auto-accepted) + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4a: Add Aspire.Hosting.Azure.ContainerApps package (for managed identity support) + // This command triggers TWO prompts in sequence: + // 1. Integration selection prompt (because "ContainerApps" matches multiple Azure packages) + // 2. Version selection prompt (in CI, to select package version) + output.WriteLine("Step 4a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerApps") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + // First, handle integration selection prompt + sequenceBuilder + .WaitUntil(s => waitingForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first integration (azure-appcontainers) + // Then, handle version selection prompt + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4b: Add Aspire.Hosting.Azure.KeyVault package + // This command may only show version selection prompt (unique match) + output.WriteLine("Step 4b: Adding Azure Key Vault hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.KeyVault") + .Enter(); + + // In CI, aspire add shows version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add Azure Key Vault resource + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Container App Environment for managed identity support +_ = builder.AddAzureContainerAppEnvironment("env"); + +// Add Azure Key Vault resource for deployment testing +builder.AddAzureKeyVault("keyvault"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs to add Azure Key Vault resource"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure using aspire deploy + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify the Azure Key Vault was created + output.WriteLine("Step 8: Verifying Azure Key Vault..."); + sequenceBuilder + .Type($"az keyvault list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployAzureKeyVaultResource), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployAzureKeyVaultResource), + resourceGroupName, + ex.Message); + + throw; + } + finally + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs new file mode 100644 index 00000000000..c0a14e97dfb --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs @@ -0,0 +1,238 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Azure Log Analytics Workspace resources via Aspire. +/// Tests the Aspire.Hosting.Azure.OperationalInsights integration package. +/// +public sealed class AzureLogAnalyticsDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 30 minutes for Azure resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task DeployAzureLogAnalyticsResource() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployAzureLogAnalyticsResourceCore(cancellationToken); + } + + private async Task DeployAzureLogAnalyticsResourceCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureLogAnalyticsResource)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("logs"); + + output.WriteLine($"Test: {nameof(DeployAzureLogAnalyticsResource)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + // Pattern searchers for aspire init + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + // NuGet.config prompt may or may not appear depending on environment. + // Wait a moment then press Enter to dismiss if present, then wait for completion. + .Wait(TimeSpan.FromSeconds(5)) + .Enter() // Dismiss NuGet.config prompt if present (no-op if already auto-accepted) + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4: Add Aspire.Hosting.Azure.OperationalInsights package + output.WriteLine("Step 4: Adding Azure Log Analytics hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.OperationalInsights") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add Azure Log Analytics Workspace resource + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Log Analytics Workspace resource for deployment testing +builder.AddAzureLogAnalyticsWorkspace("logs"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs to add Azure Log Analytics Workspace resource"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure using aspire deploy + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify the Azure Log Analytics Workspace was created + output.WriteLine("Step 8: Verifying Azure Log Analytics Workspace..."); + sequenceBuilder + .Type($"az monitor log-analytics workspace list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployAzureLogAnalyticsResource), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployAzureLogAnalyticsResource), + resourceGroupName, + ex.Message); + + throw; + } + finally + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs new file mode 100644 index 00000000000..c3083e8d9a4 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs @@ -0,0 +1,269 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Azure Service Bus resources via Aspire. +/// Tests the Aspire.Hosting.Azure.ServiceBus integration package. +/// +public sealed class AzureServiceBusDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 30 minutes for Azure resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task DeployAzureServiceBusResource() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployAzureServiceBusResourceCore(cancellationToken); + } + + private async Task DeployAzureServiceBusResourceCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureServiceBusResource)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("servicebus"); + + output.WriteLine($"Test: {nameof(DeployAzureServiceBusResource)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + // Pattern searchers for aspire init + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + // Pattern searchers for aspire add prompts + // Integration selection prompt appears when multiple packages match the search term + var waitingForIntegrationSelectionPrompt = new CellPatternSearcher() + .Find("Select an integration to add:"); + + // Version selection prompt appears when selecting a package version in CI + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + // NuGet.config prompt may or may not appear depending on environment. + // Wait a moment then press Enter to dismiss if present, then wait for completion. + .Wait(TimeSpan.FromSeconds(5)) + .Enter() // Dismiss NuGet.config prompt if present (no-op if already auto-accepted) + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4a: Add Aspire.Hosting.Azure.ContainerApps package (for managed identity support) + // This command triggers TWO prompts in sequence: + // 1. Integration selection prompt (because "ContainerApps" matches multiple Azure packages) + // 2. Version selection prompt (in CI, to select package version) + output.WriteLine("Step 4a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerApps") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + // First, handle integration selection prompt + sequenceBuilder + .WaitUntil(s => waitingForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first integration (azure-appcontainers) + // Then, handle version selection prompt + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4b: Add Aspire.Hosting.Azure.ServiceBus package + // This command may only show version selection prompt (unique match) + output.WriteLine("Step 4b: Adding Azure Service Bus hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ServiceBus") + .Enter(); + + // In CI, aspire add shows version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add Azure Service Bus resource + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Container App Environment for managed identity support +_ = builder.AddAzureContainerAppEnvironment("env"); + +// Add Azure Service Bus resource for deployment testing +builder.AddAzureServiceBus("messaging"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs to add Azure Service Bus resource"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure using aspire deploy + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify the Azure Service Bus namespace was created + output.WriteLine("Step 8: Verifying Azure Service Bus namespace..."); + sequenceBuilder + .Type($"az servicebus namespace list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployAzureServiceBusResource), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployAzureServiceBusResource), + resourceGroupName, + ex.Message); + + throw; + } + finally + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs new file mode 100644 index 00000000000..3c004591f74 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs @@ -0,0 +1,274 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Azure Storage resources via Aspire. +/// Tests the Aspire.Hosting.Azure.Storage integration package. +/// +public sealed class AzureStorageDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 30 minutes for Azure resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task DeployAzureStorageResource() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployAzureStorageResourceCore(cancellationToken); + } + + private async Task DeployAzureStorageResourceCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureStorageResource)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("storage"); + + output.WriteLine($"Test: {nameof(DeployAzureStorageResource)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + // Pattern searchers for aspire init + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + // Pattern searchers for aspire add prompts + // Integration selection prompt appears when multiple packages match the search term + var waitingForIntegrationSelectionPrompt = new CellPatternSearcher() + .Find("Select an integration to add:"); + + // Version selection prompt appears when selecting a package version in CI + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searcher for deployment success + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + // NuGet.config prompt may or may not appear depending on environment. + // Wait a moment then press Enter to dismiss if present, then wait for completion. + .Wait(TimeSpan.FromSeconds(5)) + .Enter() // Dismiss NuGet.config prompt if present (no-op if already auto-accepted) + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4a: Add Aspire.Hosting.Azure.ContainerApps package (for managed identity support) + // This command triggers TWO prompts in sequence: + // 1. Integration selection prompt (because "ContainerApps" matches multiple Azure packages) + // 2. Version selection prompt (in CI, to select package version) + output.WriteLine("Step 4a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerApps") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + // First, handle integration selection prompt + sequenceBuilder + .WaitUntil(s => waitingForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first integration (azure-appcontainers) + // Then, handle version selection prompt + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4b: Add Aspire.Hosting.Azure.Storage package + // This command may only show version selection prompt (unique match) + output.WriteLine("Step 4b: Adding Azure Storage hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Storage") + .Enter(); + + // In CI, aspire add shows version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // Select first version + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add Azure Storage resource + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + // Insert Azure Storage with a container app environment (required for role assignments) + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Container App Environment for managed identity support +_ = builder.AddAzureContainerAppEnvironment("env"); + +// Add Azure Storage resource for deployment testing +builder.AddAzureStorage("storage"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs to add Azure Storage resource"); + output.WriteLine($"New content:\n{content}"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure using aspire deploy + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + // Wait for pipeline to complete successfully + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(20)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify the Azure Storage account was created + output.WriteLine("Step 8: Verifying Azure Storage account..."); + sequenceBuilder + .Type($"az storage account list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + // Report success + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployAzureStorageResource), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployAzureStorageResource), + resourceGroupName, + ex.Message); + + throw; + } + finally + { + // Always attempt to clean up the resource group + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs index 29ae174bb83..71537a5f54e 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs @@ -14,8 +14,9 @@ namespace Aspire.Deployment.EndToEnd.Tests; /// public sealed class PythonFastApiDeploymentTests(ITestOutputHelper output) { - // Timeout set to 20 minutes to allow for Azure provisioning and Python environment setup. - private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(20); + // Timeout set to 40 minutes to allow for Azure provisioning and Python environment setup. + // Full deployments can take up to 30 minutes if Azure infrastructure is backed up. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); [Fact] public async Task DeployPythonFastApiTemplateToAzureContainerApps() @@ -205,10 +206,11 @@ private async Task DeployPythonFastApiTemplateToAzureContainerAppsCore(Cancellat .Type("aspire deploy --clear-cache") .Enter() // Wait for pipeline to complete successfully - .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(15)) + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); - // Step 10: Extract deployment URLs and verify endpoints + // Step 10: Extract deployment URLs and verify endpoints with retry + // Retry each endpoint for up to 3 minutes (18 attempts * 10 seconds) output.WriteLine("Step 8: Verifying deployed endpoints..."); sequenceBuilder .Type($"RG_NAME=\"{resourceGroupName}\" && " + @@ -219,13 +221,18 @@ private async Task DeployPythonFastApiTemplateToAzureContainerAppsCore(Cancellat "if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " + "failed=0 && " + "for url in $urls; do " + - "echo -n \"Checking https://$url... \"; " + + "echo \"Checking https://$url...\"; " + + "success=0; " + + "for i in $(seq 1 18); do " + "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 10 2>/dev/null); " + - "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \"✅ $STATUS\"; else echo \"❌ $STATUS\"; failed=1; fi; " + + "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " + + "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " + + "done; " + + "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " + "done && " + "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi") .Enter() - .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); // Step 11: Exit terminal sequenceBuilder diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/README.md b/tests/Aspire.Deployment.EndToEnd.Tests/README.md index 189547f5757..022fc0f2bf2 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/README.md +++ b/tests/Aspire.Deployment.EndToEnd.Tests/README.md @@ -6,6 +6,53 @@ This project contains end-to-end tests that deploy Aspire applications to real A These tests use the [Hex1b](https://github.com/hex1b/hex1b) terminal automation library to drive the Aspire CLI, similar to the CLI E2E tests. The key difference is that these tests actually deploy to Azure and verify the deployed applications work correctly. +## Azure Subscription Quota Requirements + +The deployment tests require an Azure subscription with sufficient quota for the resources being deployed. Ensure the following quotas are available in the test region (currently `westus3`). + +### Container Apps + +| Resource | Quota Required | Current Setting | Notes | +|----------|---------------|-----------------|-------| +| Managed Environments | 150+ | 150 | Each test run creates a new environment. High quota allows concurrent runs and handles cleanup delays. | +| Container App Instances | Default | - | Standard quota is typically sufficient | + +### App Service + +| Resource | Quota Required | Current Setting | Notes | +|----------|---------------|-----------------|-------| +| PremiumV3 vCPUs | 10+ | TBD | App Service Plans use PremiumV3 tier (P0V3). Each deployment needs ~1 vCPU. | +| App Service Plans | 10+ | Default | Each deployment creates a new plan | + +### Container Registry + +| Resource | Quota Required | Notes | +|----------|---------------|-------| +| Azure Container Registry | Default | Standard quota is typically sufficient | + +### General + +| Resource | Quota Required | Notes | +|----------|---------------|-------| +| Resource Groups | 100+ | Each test creates a unique resource group (e.g., `e2e-starter-12345678-1`) | +| Role Assignments | Default | Tests may create role assignments for managed identities | + +### Requesting Quota Increases + +To request quota increases: + +1. Go to the [Azure Portal](https://portal.azure.com) +2. Navigate to **Subscriptions** → Select your subscription +3. Go to **Usage + quotas** +4. Filter by the resource type: + - `Microsoft.App` for Container Apps + - `Microsoft.Web` for App Service +5. Select the quota to increase and click **Request increase** + +Common quota increase requests: +- **Container Apps Managed Environments**: Request 150+ in westus3 +- **App Service PremiumV3 vCPUs**: Request 10+ in westus3 + ## Prerequisites ### For Local Development @@ -100,7 +147,17 @@ Aspire.Deployment.EndToEnd.Tests/ │ ├── DeploymentE2ETestHelpers.cs # Terminal automation helpers │ ├── DeploymentReporter.cs # GitHub step summary reporting │ └── SequenceCounter.cs # Prompt tracking -├── AcaStarterDeploymentTests.cs # Azure Container Apps tests +├── AcaStarterDeploymentTests.cs # Blazor to Azure Container Apps +├── AppServicePythonDeploymentTests.cs # Python FastAPI to Azure App Service +├── AppServiceReactDeploymentTests.cs # React + ASP.NET Core to Azure App Service +├── AzureAppConfigDeploymentTests.cs # Azure App Configuration resource +├── AzureContainerRegistryDeploymentTests.cs # Azure Container Registry resource +├── AzureEventHubsDeploymentTests.cs # Azure Event Hubs resource +├── AzureKeyVaultDeploymentTests.cs # Azure Key Vault resource +├── AzureLogAnalyticsDeploymentTests.cs # Azure Log Analytics resource +├── AzureServiceBusDeploymentTests.cs # Azure Service Bus resource +├── AzureStorageDeploymentTests.cs # Azure Storage resource +├── PythonFastApiDeploymentTests.cs # Python FastAPI to Azure Container Apps ├── xunit.runner.json # Test runner config └── README.md # This file ``` @@ -152,7 +209,14 @@ public sealed class MyDeploymentTests : IAsyncDisposable ### Deployment Timeouts -Deployments can take 15-30+ minutes. The per-test timeout is set to 15 minutes, and the test session timeout is 60 minutes. +Deployments can take 15-30+ minutes. Current timeout settings: + +| Step | Timeout | Description | +|------|---------|-------------| +| Overall test | 40 minutes | Maximum time for entire test execution | +| Pipeline deployment | 30 minutes | Time for `aspire deploy` to complete | +| Endpoint verification | 5 minutes | Time for endpoint check command with retries | +| Per-endpoint retry | ~3 minutes | 18 attempts × 10 seconds per endpoint | ### Resource Cleanup @@ -161,8 +225,14 @@ Tests attempt to clean up Azure resources after completion. The cleanup workflow To find orphaned resources: ```bash -# Resource groups created by aspire deploy +# Resource groups created by deployment tests (current naming) +az group list --query "[?starts_with(name, 'e2e-')]" -o table + +# Resource groups created by aspire deploy (legacy naming) az group list --query "[?starts_with(name, 'rg-aspire-')]" -o table + +# Delete all test resource groups (use with caution!) +az group list --query "[?starts_with(name, 'e2e-')].name" -o tsv | xargs -I {} az group delete --name {} --yes --no-wait ``` ### Viewing Recordings From cae099132c9aed1841e7289c539833f13a943778 Mon Sep 17 00:00:00 2001 From: dotnet bot Date: Wed, 4 Feb 2026 09:16:23 -0800 Subject: [PATCH 039/256] Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2895128 (#14332) --- .../Resources/xlf/DocsCommandStrings.cs.xlf | 30 ++++++------- .../Resources/xlf/DocsCommandStrings.ko.xlf | 30 ++++++------- .../xlf/DocsCommandStrings.pt-BR.xlf | 2 +- .../Resources/xlf/DocsCommandStrings.ru.xlf | 30 ++++++------- .../xlf/DocsCommandStrings.zh-Hant.xlf | 30 ++++++------- .../xlf/ResourceCommandStrings.cs.xlf | 24 +++++----- .../xlf/ResourceCommandStrings.de.xlf | 6 +-- .../xlf/ResourceCommandStrings.es.xlf | 6 +-- .../xlf/ResourceCommandStrings.fr.xlf | 6 +-- .../xlf/ResourceCommandStrings.it.xlf | 6 +-- .../xlf/ResourceCommandStrings.ja.xlf | 6 +-- .../xlf/ResourceCommandStrings.ko.xlf | 24 +++++----- .../xlf/ResourceCommandStrings.pl.xlf | 6 +-- .../xlf/ResourceCommandStrings.pt-BR.xlf | 6 +-- .../xlf/ResourceCommandStrings.ru.xlf | 24 +++++----- .../xlf/ResourceCommandStrings.tr.xlf | 6 +-- .../xlf/ResourceCommandStrings.zh-Hans.xlf | 6 +-- .../xlf/ResourceCommandStrings.zh-Hant.xlf | 24 +++++----- .../Resources/xlf/RootCommandStrings.cs.xlf | 6 +-- .../Resources/xlf/RootCommandStrings.es.xlf | 6 +-- .../Resources/xlf/RootCommandStrings.fr.xlf | 6 +-- .../Resources/xlf/RootCommandStrings.ja.xlf | 6 +-- .../Resources/xlf/RootCommandStrings.ko.xlf | 6 +-- .../xlf/RootCommandStrings.pt-BR.xlf | 6 +-- .../Resources/xlf/RootCommandStrings.ru.xlf | 6 +-- .../Resources/xlf/RootCommandStrings.tr.xlf | 6 +-- .../xlf/RootCommandStrings.zh-Hans.xlf | 6 +-- .../xlf/RootCommandStrings.zh-Hant.xlf | 6 +-- .../xlf/TelemetryCommandStrings.cs.xlf | 44 +++++++++---------- .../xlf/TelemetryCommandStrings.de.xlf | 12 ++--- .../xlf/TelemetryCommandStrings.es.xlf | 44 +++++++++---------- .../xlf/TelemetryCommandStrings.fr.xlf | 44 +++++++++---------- .../xlf/TelemetryCommandStrings.it.xlf | 12 ++--- .../xlf/TelemetryCommandStrings.ja.xlf | 44 +++++++++---------- .../xlf/TelemetryCommandStrings.ko.xlf | 44 +++++++++---------- .../xlf/TelemetryCommandStrings.pl.xlf | 12 ++--- .../xlf/TelemetryCommandStrings.pt-BR.xlf | 44 +++++++++---------- .../xlf/TelemetryCommandStrings.ru.xlf | 44 +++++++++---------- .../xlf/TelemetryCommandStrings.tr.xlf | 44 +++++++++---------- .../xlf/TelemetryCommandStrings.zh-Hans.xlf | 44 +++++++++---------- .../xlf/TelemetryCommandStrings.zh-Hant.xlf | 44 +++++++++---------- .../Resources/xlf/AIAssistant.cs.xlf | 2 +- .../Resources/xlf/AIAssistant.it.xlf | 4 +- .../Resources/xlf/AIAssistant.tr.xlf | 4 +- .../Resources/xlf/AIAssistant.zh-Hant.xlf | 4 +- .../Resources/xlf/MessageStrings.cs.xlf | 14 +++--- .../Resources/xlf/MessageStrings.de.xlf | 14 +++--- .../Resources/xlf/MessageStrings.es.xlf | 14 +++--- .../Resources/xlf/MessageStrings.fr.xlf | 14 +++--- .../Resources/xlf/MessageStrings.it.xlf | 2 +- .../Resources/xlf/MessageStrings.ja.xlf | 14 +++--- .../Resources/xlf/MessageStrings.ko.xlf | 14 +++--- .../Resources/xlf/MessageStrings.pl.xlf | 2 +- .../Resources/xlf/MessageStrings.pt-BR.xlf | 14 +++--- .../Resources/xlf/MessageStrings.ru.xlf | 14 +++--- .../Resources/xlf/MessageStrings.tr.xlf | 14 +++--- .../Resources/xlf/MessageStrings.zh-Hans.xlf | 14 +++--- .../Resources/xlf/MessageStrings.zh-Hant.xlf | 14 +++--- 58 files changed, 490 insertions(+), 490 deletions(-) diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.cs.xlf index 10ef46f3340..d405ab7915d 100644 --- a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.cs.xlf @@ -4,77 +4,77 @@ Browse and search Aspire documentation from aspire.dev. - Browse and search Aspire documentation from aspire.dev. + Dokumentaci můžete procházet a vyhledávat na aspire.dev. Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. - Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Stránka dokumentace {0} nebyla nalezena. Pokud chcete zobrazit dostupné stránky, použijte příkaz aspire docs list. Output format (Table or Json). - Output format (Table or Json). + Výstupní formát (tabulka nebo JSON) Found {0} documentation pages. - Found {0} documentation pages. + Počet nalezených stránek dokumentace: {0} Found {0} results for '{1}'. - Found {0} results for '{1}'. + Počet nalezených výsledků pro {1}: {0} Get the full content of a documentation page by its slug. - Get the full content of a documentation page by its slug. + Získejte úplný obsah stránky dokumentace podle jejího slugu. Maximum number of search results to return (default: 5, max: 10). - Maximum number of search results to return (default: 5, max: 10). + Maximální počet výsledků hledání, které se mají vrátit (výchozí: 5, max.: 10) List all available Aspire documentation pages. - List all available Aspire documentation pages. + Vypíše všechny dostupné stránky dokumentace Aspire. Loading documentation... - Loading documentation... + Načítá se dokumentace... No documentation available. The aspire.dev docs may not have loaded correctly. - No documentation available. The aspire.dev docs may not have loaded correctly. + Není dostupná žádná dokumentace. Dokumentace aspire.dev se možná nenačetla správně. No results found for '{0}'. Try different search terms. - No results found for '{0}'. Try different search terms. + Pro {0} se nenašly žádné výsledky. Zkuste použít jiné hledané termíny. The search query. - The search query. + Vyhledávací dotaz Search Aspire documentation by keywords. - Search Aspire documentation by keywords. + V dokumentaci můžete vyhledávat podle klíčových slov. Return only the specified section of the page. - Return only the specified section of the page. + Vrátí pouze zadaný oddíl stránky. The slug of the documentation page (e.g., 'redis-integration'). - The slug of the documentation page (e.g., 'redis-integration'). + Slug stránky dokumentace (např. redis-integration) diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ko.xlf index 9fb30f727db..ceeab3cd7df 100644 --- a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ko.xlf @@ -4,77 +4,77 @@ Browse and search Aspire documentation from aspire.dev. - Browse and search Aspire documentation from aspire.dev. + aspire.dev에서 Aspire 설명서를 찾아보고 검색하세요. Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. - Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + 설명서 페이지 '{0}'을(를) 찾을 수 없습니다. 사용 가능한 페이지를 보려면 'aspire docs list'를 사용하세요. Output format (Table or Json). - Output format (Table or Json). + 출력 형식(테이블 또는 JSON)입니다. Found {0} documentation pages. - Found {0} documentation pages. + {0}개의 설명서 페이지를 찾았습니다. Found {0} results for '{1}'. - Found {0} results for '{1}'. + '{1}'에 대한 {0}개의 결과를 찾았습니다. Get the full content of a documentation page by its slug. - Get the full content of a documentation page by its slug. + 슬러그로 설명서 페이지의 전체 내용을 가져옵니다. Maximum number of search results to return (default: 5, max: 10). - Maximum number of search results to return (default: 5, max: 10). + 반환할 최대 검색 결과 수입니다(기본값: 5, 최대: 10). List all available Aspire documentation pages. - List all available Aspire documentation pages. + 사용 가능한 모든 Aspire 설명서 페이지를 나열합니다. Loading documentation... - Loading documentation... + 설명서를 로드하는 중... No documentation available. The aspire.dev docs may not have loaded correctly. - No documentation available. The aspire.dev docs may not have loaded correctly. + 사용할 수 있는 설명서가 없습니다. aspire.dev 문서가 제대로 로드되지 않았을 수 있습니다. No results found for '{0}'. Try different search terms. - No results found for '{0}'. Try different search terms. + '{0}'에 대한 결과를 찾을 수 없습니다. 다른 검색어를 사용해 보세요. The search query. - The search query. + 검색 쿼리입니다. Search Aspire documentation by keywords. - Search Aspire documentation by keywords. + 키워드로 Aspire 설명서를 검색하세요. Return only the specified section of the page. - Return only the specified section of the page. + 페이지의 지정된 섹션만 반환합니다. The slug of the documentation page (e.g., 'redis-integration'). - The slug of the documentation page (e.g., 'redis-integration'). + 설명서 페이지의 슬러그(예: 'redis-integration')입니다. diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pt-BR.xlf index cfad7c83523..7c90f9a05eb 100644 --- a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pt-BR.xlf @@ -14,7 +14,7 @@ Output format (Table or Json). - Output format (Table or Json). + Formato de saída (Tabela ou Json). diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ru.xlf index c0415dda9ef..0cc6c6679fc 100644 --- a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ru.xlf @@ -4,77 +4,77 @@ Browse and search Aspire documentation from aspire.dev. - Browse and search Aspire documentation from aspire.dev. + Просмотр документации Aspire и поиск в ней на aspire.dev. Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. - Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Страница документации "{0}" не найдена. Чтобы увидеть, какие страницы доступны, используйте команду "aspire docs list". Output format (Table or Json). - Output format (Table or Json). + Формат вывода (таблица или JSON). Found {0} documentation pages. - Found {0} documentation pages. + Найдено страниц документации: {0}. Found {0} results for '{1}'. - Found {0} results for '{1}'. + По запросу "{1}" найдено результатов: {0}. Get the full content of a documentation page by its slug. - Get the full content of a documentation page by its slug. + Получить полное содержимое страницы документации по её динамическому идентификатору. Maximum number of search results to return (default: 5, max: 10). - Maximum number of search results to return (default: 5, max: 10). + Максимальное количество результатов поиска, которые следует вернуть (по умолчанию: 5, максимум: 10). List all available Aspire documentation pages. - List all available Aspire documentation pages. + Выдать список всех доступных страниц документации по Aspire. Loading documentation... - Loading documentation... + Документация загружается... No documentation available. The aspire.dev docs may not have loaded correctly. - No documentation available. The aspire.dev docs may not have loaded correctly. + Документации нет. Возможно, документация по aspire.dev некорректно загрузилась. No results found for '{0}'. Try different search terms. - No results found for '{0}'. Try different search terms. + По запросу "{0}" ничего не найдено. Попробуйте искать по другим ключевым словам. The search query. - The search query. + Поисковый запрос. Search Aspire documentation by keywords. - Search Aspire documentation by keywords. + Поиск в документации Aspire по ключевым словам. Return only the specified section of the page. - Return only the specified section of the page. + Возвращать только указанный раздел страницы. The slug of the documentation page (e.g., 'redis-integration'). - The slug of the documentation page (e.g., 'redis-integration'). + Идентификатор страницы документации (например, "redis-integration"). diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hant.xlf index ec689b24219..9a64bcde8fd 100644 --- a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hant.xlf @@ -4,77 +4,77 @@ Browse and search Aspire documentation from aspire.dev. - Browse and search Aspire documentation from aspire.dev. + 瀏覽並搜尋 aspire.dev 上的 Aspire 文件。 Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. - Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + 找不到文件頁面 '{0}'。請使用 'aspire docs list' 查看可用頁面。 Output format (Table or Json). - Output format (Table or Json). + 輸出格式 (資料表或 Json)。 Found {0} documentation pages. - Found {0} documentation pages. + 找到 {0} 文件頁面。 Found {0} results for '{1}'. - Found {0} results for '{1}'. + 找到 {0} 個 '{1}' 個結果。 Get the full content of a documentation page by its slug. - Get the full content of a documentation page by its slug. + 依據識別字串取得文件頁面的完整內容。 Maximum number of search results to return (default: 5, max: 10). - Maximum number of search results to return (default: 5, max: 10). + 最多回傳的搜尋結果數量 (預設: 5,最大值: 10)。 List all available Aspire documentation pages. - List all available Aspire documentation pages. + 列出所有可用的 Aspire 文件頁面。 Loading documentation... - Loading documentation... + 正在載入檔案... No documentation available. The aspire.dev docs may not have loaded correctly. - No documentation available. The aspire.dev docs may not have loaded correctly. + 沒有可用的文件。aspire.dev 文件可能未正確載入。 No results found for '{0}'. Try different search terms. - No results found for '{0}'. Try different search terms. + 找不到 '{0}' 的結果。請嘗試不同的搜尋字詞。 The search query. - The search query. + 搜尋查詢。 Search Aspire documentation by keywords. - Search Aspire documentation by keywords. + 依關鍵字搜尋 Aspire 文件。 Return only the specified section of the page. - Return only the specified section of the page. + 僅回傳頁面指定的區段。 The slug of the documentation page (e.g., 'redis-integration'). - The slug of the documentation page (e.g., 'redis-integration'). + 文件頁面的識別字串 (例如 'redis-integration')。 diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf index fe0a04c4f4d..0609c666d11 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf @@ -4,62 +4,62 @@ Execute a command on a resource. - Execute a command on a resource. + Spusťte příkaz pro prostředek. The name of the command to execute. - The name of the command to execute. + Název příkazu, který se má spustit The name of the resource to execute the command on. - The name of the resource to execute the command on. + Název prostředku, na kterém se má příkaz provést No in-scope AppHosts found. Showing all running AppHosts. - No in-scope AppHosts found. Showing all running AppHosts. + Nebyli nalezeni žádní hostitelé aplikací v daném oboru. Zobrazují se všichni spuštění hostitelé aplikací. No running AppHosts found. - No running AppHosts found. + Nebyl nalezen žádný spuštěný hostitel aplikací. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Cesta k souboru projektu Aspire AppHost. Restart a running resource. - Restart a running resource. + Restartujte spuštěný prostředek. The name of the resource to restart. - The name of the resource to restart. + Název prostředku, který se má restartovat Scanning for running AppHosts... - Scanning for running AppHosts... + Vyhledávání spuštěných hostitelů aplikací... Select which AppHost to connect to: - Select which AppHost to connect to: + Vyberte hostitele aplikace, ke kterému se chcete připojit: Start a stopped resource. - Start a stopped resource. + Spusťte zastavený prostředek. The name of the resource to start. - The name of the resource to start. + Název prostředku, který se má spustit diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf index e739f22fd4a..17b4fe1faa6 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf @@ -24,12 +24,12 @@ No running AppHosts found. - No running AppHosts found. + Es wurden keine ausgeführten AppHosts gefunden. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Der Pfad zur Aspire AppHost-Projektdatei. @@ -44,7 +44,7 @@ Scanning for running AppHosts... - Scanning for running AppHosts... + Suche nach aktiven AppHosts … diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf index a5de6775537..bb9f94bebab 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf @@ -24,12 +24,12 @@ No running AppHosts found. - No running AppHosts found. + No se encontraron AppHosts en ejecución. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + La ruta de acceso al archivo del proyecto host de la AppHost Aspire. @@ -44,7 +44,7 @@ Scanning for running AppHosts... - Scanning for running AppHosts... + Buscando AppHosts en ejecución... diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf index dd2e07680fb..e64214b443f 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf @@ -24,12 +24,12 @@ No running AppHosts found. - No running AppHosts found. + Désolé, aucun AppHost en cours d’exécution n’a été trouvé. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Chemin d’accès au fichier projet AppHost Aspire. @@ -44,7 +44,7 @@ Scanning for running AppHosts... - Scanning for running AppHosts... + Recherche des AppHosts en cours d’exécution... diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf index 907a0dadf05..dd5ec8b2c03 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf @@ -24,12 +24,12 @@ No running AppHosts found. - No running AppHosts found. + Non sono stati trovati AppHost in esecuzione. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Percorso del file di un progetto AppHost di Aspire. @@ -44,7 +44,7 @@ Scanning for running AppHosts... - Scanning for running AppHosts... + Analisi per l'esecuzione di AppHosts in corso... diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf index 237a2c6e770..73a1a93effb 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf @@ -24,12 +24,12 @@ No running AppHosts found. - No running AppHosts found. + 実行中の AppHost が見つかりません。 The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost プロジェクト ファイルへのパス。 @@ -44,7 +44,7 @@ Scanning for running AppHosts... - Scanning for running AppHosts... + 実行中の AppHost をスキャンしています... diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf index 37bc26221ed..3b6f05348bd 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf @@ -4,62 +4,62 @@ Execute a command on a resource. - Execute a command on a resource. + 리소스에서 명령을 실행합니다. The name of the command to execute. - The name of the command to execute. + 실행할 명령의 이름입니다. The name of the resource to execute the command on. - The name of the resource to execute the command on. + 명령을 실행할 대상 리소스의 이름입니다. No in-scope AppHosts found. Showing all running AppHosts. - No in-scope AppHosts found. Showing all running AppHosts. + 범위 내 AppHost를 찾을 수 없습니다. 실행 중인 모든 AppHost를 표시하는 중입니다. No running AppHosts found. - No running AppHosts found. + 실행 중인 AppHost를 찾을 수 없습니다. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost 프로젝트 파일의 경로입니다. Restart a running resource. - Restart a running resource. + 실행 중인 리소스를 다시 시작합니다. The name of the resource to restart. - The name of the resource to restart. + 다시 시작할 리소스의 이름입니다. Scanning for running AppHosts... - Scanning for running AppHosts... + 실행 중인 AppHost를 검색하는 중... Select which AppHost to connect to: - Select which AppHost to connect to: + 연결할 AppHost 선택: Start a stopped resource. - Start a stopped resource. + 중지된 리소스를 시작합니다. The name of the resource to start. - The name of the resource to start. + 시작할 리소스의 이름입니다. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf index 0461ed7042f..d5f66e0e486 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf @@ -24,12 +24,12 @@ No running AppHosts found. - No running AppHosts found. + Nie znaleziono uruchomionych hostów aplikacji. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Ścieżka do pliku projektu hosta AppHost platformy Aspire. @@ -44,7 +44,7 @@ Scanning for running AppHosts... - Scanning for running AppHosts... + Skanowanie w poszukiwaniu uruchomionych hostów aplikacji... diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf index fc1c5559858..d4e09d13b05 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf @@ -24,12 +24,12 @@ No running AppHosts found. - No running AppHosts found. + Nenhum AppHosts em execução encontrado. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + O caminho para o arquivo de projeto do Aspire AppHost. @@ -44,7 +44,7 @@ Scanning for running AppHosts... - Scanning for running AppHosts... + Verificando se há AppHosts em execução... diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf index 1244b5f428a..6338b30ecfd 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf @@ -4,62 +4,62 @@ Execute a command on a resource. - Execute a command on a resource. + Выполнить команду на ресурсе. The name of the command to execute. - The name of the command to execute. + Имя команды, которую нужно выполнить. The name of the resource to execute the command on. - The name of the resource to execute the command on. + Имя целевого ресурса, на котором будет выполняться команда. No in-scope AppHosts found. Showing all running AppHosts. - No in-scope AppHosts found. Showing all running AppHosts. + Не найдено ни одного AppHost в области действия. Показаны все выполняющиеся AppHost. No running AppHosts found. - No running AppHosts found. + Запущенные appHosts не найдены. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Путь к файлу проекта Aspire AppHost. Restart a running resource. - Restart a running resource. + Перезапустить уже запущенный ресурс. The name of the resource to restart. - The name of the resource to restart. + Имя ресурса, который нужно перезапустить. Scanning for running AppHosts... - Scanning for running AppHosts... + Выполняется сканирование на наличие запущенных хостов приложений... Select which AppHost to connect to: - Select which AppHost to connect to: + Выберите AppHost, к которому нужно подключиться: Start a stopped resource. - Start a stopped resource. + Запустить остановленный ресурс. The name of the resource to start. - The name of the resource to start. + Имя ресурса, который нужно запустить. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf index f9f70c8d8f1..9f8bf7d5597 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf @@ -24,12 +24,12 @@ No running AppHosts found. - No running AppHosts found. + Çalışan AppHost bulunamadı. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost proje dosyasının yolu. @@ -44,7 +44,7 @@ Scanning for running AppHosts... - Scanning for running AppHosts... + Çalışan AppHost'lar taranıyor... diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf index 1c9182cd8c0..80c7cceb347 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf @@ -24,12 +24,12 @@ No running AppHosts found. - No running AppHosts found. + 未找到正在运行的 AppHost。 The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost 项目文件的路径。 @@ -44,7 +44,7 @@ Scanning for running AppHosts... - Scanning for running AppHosts... + 正在扫描处于运行状态的 AppHost... diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf index 212bfdcdff0..04d14e0d03c 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf @@ -4,62 +4,62 @@ Execute a command on a resource. - Execute a command on a resource. + 在資源上執行命令。 The name of the command to execute. - The name of the command to execute. + 所要執行命令的名稱。 The name of the resource to execute the command on. - The name of the resource to execute the command on. + 要執行命令的資源名稱。 No in-scope AppHosts found. Showing all running AppHosts. - No in-scope AppHosts found. Showing all running AppHosts. + 未找到符合範圍的 AppHost。顯示所有正在執行的 AppHost。 No running AppHosts found. - No running AppHosts found. + 找不到正在執行的 AppHost。 The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost 專案檔案的路徑。 Restart a running resource. - Restart a running resource. + 重新啟動正在執行的資源。 The name of the resource to restart. - The name of the resource to restart. + 要重新啟動的資源名稱。 Scanning for running AppHosts... - Scanning for running AppHosts... + 正在掃描執行中的 AppHost... Select which AppHost to connect to: - Select which AppHost to connect to: + 選取要連接的 AppHost: Start a stopped resource. - Start a stopped resource. + 啟動已停止的資源。 The name of the resource to start. - The name of the resource to start. + 要啟動的資源名稱。 diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf index 4bd0885db3d..d66eaa8362b 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf @@ -4,17 +4,17 @@ Display the animated Aspire CLI welcome banner. - Display the animated Aspire CLI welcome banner. + Umožňuje zobrazit animovaný úvodní banner rozhraní příkazového řádku Aspire. CLI — version {0} - CLI — version {0} + CLI – verze {0} Welcome to the - Welcome to the + Vítá vás diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf index 64c08f340c2..89bf02fc9db 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf @@ -4,17 +4,17 @@ Display the animated Aspire CLI welcome banner. - Display the animated Aspire CLI welcome banner. + Muestra el banner de bienvenida animado de la CLI de Aspire. CLI — version {0} - CLI — version {0} + CLI: versión {0} Welcome to the - Welcome to the + Le damos la bienvenida al diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf index ad47b392aff..e1ca384b128 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf @@ -4,17 +4,17 @@ Display the animated Aspire CLI welcome banner. - Display the animated Aspire CLI welcome banner. + Afficher la bannière d’accueil animée Aspire CLI. CLI — version {0} - CLI — version {0} + CLI – version {0} Welcome to the - Welcome to the + Bienvenue dans le diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf index 8ef3d18cdff..8dc9f4cfde0 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf @@ -4,17 +4,17 @@ Display the animated Aspire CLI welcome banner. - Display the animated Aspire CLI welcome banner. + アニメーション化された Aspire CLI のウェルカム バナーを表示します。 CLI — version {0} - CLI — version {0} + CLI — バージョン {0} Welcome to the - Welcome to the + ようこそ: diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf index a8b684e4d37..b6e49aec70e 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf @@ -4,17 +4,17 @@ Display the animated Aspire CLI welcome banner. - Display the animated Aspire CLI welcome banner. + 애니메이션 Aspire CLI 환영 배너를 표시합니다. CLI — version {0} - CLI — version {0} + CLI — 버전 {0} Welcome to the - Welcome to the + 시작: diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf index 636de9f5e9b..f6c37412418 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf @@ -4,17 +4,17 @@ Display the animated Aspire CLI welcome banner. - Display the animated Aspire CLI welcome banner. + Exibir a barra de notificação de boas-vindas animada da CLI Aspire. CLI — version {0} - CLI — version {0} + CLI — versão {0} Welcome to the - Welcome to the + Bem-vindo(a) à diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf index b93ff739025..7a78e841298 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf @@ -4,17 +4,17 @@ Display the animated Aspire CLI welcome banner. - Display the animated Aspire CLI welcome banner. + Отобразить анимированный приветственный баннер CLI Aspire. CLI — version {0} - CLI — version {0} + CLI — версия {0} Welcome to the - Welcome to the + Вас приветствует diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf index be24d24fb79..e4b8e3eca1f 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf @@ -4,17 +4,17 @@ Display the animated Aspire CLI welcome banner. - Display the animated Aspire CLI welcome banner. + Animasyonlu Aspire CLI karşılama başlığını görüntüleyin. CLI — version {0} - CLI — version {0} + CLI — sürüm {0} Welcome to the - Welcome to the + Hoş geldiniz diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf index bfdd7c94dff..f11e90f294e 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf @@ -4,17 +4,17 @@ Display the animated Aspire CLI welcome banner. - Display the animated Aspire CLI welcome banner. + 显示动画 Aspire CLI 欢迎横幅。 CLI — version {0} - CLI — version {0} + CLI - 版本 {0} Welcome to the - Welcome to the + 欢迎使用 diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf index d9ce77bbfdf..dde0092131d 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf @@ -4,17 +4,17 @@ Display the animated Aspire CLI welcome banner. - Display the animated Aspire CLI welcome banner. + 顯示動畫 Aspire CLI 的歡迎使用橫幅。 CLI — version {0} - CLI — version {0} + CLI — 版本 {0} Welcome to the - Welcome to the + 歡迎使用 diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf index 0a71d47b0af..968c090acd6 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf @@ -4,112 +4,112 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Nenašel se žádný spuštěný hostitel aplikací. Nejprve spusťte spuštění pomocí příkazu „aspire run“. Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Rozhraní API řídicího panelu není k dispozici. Ujistěte se, že hostitel aplikací běží s povoleným řídicím panelem. View telemetry data (logs, spans, traces) from a running Aspire application. - View telemetry data (logs, spans, traces) from a running Aspire application. + Umožňuje zobrazit telemetrická data (protokoly, rozsahy, trasování) ze spuštěné aplikace Aspire. Failed to fetch telemetry: {0} - Failed to fetch telemetry: {0} + Nepodařilo se načíst telemetrii: {0} Stream telemetry in real-time as it arrives. - Stream telemetry in real-time as it arrives. + Umožňuje streamovat telemetrii v reálném čase, jakmile dorazí. Output format (Table or Json). - Output format (Table or Json). + Výstupní formát (tabulka nebo JSON). Filter by error status (true to show only errors, false to exclude errors). - Filter by error status (true to show only errors, false to exclude errors). + Umožňuje filtrovat podle chybového stavu (true pro zobrazení pouze chyb, false pro vyloučení chyb). The --limit value must be a positive number. - The --limit value must be a positive number. + Hodnota --tail musí být kladné číslo. Maximum number of items to return. - Maximum number of items to return. + Maximální počet položek, které se mají vrátit. View structured logs from the Dashboard telemetry API. - View structured logs from the Dashboard telemetry API. + Umožňuje zobrazit strukturované protokoly z rozhraní API telemetrie řídicího panelu. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + V aktuálním adresáři se nenašli žádní hostitelé aplikací. Zobrazují se všichni spuštění hostitelé aplikací. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Cesta k souboru projektu Aspire AppHost. Filter by resource name. - Filter by resource name. + Filtrovat podle názvu prostředku. Scanning for running AppHosts... - Scanning for running AppHosts... + Vyhledávání spuštěných hostitelů aplikací... Select an AppHost: - Select an AppHost: + Vyberte hostitele aplikací: Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). - Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Umožňuje filtrovat protokoly podle minimální závažnosti (trasování, ladění, informace, upozornění, chyba, kritické). View spans from the Dashboard telemetry API. - View spans from the Dashboard telemetry API. + Umožňuje zobrazit rozsahy z rozhraní API telemetrie řídicího panelu. The trace ID to view. If not specified, lists all traces. - The trace ID to view. If not specified, lists all traces. + ID trasování, které se má zobrazit. Pokud není zadáno, vypíše všechna trasování. Filter by trace ID. - Filter by trace ID. + Umožňuje filtrovat podle ID trasování. Trace with ID '{0}' was not found. - Trace with ID '{0}' was not found. + Trasování s ID {0} nebylo nalezeno. View traces from the Dashboard telemetry API. - View traces from the Dashboard telemetry API. + Umožňuje zobrazit trasování z rozhraní API telemetrie řídicího panelu. Dashboard API returned unexpected content type. Expected JSON response. - Dashboard API returned unexpected content type. Expected JSON response. + Rozhraní API řídicího panelu vrátilo neočekávaný typ obsahu. Očekávala se odpověď JSON. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf index 35b6ae39020..5ccffb04d9b 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf @@ -4,7 +4,7 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Es wurde kein aktiver AppHost gefunden. Verwenden Sie zuerst „aspire run“, um einen zu starten. @@ -54,27 +54,27 @@ No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Im aktuellen Verzeichnis wurden keine AppHosts gefunden. Es werden alle aktiven AppHosts angezeigt. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Der Pfad zur Aspire AppHost-Projektdatei. Filter by resource name. - Filter by resource name. + Filtern Sie nach Ressourcennamen. Scanning for running AppHosts... - Scanning for running AppHosts... + Suche nach aktiven AppHosts … Select an AppHost: - Select an AppHost: + AppHost auswählen: diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf index 990c32ce810..aa5860c44fa 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf @@ -4,112 +4,112 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + No se encontró ningún AppHost en ejecución. Usa 'aspire run' para iniciar uno primero. Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + La API de panel no está disponible. Asegúrese de que AppHost se esté ejecutando con Panel habilitado. View telemetry data (logs, spans, traces) from a running Aspire application. - View telemetry data (logs, spans, traces) from a running Aspire application. + Ver los datos de telemetría (registros, intervalos, seguimientos) de una aplicación de Aspire en ejecución. Failed to fetch telemetry: {0} - Failed to fetch telemetry: {0} + Error al capturar la telemetría: {0} Stream telemetry in real-time as it arrives. - Stream telemetry in real-time as it arrives. + Transmitir datos de telemetría en tiempo real a medida que llegan. Output format (Table or Json). - Output format (Table or Json). + Formato de salida (tabla o JSON). Filter by error status (true to show only errors, false to exclude errors). - Filter by error status (true to show only errors, false to exclude errors). + Filtrar por estado de error (true para mostrar solo los errores, false para excluir errores). The --limit value must be a positive number. - The --limit value must be a positive number. + El valor --limit debe ser un número positivo. Maximum number of items to return. - Maximum number of items to return. + Número máximo de elementos que se van a devolver. View structured logs from the Dashboard telemetry API. - View structured logs from the Dashboard telemetry API. + Ver los registros estructurados desde la API de telemetría del panel. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + No se encontró ningún AppHosts en el directorio actual. Mostrando todos los AppHosts en ejecución. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + La ruta de acceso al archivo del proyecto host de la AppHost Aspire. Filter by resource name. - Filter by resource name. + Filtrar por nombre de recurso. Scanning for running AppHosts... - Scanning for running AppHosts... + Buscando AppHosts en ejecución... Select an AppHost: - Select an AppHost: + Seleccione un AppHost: Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). - Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filtrar los registros por gravedad mínima (Seguimiento, Depuración, Información, Advertencia, Error, Crítico). View spans from the Dashboard telemetry API. - View spans from the Dashboard telemetry API. + Ver intervalos de la API de telemetría del panel. The trace ID to view. If not specified, lists all traces. - The trace ID to view. If not specified, lists all traces. + El id. de rastreo que se desea ver. Si no se especifica, enumera todos los seguimientos. Filter by trace ID. - Filter by trace ID. + Filtre por id. de seguimiento. Trace with ID '{0}' was not found. - Trace with ID '{0}' was not found. + No se encontró el seguimiento con el id. "{0}". View traces from the Dashboard telemetry API. - View traces from the Dashboard telemetry API. + Ver seguimientos desde la API de telemetría del panel. Dashboard API returned unexpected content type. Expected JSON response. - Dashboard API returned unexpected content type. Expected JSON response. + La API de panel devolvió un tipo de contenido inesperado. Se esperaba una respuesta JSON. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf index 564772f1a0c..8eb389f527e 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf @@ -4,112 +4,112 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Désolé, aucun AppHost en cours d’exécution n’a été trouvé. Utilisez « aspire run » pour en démarrer un. Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Désolé, l’API du tableau de bord n’est pas disponible. Vérifiez que AppHost fonctionne avec le tableau de bord activé. View telemetry data (logs, spans, traces) from a running Aspire application. - View telemetry data (logs, spans, traces) from a running Aspire application. + Affichez les données de télémétrie (journaux, spans, traces) d’une application Aspire en cours d’exécution. Failed to fetch telemetry: {0} - Failed to fetch telemetry: {0} + Échec de la récupération de télémétrie : {0} Stream telemetry in real-time as it arrives. - Stream telemetry in real-time as it arrives. + Diffuser la télémétrie en temps réel dès son arrivée. Output format (Table or Json). - Output format (Table or Json). + Format de sortie (Tableau ou JSON). Filter by error status (true to show only errors, false to exclude errors). - Filter by error status (true to show only errors, false to exclude errors). + Filtrer selon le statut d’erreur (true pour afficher uniquement les erreurs, false pour exclure les erreurs). The --limit value must be a positive number. - The --limit value must be a positive number. + La valeur --limit doit être un nombre positif. Maximum number of items to return. - Maximum number of items to return. + Nombre maximal d’éléments à retourner. View structured logs from the Dashboard telemetry API. - View structured logs from the Dashboard telemetry API. + Afficher les journaux structurés via l’API de télémétrie du tableau de bord. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Désolé, aucun AppHosts n’a été trouvé dans le répertoire actif. Affichage de tous les AppHosts en cours d’exécution. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Chemin d’accès au fichier projet AppHost Aspire. Filter by resource name. - Filter by resource name. + Filtrer par nom de ressource. Scanning for running AppHosts... - Scanning for running AppHosts... + Recherche des AppHosts en cours d’exécution... Select an AppHost: - Select an AppHost: + Sélectionner un AppHost : Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). - Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filtrer les journaux selon la gravité minimale (Trace, Debug, Information, Warning, Error, Critical). View spans from the Dashboard telemetry API. - View spans from the Dashboard telemetry API. + Afficher les intervalles à partir de l’API de télémétrie du tableau de bord. The trace ID to view. If not specified, lists all traces. - The trace ID to view. If not specified, lists all traces. + ID de trace à afficher. Si aucun n’est spécifié, liste toutes les traces. Filter by trace ID. - Filter by trace ID. + Filtrer par ID de trace. Trace with ID '{0}' was not found. - Trace with ID '{0}' was not found. + La trace avec l’ID « {0} » n’a pas été trouvée. View traces from the Dashboard telemetry API. - View traces from the Dashboard telemetry API. + Afficher les traces via l’API de télémétrie du tableau de bord. Dashboard API returned unexpected content type. Expected JSON response. - Dashboard API returned unexpected content type. Expected JSON response. + L’API du tableau de bord a renvoyé un type de contenu inattendu. Une réponse JSON est attendue. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf index e521d0f1938..a8fd5bd765f 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf @@ -4,7 +4,7 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Non è stato trovato alcun AppHost in esecuzione. Usare prima di tutto "aspire run" per avviarne uno. @@ -54,27 +54,27 @@ No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Nessun AppHost trovato nella directory corrente. Visualizzazione di tutti gli AppHost in esecuzione. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Percorso del file di un progetto AppHost di Aspire. Filter by resource name. - Filter by resource name. + Filtrare per nome della risorsa. Scanning for running AppHosts... - Scanning for running AppHosts... + Analisi per l'esecuzione di AppHosts in corso... Select an AppHost: - Select an AppHost: + Selezionare un AppHost: diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf index b1788796279..9449eef10ba 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf @@ -4,112 +4,112 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + 実行中の AppHost は見つかりません。最初に 'aspire run' を使って起動してください。 Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + ダッシュボード API は利用できません。ダッシュボードが有効になっている状態で AppHost が実行されていることを確認します。 View telemetry data (logs, spans, traces) from a running Aspire application. - View telemetry data (logs, spans, traces) from a running Aspire application. + 実行中の Aspire アプリケーションのテレメトリ データ (ログ、スパン、トレース) を表示します。 Failed to fetch telemetry: {0} - Failed to fetch telemetry: {0} + テレメトリをフェッチできませんでした: {0} Stream telemetry in real-time as it arrives. - Stream telemetry in real-time as it arrives. + テレメトリが到着すると、リアルタイムでストリーミングします。 Output format (Table or Json). - Output format (Table or Json). + 出力形式 (テーブルまたは JSON)。 Filter by error status (true to show only errors, false to exclude errors). - Filter by error status (true to show only errors, false to exclude errors). + エラーの状態でフィルター処理します (エラーのみを表示する場合は true、エラーを除外するには false)。 The --limit value must be a positive number. - The --limit value must be a positive number. + --limit の値は正の数値である必要があります。 Maximum number of items to return. - Maximum number of items to return. + 返される項目の最大数。 View structured logs from the Dashboard telemetry API. - View structured logs from the Dashboard telemetry API. + ダッシュボード テレメトリ API から構造化ログを表示します。 No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + 現在のディレクトリ内に AppHost が見つかりません。実行中のすべての AppHost を表示しています。 The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost プロジェクト ファイルへのパス。 Filter by resource name. - Filter by resource name. + リソース名でフィルター処理します。 Scanning for running AppHosts... - Scanning for running AppHosts... + 実行中の AppHost をスキャンしています... Select an AppHost: - Select an AppHost: + AppHost を選択: Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). - Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + 最小重大度 (トレース、デバッグ、情報、警告、エラー、重大) でログをフィルター処理します。 View spans from the Dashboard telemetry API. - View spans from the Dashboard telemetry API. + ダッシュボード テレメトリ API からスパンを表示します。 The trace ID to view. If not specified, lists all traces. - The trace ID to view. If not specified, lists all traces. + 表示するトレース ID。指定しない場合は、すべてのトレースが一覧表示されます。 Filter by trace ID. - Filter by trace ID. + トレース ID でフィルター処理します。 Trace with ID '{0}' was not found. - Trace with ID '{0}' was not found. + ID '{0}' のトレースが見つかりませんでした。 View traces from the Dashboard telemetry API. - View traces from the Dashboard telemetry API. + ダッシュボード テレメトリ API からトレースを表示します。 Dashboard API returned unexpected content type. Expected JSON response. - Dashboard API returned unexpected content type. Expected JSON response. + ダッシュボード API から予期しないコンテンツ タイプが返されました。JSON 応答が必要です。 diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf index 091342ab9ca..68a721287d1 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf @@ -4,112 +4,112 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + 실행 중인 AppHost를 찾을 수 없습니다. 'aspire run'을 사용하여 먼저 하나를 시작합니다. Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + 대시보드 API를 사용할 수 없습니다. AppHost가 대시보드를 활성화한 상태로 실행 중인지 확인하세요. View telemetry data (logs, spans, traces) from a running Aspire application. - View telemetry data (logs, spans, traces) from a running Aspire application. + 실행 중인 Aspire 애플리케이션의 원격 분석 데이터(로그, 범위, 추적)를 확인합니다. Failed to fetch telemetry: {0} - Failed to fetch telemetry: {0} + 원격 분석을 가져오지 못했습니다. {0} Stream telemetry in real-time as it arrives. - Stream telemetry in real-time as it arrives. + 원격 분석 데이터를 실시간으로 스트림합니다. Output format (Table or Json). - Output format (Table or Json). + 출력 형식(테이블 또는 JSON)입니다. Filter by error status (true to show only errors, false to exclude errors). - Filter by error status (true to show only errors, false to exclude errors). + 오류 상태로 필터링합니다(true면 오류만, false면 오류 제외). The --limit value must be a positive number. - The --limit value must be a positive number. + --limit 값은 양수여야 합니다. Maximum number of items to return. - Maximum number of items to return. + 반환할 최대 항목 수입니다. View structured logs from the Dashboard telemetry API. - View structured logs from the Dashboard telemetry API. + 대시보드 원격 분석 API에서 구조화된 로그를 확인합니다. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + 현재 디렉터리에 AppHost가 없습니다. 실행 중인 모든 AppHost를 표시하는 중입니다. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost 프로젝트 파일의 경로입니다. Filter by resource name. - Filter by resource name. + 리소스 이름으로 필터링 Scanning for running AppHosts... - Scanning for running AppHosts... + 실행 중인 AppHost를 검색하는 중... Select an AppHost: - Select an AppHost: + AppHost 선택: Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). - Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + 최소 심각도(Trace, Debug, Information, Warning, Error, Critical)로 로그를 필터링합니다. View spans from the Dashboard telemetry API. - View spans from the Dashboard telemetry API. + 대시보드 원격 분석 API에서 범위를 확인합니다. The trace ID to view. If not specified, lists all traces. - The trace ID to view. If not specified, lists all traces. + 조회할 추적 ID입니다. 지정하지 않으면 모든 추적이 나열됩니다. Filter by trace ID. - Filter by trace ID. + 추적 ID로 필터링합니다. Trace with ID '{0}' was not found. - Trace with ID '{0}' was not found. + ID가 '{0}'인 추적을 찾을 수 없습니다. View traces from the Dashboard telemetry API. - View traces from the Dashboard telemetry API. + 대시보드 원격 분석 API에서 추적을 확인하세요. Dashboard API returned unexpected content type. Expected JSON response. - Dashboard API returned unexpected content type. Expected JSON response. + 대시보드 API에서 예상치 못한 콘텐츠 형식이 반환되었습니다. JSON 응답이 필요합니다. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf index 52e108c29b3..917b9556d59 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf @@ -4,7 +4,7 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Nie znaleziono uruchomionego hosta aplikacji. Najpierw uruchom go poleceniem „aspire run”. @@ -54,27 +54,27 @@ No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Nie znaleziono hostów aplikacji w bieżącym katalogu. Wyświetlanie wszystkich uruchomionych hostów aplikacji. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Ścieżka do pliku projektu hosta AppHost platformy Aspire. Filter by resource name. - Filter by resource name. + Filtruj według nazwy zasobu. Scanning for running AppHosts... - Scanning for running AppHosts... + Skanowanie w poszukiwaniu uruchomionych hostów aplikacji... Select an AppHost: - Select an AppHost: + Wybierz hosta aplikacji: diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf index 695853fa7be..46aa2a2640d 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf @@ -4,112 +4,112 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Nenhum AppHost em execução encontrado. Use "aspire run" para iniciar um primeiro. Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + A API do painel não está disponível. Verifique se o AppHost está em execução com o Painel habilitado. View telemetry data (logs, spans, traces) from a running Aspire application. - View telemetry data (logs, spans, traces) from a running Aspire application. + Veja os dados de telemetria (logs, spans, rastreamentos) de um aplicativo Aspire em execução. Failed to fetch telemetry: {0} - Failed to fetch telemetry: {0} + Falha ao buscar telemetria: {0} Stream telemetry in real-time as it arrives. - Stream telemetry in real-time as it arrives. + Transmita telemetria em tempo real conforme ela chega. Output format (Table or Json). - Output format (Table or Json). + Formato de saída (Tabela ou Json). Filter by error status (true to show only errors, false to exclude errors). - Filter by error status (true to show only errors, false to exclude errors). + Filtrar por status de erro (true para mostrar apenas erros, false para excluir erros). The --limit value must be a positive number. - The --limit value must be a positive number. + O valor --limit deve ser um número positivo. Maximum number of items to return. - Maximum number of items to return. + Número máximo de itens a serem retornados. View structured logs from the Dashboard telemetry API. - View structured logs from the Dashboard telemetry API. + Veja os logs estruturados da API de telemetria do Painel. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Nenhum AppHosts encontrado no diretório atual. Mostrando todos os AppHosts em execução. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + O caminho para o arquivo de projeto do Aspire AppHost. Filter by resource name. - Filter by resource name. + Filtre por nome de recurso. Scanning for running AppHosts... - Scanning for running AppHosts... + Verificando se há AppHosts em execução... Select an AppHost: - Select an AppHost: + Selecione um AppHost: Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). - Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filtre os logs por severidade mínima (Rastreamento, Depuração, Informações, Aviso, Erro, Crítico). View spans from the Dashboard telemetry API. - View spans from the Dashboard telemetry API. + Veja os spans da API de telemetria do Painel. The trace ID to view. If not specified, lists all traces. - The trace ID to view. If not specified, lists all traces. + A ID de rastreamento a ser visualizada. Se não for especificado, listará todos os rastreamentos. Filter by trace ID. - Filter by trace ID. + Filtrar por ID de rastreamento. Trace with ID '{0}' was not found. - Trace with ID '{0}' was not found. + O rastreamento com a ID ''{0}'' não foi encontrado. View traces from the Dashboard telemetry API. - View traces from the Dashboard telemetry API. + Veja os rastreamentos da API de telemetria do Painel. Dashboard API returned unexpected content type. Expected JSON response. - Dashboard API returned unexpected content type. Expected JSON response. + A API do painel retornou um tipo de conteúdo inesperado. Resposta JSON esperada. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf index 975f0595226..e5c9e2e1315 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf @@ -4,112 +4,112 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Запущенные хосты приложений не найдены. Сначала запустите один из них с помощью команды "aspire run". Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + API панели мониторинга недоступен. Убедитесь, что AppHost запущен с включенной панелью мониторинга. View telemetry data (logs, spans, traces) from a running Aspire application. - View telemetry data (logs, spans, traces) from a running Aspire application. + Просмотр данных телеметрии (журналы, диапазоны, трассировки) из запущенного приложения Aspire. Failed to fetch telemetry: {0} - Failed to fetch telemetry: {0} + Не удалось получить телеметрию: {0} Stream telemetry in real-time as it arrives. - Stream telemetry in real-time as it arrives. + Потоковая передача телеметрии в реальном времени по мере поступления. Output format (Table or Json). - Output format (Table or Json). + Формат вывода (таблица или JSON). Filter by error status (true to show only errors, false to exclude errors). - Filter by error status (true to show only errors, false to exclude errors). + Фильтрация по состоянию ошибки (true — показывать только ошибки, false — исключать ошибки). The --limit value must be a positive number. - The --limit value must be a positive number. + Значение --limit должно быть положительным числом. Maximum number of items to return. - Maximum number of items to return. + Максимальное возвращаемое количество элементов. View structured logs from the Dashboard telemetry API. - View structured logs from the Dashboard telemetry API. + Просмотр структурированных журналов через API телеметрии панели мониторинга. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Хосты приложений не найдены в текущем каталоге. Отображаются все запущенные хосты приложений. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Путь к файлу проекта Aspire AppHost. Filter by resource name. - Filter by resource name. + Фильтровать по имени ресурса. Scanning for running AppHosts... - Scanning for running AppHosts... + Выполняется сканирование на наличие запущенных хостов приложений... Select an AppHost: - Select an AppHost: + Выберите хост приложения: Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). - Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Фильтрация журналов по минимальному уровню серьезности (трассировка, отладка, информация, предупреждение, ошибка, критическое). View spans from the Dashboard telemetry API. - View spans from the Dashboard telemetry API. + Просмотр диапазонов через API телеметрии панели мониторинга. The trace ID to view. If not specified, lists all traces. - The trace ID to view. If not specified, lists all traces. + Идентификатор трассировки для просмотра. Если не указан, отображаются все трассировки. Filter by trace ID. - Filter by trace ID. + Фильтровать по идентификатору трассировки. Trace with ID '{0}' was not found. - Trace with ID '{0}' was not found. + Не удалось найти трассировку с идентификатором "{0}". View traces from the Dashboard telemetry API. - View traces from the Dashboard telemetry API. + Просмотр трассировок через API телеметрии панели мониторинга. Dashboard API returned unexpected content type. Expected JSON response. - Dashboard API returned unexpected content type. Expected JSON response. + API панели мониторинга вернул неожиданный тип содержимого. Ожидался ответ JSON. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf index da3f4f29b3d..6c448597e04 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf @@ -4,112 +4,112 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + Çalışan AppHost bulunamadı. Önce birini başlatmak için 'aspire run' komutunu kullanın. Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Pano API'si kullanılamıyor. AppHost'un Pano özelliği etkin olarak çalıştığından emin olun. View telemetry data (logs, spans, traces) from a running Aspire application. - View telemetry data (logs, spans, traces) from a running Aspire application. + Çalışan bir Aspire uygulamasından telemetri verilerini (günlükler, yayılmalar, izlemeler) görüntüleyin. Failed to fetch telemetry: {0} - Failed to fetch telemetry: {0} + Telemetri getirilemedi: {0} Stream telemetry in real-time as it arrives. - Stream telemetry in real-time as it arrives. + Telemetri geldikçe gerçek zamanlı olarak akışını yapın. Output format (Table or Json). - Output format (Table or Json). + Çıktı biçimi (Tablo veya Json). Filter by error status (true to show only errors, false to exclude errors). - Filter by error status (true to show only errors, false to exclude errors). + Hata durumuna göre filtreleyin (yalnızca hataları göstermek için true, hataları hariç tutmak için false). The --limit value must be a positive number. - The --limit value must be a positive number. + --limit değeri, pozitif bir sayı olmalıdır. Maximum number of items to return. - Maximum number of items to return. + Döndürülecek maksimum öğe sayısı. View structured logs from the Dashboard telemetry API. - View structured logs from the Dashboard telemetry API. + Pano telemetri API'sinden yapılandırılmış günlükleri görüntüleyin. No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + Geçerli dizinde AppHost bulunamadı. Tüm çalışan AppHost'lar gösteriliyor. The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost proje dosyasının yolu. Filter by resource name. - Filter by resource name. + Kaynak adına göre filtreleyin. Scanning for running AppHosts... - Scanning for running AppHosts... + Çalışan AppHost'lar taranıyor... Select an AppHost: - Select an AppHost: + AppHost seçin: Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). - Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Günlükleri en düşük önem derecesine göre filtreleyin (İzleme, Hata Ayıklama, Bilgi, Uyarı, Hata, Kritik). View spans from the Dashboard telemetry API. - View spans from the Dashboard telemetry API. + Pano telemetri API'sinden yayılmaları görüntüleyin. The trace ID to view. If not specified, lists all traces. - The trace ID to view. If not specified, lists all traces. + Görüntülenecek izleme kimliği. Belirtilmezse, tüm izlemeler listelenir. Filter by trace ID. - Filter by trace ID. + İzleme kimliğine göre filtreleyin. Trace with ID '{0}' was not found. - Trace with ID '{0}' was not found. + Kimliği '{0}' olan bir izleme bulunamadı. View traces from the Dashboard telemetry API. - View traces from the Dashboard telemetry API. + Pano telemetri API'sinden izlemeleri görüntüleyin. Dashboard API returned unexpected content type. Expected JSON response. - Dashboard API returned unexpected content type. Expected JSON response. + Pano API'si beklenmeyen içerik türü döndürdü. JSON yanıtı bekleniyordu. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf index e181e404459..d33a07e9fd4 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf @@ -4,112 +4,112 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + 找不到正在运行的 AppHost。请先使用 "aspire run" 启动一个。 Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + 仪表板 API 不可用。请确保 AppHost 在运行时启用了仪表板。 View telemetry data (logs, spans, traces) from a running Aspire application. - View telemetry data (logs, spans, traces) from a running Aspire application. + 查看正在运行的 Aspire 应用程序中的遥测数据(日志、跨度、跟踪)。 Failed to fetch telemetry: {0} - Failed to fetch telemetry: {0} + 未能获取遥测数据: {0} Stream telemetry in real-time as it arrives. - Stream telemetry in real-time as it arrives. + 在遥测数据到来时,将其实时流式传输。 Output format (Table or Json). - Output format (Table or Json). + 输出格式(表格或 Json)。 Filter by error status (true to show only errors, false to exclude errors). - Filter by error status (true to show only errors, false to exclude errors). + 按错误状态筛选(true 表示仅显示错误,false 表示排除错误)。 The --limit value must be a positive number. - The --limit value must be a positive number. + --limit 值必须是正数。 Maximum number of items to return. - Maximum number of items to return. + 要返回的最大项目数。 View structured logs from the Dashboard telemetry API. - View structured logs from the Dashboard telemetry API. + 查看仪表板遥测 API 中的结构化日志。 No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + 当前目录中未找到 AppHost。显示所有正在运行的 AppHost。 The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost 项目文件的路径。 Filter by resource name. - Filter by resource name. + 按资源名称筛选。 Scanning for running AppHosts... - Scanning for running AppHosts... + 正在扫描处于运行状态的 AppHost... Select an AppHost: - Select an AppHost: + 选择 AppHost: Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). - Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + 按最低严重级别(跟踪、调试、信息、警告、错误、严重)筛选日志。 View spans from the Dashboard telemetry API. - View spans from the Dashboard telemetry API. + 查看仪表板遥测 API 中的跨度。 The trace ID to view. If not specified, lists all traces. - The trace ID to view. If not specified, lists all traces. + 要查看的跟踪 ID。如果未指定,则列出所有跟踪。 Filter by trace ID. - Filter by trace ID. + 按跟踪 ID 筛选。 Trace with ID '{0}' was not found. - Trace with ID '{0}' was not found. + 找不到 ID 为 "{0}" 的跟踪。 View traces from the Dashboard telemetry API. - View traces from the Dashboard telemetry API. + 查看仪表板遥测 API 中的跟踪。 Dashboard API returned unexpected content type. Expected JSON response. - Dashboard API returned unexpected content type. Expected JSON response. + 仪表板 API 返回了意外的内容类型。预期为 JSON 响应。 diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf index 805e844279a..6e33905d72a 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf @@ -4,112 +4,112 @@ No running AppHost found. Use 'aspire run' to start one first. - No running AppHost found. Use 'aspire run' to start one first. + 找不到正在執行的 AppHost。請先使用 'aspire run' 啟動一個。 Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + 儀表板 API 無法使用。確保 AppHost 執行中且儀表板功能已啟用。 View telemetry data (logs, spans, traces) from a running Aspire application. - View telemetry data (logs, spans, traces) from a running Aspire application. + 從執行中的 Aspire 應用程式檢視遙測資料 (記錄、範圍、追蹤)。 Failed to fetch telemetry: {0} - Failed to fetch telemetry: {0} + 無法擷取遙測: {0} Stream telemetry in real-time as it arrives. - Stream telemetry in real-time as it arrives. + 在遙測抵達時即時串流遙測。 Output format (Table or Json). - Output format (Table or Json). + 輸出格式 (資料表或 Json)。 Filter by error status (true to show only errors, false to exclude errors). - Filter by error status (true to show only errors, false to exclude errors). + 依錯誤狀態篩選 (true 以僅顯示錯誤,false 以排除錯誤)。 The --limit value must be a positive number. - The --limit value must be a positive number. + --limit 值必須是正數。 Maximum number of items to return. - Maximum number of items to return. + 要傳回的項目數上限。 View structured logs from the Dashboard telemetry API. - View structured logs from the Dashboard telemetry API. + 從儀表板遙測 API 檢視結構化記錄。 No AppHosts found in current directory. Showing all running AppHosts. - No AppHosts found in current directory. Showing all running AppHosts. + 在目前的目錄中找不到 AppHost。顯示所有正在執行的 AppHost。 The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. + Aspire AppHost 專案檔案的路徑。 Filter by resource name. - Filter by resource name. + 依資源名稱篩選。 Scanning for running AppHosts... - Scanning for running AppHosts... + 正在掃描執行中的 AppHost... Select an AppHost: - Select an AppHost: + 選取 AppHost: Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). - Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + 依最低嚴重性篩選記錄 (追蹤、偵錯、資訊、警告、錯誤、重大)。 View spans from the Dashboard telemetry API. - View spans from the Dashboard telemetry API. + 從儀表板遙測 API 檢視範圍。 The trace ID to view. If not specified, lists all traces. - The trace ID to view. If not specified, lists all traces. + 要檢視的追蹤識別碼。如果未指定,則列出所有追蹤。 Filter by trace ID. - Filter by trace ID. + 依追蹤識別碼篩選。 Trace with ID '{0}' was not found. - Trace with ID '{0}' was not found. + 找不到識別碼為 '{0}' 的追蹤。 View traces from the Dashboard telemetry API. - View traces from the Dashboard telemetry API. + 從儀表板遙測 API 檢視追蹤。 Dashboard API returned unexpected content type. Expected JSON response. - Dashboard API returned unexpected content type. Expected JSON response. + 儀表板 API 已傳回未預期的內容類型。預期的 JSON 回應。 diff --git a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.cs.xlf index 675d218a29b..779afb694ae 100644 --- a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.cs.xlf @@ -54,7 +54,7 @@ Sign into VS Code with a GitHub account that has a Copilot subscription. Follow the steps in <a href="{0}" target="_blank">Set up GitHub Copilot in VS Code</a>. - Přihlaste se k VS Code pomocí účtu GitHub, který má předplatné Copilotu. Postupujte podle pokynů v tématu věnovaném <a href="{0}" target="_blank">porovnání nastavení GitHub Copilotu ve VS Code</a>. + Přihlaste se k VS Code pomocí účtu GitHub, který má předplatné Copilota. Postupujte podle pokynů v tématu věnovaném <a href="{0}" target="_blank">porovnání nastavení GitHub Copilota ve VS Code</a>. {0} is a link diff --git a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.it.xlf b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.it.xlf index 48680ebdb6c..3dc8c92b787 100644 --- a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.it.xlf @@ -109,7 +109,7 @@ GitHub Copilot chat - Chat di GitHub Copilot + GitHub Copilot Chat @@ -164,7 +164,7 @@ GitHub Copilot chat - Chat di GitHub Copilot + GitHub Copilot Chat diff --git a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.tr.xlf index 4b7ad4f7187..586273b43ce 100644 --- a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.tr.xlf @@ -109,7 +109,7 @@ GitHub Copilot chat - GitHub Copilot sohbeti + GitHub Copilot Chat @@ -164,7 +164,7 @@ GitHub Copilot chat - GitHub Copilot sohbeti + GitHub Copilot Chat diff --git a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.zh-Hant.xlf index 7289d2a4709..676257632d2 100644 --- a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.zh-Hant.xlf @@ -109,7 +109,7 @@ GitHub Copilot chat - GitHub Copilot 聊天 + GitHub Copilot Chat @@ -164,7 +164,7 @@ GitHub Copilot chat - GitHub Copilot 聊天 + GitHub Copilot Chat diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf index 4e6e9517f23..4deec3220e3 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.cs.xlf @@ -29,37 +29,37 @@ Installation instructions - Installation instructions + Pokyny k instalaci Missing command - Missing command + Chybějící příkaz Required command '{0}' was not found on PATH or at the specified location. - Required command '{0}' was not found on PATH or at the specified location. + Požadovaný příkaz {0} nebyl nalezen v cestě PATH nebo v zadaném umístění. Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} - Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Požadovaný příkaz {0} nebyl nalezen v cestě PATH nebo v zadaném umístění. Pokyny k instalaci najdete tady: {1} Command '{0}' validation failed: {1} - Command '{0}' validation failed: {1} + Ověření příkazu {0} se nezdařilo: {1} Command '{0}' validation failed: {1}. For installation instructions, see: {2} - Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Ověření příkazu {0} se nezdařilo: {1}. Pokyny k instalaci najdete tady: {2} Resource '{0}' may fail to start: {1} - Resource '{0}' may fail to start: {1} + Spuštění prostředku {0} se nemusí podařit: {1} diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf index e81e5a146bf..76b6524613b 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.de.xlf @@ -29,37 +29,37 @@ Installation instructions - Installation instructions + Installationsanleitung Missing command - Missing command + Fehlender Befehl Required command '{0}' was not found on PATH or at the specified location. - Required command '{0}' was not found on PATH or at the specified location. + Der erforderliche Befehl „{0}“ wurde weder im PATH noch an dem angegebenen Speicherort gefunden. Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} - Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Der erforderliche Befehl „{0}“ wurde weder im PATH noch an dem angegebenen Speicherort gefunden. Anleitungen für die Installation finden Sie unter: {1} Command '{0}' validation failed: {1} - Command '{0}' validation failed: {1} + Fehler bei der Überprüfung des Befehls „{0}“: {1}. Command '{0}' validation failed: {1}. For installation instructions, see: {2} - Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Fehler bei der Überprüfung des Befehls „{0}“: {1}. Anleitungen für die Installation finden Sie unter: {2} Resource '{0}' may fail to start: {1} - Resource '{0}' may fail to start: {1} + Die Ressource „{0}“ kann möglicherweise nicht gestartet werden: {1} diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf index 366bb04f918..e2efab7237e 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.es.xlf @@ -29,37 +29,37 @@ Installation instructions - Installation instructions + Instrucciones de instalación Missing command - Missing command + Falta un comando Required command '{0}' was not found on PATH or at the specified location. - Required command '{0}' was not found on PATH or at the specified location. + No se encontró el comando necesario "{0}" en PATH o en la ubicación especificada. Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} - Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + No se encontró el comando necesario "{0}" en PATH o en la ubicación especificada. Para obtener instrucciones de instalación, consulte: {1} Command '{0}' validation failed: {1} - Command '{0}' validation failed: {1} + Error de validación del comando "{0}": {1} Command '{0}' validation failed: {1}. For installation instructions, see: {2} - Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Error de validación del comando "{0}": {1}. Para obtener instrucciones de instalación, consulte: {2} Resource '{0}' may fail to start: {1} - Resource '{0}' may fail to start: {1} + El recurso "{0}" puede no iniciarse: {1} diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf index c945e5dd9d3..ef6eb00bf54 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.fr.xlf @@ -29,37 +29,37 @@ Installation instructions - Installation instructions + Instructions d’installation Missing command - Missing command + Commande manquante Required command '{0}' was not found on PATH or at the specified location. - Required command '{0}' was not found on PATH or at the specified location. + La commande requise « {0} » est introuvable dans le CHEMIN ou à l’emplacement spécifié. Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} - Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + La commande requise « {0} » est introuvable dans le CHEMIN ou à l’emplacement spécifié. Pour les instructions d’installation manuelle, voir : {1} Command '{0}' validation failed: {1} - Command '{0}' validation failed: {1} + Désolé, échec de la validation de la commande « {0} » : {1} Command '{0}' validation failed: {1}. For installation instructions, see: {2} - Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Échec de la validation de la commande « {0} » : {1}. Pour les instructions d’installation manuelle, voir : {2} Resource '{0}' may fail to start: {1} - Resource '{0}' may fail to start: {1} + Le démarrage de la ressource « {0} » peut échouer : {1} diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf index 3d1879888d9..d621b665d3b 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf @@ -29,7 +29,7 @@ Installation instructions - Installation instructions + Istruzioni per l'installazione diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf index 66d340fe621..1acc9dcdc8f 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ja.xlf @@ -29,37 +29,37 @@ Installation instructions - Installation instructions + インストール手順 Missing command - Missing command + コマンドがありません Required command '{0}' was not found on PATH or at the specified location. - Required command '{0}' was not found on PATH or at the specified location. + 必要なコマンド '{0}' が PATH または指定された場所に見つかりませんでした。 Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} - Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + 必要なコマンド '{0}' が PATH または指定された場所に見つかりませんでした。インストール手順については、次を参照: {1} Command '{0}' validation failed: {1} - Command '{0}' validation failed: {1} + コマンド '{0}' 検証に失敗しました: {1} Command '{0}' validation failed: {1}. For installation instructions, see: {2} - Command '{0}' validation failed: {1}. For installation instructions, see: {2} + コマンド '{0}' 検証に失敗しました: {1}。インストール手順については、次を参照: {2} Resource '{0}' may fail to start: {1} - Resource '{0}' may fail to start: {1} + リソース '{0}' の開始に失敗する可能性があります: {1} diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf index 2e846c68413..a73bb026ef0 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ko.xlf @@ -29,37 +29,37 @@ Installation instructions - Installation instructions + 설치 지침 Missing command - Missing command + 명령이 없음 Required command '{0}' was not found on PATH or at the specified location. - Required command '{0}' was not found on PATH or at the specified location. + PATH 또는 지정된 위치에서 필수 명령 '{0}'을(를) 찾을 수 없습니다. Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} - Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + PATH 또는 지정된 위치에서 필수 명령 '{0}'을(를) 찾을 수 없습니다. 설치 지침은 다음을 참조하세요. {1} Command '{0}' validation failed: {1} - Command '{0}' validation failed: {1} + 명령 '{0}' 유효성 검사가 실패함: {1}. Command '{0}' validation failed: {1}. For installation instructions, see: {2} - Command '{0}' validation failed: {1}. For installation instructions, see: {2} + 명령 '{0}' 유효성 검사가 실패했습니다. {1}. 설치 지침은 다음을 참조하세요. {2} Resource '{0}' may fail to start: {1} - Resource '{0}' may fail to start: {1} + 리소스 '{0}'을(를) 시작하지 못할 수 있음: {1} diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf index b407df6cc83..e56228edcd6 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf @@ -29,7 +29,7 @@ Installation instructions - Installation instructions + Instrukcje instalacji diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf index 430559d22d4..784af7d1dc6 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pt-BR.xlf @@ -29,37 +29,37 @@ Installation instructions - Installation instructions + Instruções de instalação Missing command - Missing command + Comando ausente Required command '{0}' was not found on PATH or at the specified location. - Required command '{0}' was not found on PATH or at the specified location. + O comando obrigatório ''{0}'' não foi encontrado em PATH ou no local especificado. Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} - Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + O comando obrigatório ''{0}'' não foi encontrado em PATH ou no local especificado. Para obter instruções de instalação, veja: {1} Command '{0}' validation failed: {1} - Command '{0}' validation failed: {1} + Falha na validação do comando ''{0}'': {1} Command '{0}' validation failed: {1}. For installation instructions, see: {2} - Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Falha na validação do comando ''{0}'': {1}. Para obter instruções de instalação, veja: {2} Resource '{0}' may fail to start: {1} - Resource '{0}' may fail to start: {1} + O recurso ''{0}'' pode falhar ao iniciar: {1} diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf index ea5f9581407..7c7793a4d5e 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.ru.xlf @@ -29,37 +29,37 @@ Installation instructions - Installation instructions + Инструкции по установке Missing command - Missing command + Отсутствует команда Required command '{0}' was not found on PATH or at the specified location. - Required command '{0}' was not found on PATH or at the specified location. + Требуемая команда "{0}" не найдена в PATH или в указанном расположении. Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} - Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Требуемая команда "{0}" не найдена в PATH или в указанном расположении. Инструкции по установке: {1} Command '{0}' validation failed: {1} - Command '{0}' validation failed: {1} + Сбой проверки команды "{0}": {1} Command '{0}' validation failed: {1}. For installation instructions, see: {2} - Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Сбой проверки команды "{0}": {1}. Инструкции по установке: {2} Resource '{0}' may fail to start: {1} - Resource '{0}' may fail to start: {1} + Ресурс "{0}" может не запуститься: {1} diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf index 81b9f43c7c6..206fcad9692 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.tr.xlf @@ -29,37 +29,37 @@ Installation instructions - Installation instructions + Yükleme yönergeleri Missing command - Missing command + Eksik komut Required command '{0}' was not found on PATH or at the specified location. - Required command '{0}' was not found on PATH or at the specified location. + Gerekli '{0}' komutu PATH'de veya belirtilen konumda bulunamadı. Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} - Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Gerekli '{0}' komutu PATH'de veya belirtilen konumda bulunamadı. Yükleme yönergeleri için bkz. {1} Command '{0}' validation failed: {1} - Command '{0}' validation failed: {1} + '{0}' komutu doğrulanamadı: {1} Command '{0}' validation failed: {1}. For installation instructions, see: {2} - Command '{0}' validation failed: {1}. For installation instructions, see: {2} + '{0}' komutu doğrulanamadı: {1}. Yükleme yönergeleri için bkz. {2} Resource '{0}' may fail to start: {1} - Resource '{0}' may fail to start: {1} + '{0}' kaynağı başlatılamayabilir: {1} diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf index 6d73b8d7122..717dce1422b 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hans.xlf @@ -29,37 +29,37 @@ Installation instructions - Installation instructions + 安装说明 Missing command - Missing command + 缺少命令 Required command '{0}' was not found on PATH or at the specified location. - Required command '{0}' was not found on PATH or at the specified location. + 在 PATH 或指定位置找不到所需命令 "{0}"。 Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} - Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + 在 PATH 或指定位置找不到所需命令 "{0}"。有关安装说明,请参阅: {1} Command '{0}' validation failed: {1} - Command '{0}' validation failed: {1} + 命令 "{0}" 验证失败: {1} Command '{0}' validation failed: {1}. For installation instructions, see: {2} - Command '{0}' validation failed: {1}. For installation instructions, see: {2} + 命令 "{0}" 验证失败: {1}。有关安装说明,请参阅: {2} Resource '{0}' may fail to start: {1} - Resource '{0}' may fail to start: {1} + 资源 "{0}" 可能无法启动: {1} diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf index 6210d79b2fc..83e7465d816 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.zh-Hant.xlf @@ -29,37 +29,37 @@ Installation instructions - Installation instructions + 安裝指示 Missing command - Missing command + 遺漏命令 Required command '{0}' was not found on PATH or at the specified location. - Required command '{0}' was not found on PATH or at the specified location. + 在 PATH 或指定的位置找不到必要的命令 '{0}'。 Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} - Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + 在 PATH 或指定的位置找不到必要的命令 '{0}'。如需安裝指示,請參閱: {1} Command '{0}' validation failed: {1} - Command '{0}' validation failed: {1} + 命令 '{0}' 驗證失敗: {1} Command '{0}' validation failed: {1}. For installation instructions, see: {2} - Command '{0}' validation failed: {1}. For installation instructions, see: {2} + 命令 '{0}' 驗證失敗: {1}。如需安裝指示,請參閱: {2} Resource '{0}' may fail to start: {1} - Resource '{0}' may fail to start: {1} + 資源 '{0}' 可能無法啟動: {1} From 1e1ace23ed6315e5d194cee5dc4522ab0c04be3a Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 5 Feb 2026 09:13:35 +0800 Subject: [PATCH 040/256] Lazy index docs in MCP tools and report progress (#14341) --- src/Aspire.Cli/Commands/AgentMcpCommand.cs | 36 +++++------ src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs | 8 +++ src/Aspire.Cli/Mcp/IMcpNotifier.cs | 20 ++++++ src/Aspire.Cli/Mcp/McpServerNotifier.cs | 24 ++++++++ src/Aspire.Cli/Mcp/Tools/CallToolContext.cs | 33 ++++++++++ src/Aspire.Cli/Mcp/Tools/CliMcpTool.cs | 7 +-- src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs | 61 +++++++++++++++++++ src/Aspire.Cli/Mcp/Tools/DoctorTool.cs | 5 +- .../Mcp/Tools/ExecuteResourceCommandTool.cs | 5 +- src/Aspire.Cli/Mcp/Tools/GetDocTool.cs | 7 ++- src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs | 5 +- .../Mcp/Tools/ListConsoleLogsTool.cs | 5 +- src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs | 6 +- .../Mcp/Tools/ListIntegrationsTool.cs | 5 +- src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs | 5 +- .../Mcp/Tools/ListStructuredLogsTool.cs | 8 +-- .../Mcp/Tools/ListTraceStructuredLogsTool.cs | 8 +-- src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs | 8 +-- src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs | 5 +- src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs | 11 ++-- src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs | 5 +- .../Commands/DocsCommandTests.cs | 2 + .../AppHostConnectionSelectionLogicTests.cs | 1 + .../Mcp/ExecuteResourceCommandToolTests.cs | 19 +++--- .../Mcp/ListAppHostsToolTests.cs | 13 ++-- .../Mcp/ListConsoleLogsToolTests.cs | 15 ++--- .../Aspire.Cli.Tests/Mcp/ListDocsToolTests.cs | 61 +++++++++++++++++++ .../Mcp/ListIntegrationsToolTests.cs | 8 ++- .../Mcp/ListResourcesToolTests.cs | 11 ++-- .../Mcp/MockPackagingService.cs | 1 + .../Mcp/TestDocsIndexService.cs | 27 +++++++- .../Mcp/TestMcpServerTransport.cs | 1 + .../TestServices/CallToolContextTestHelper.cs | 34 +++++++++++ .../TestServices/TestMcpNotifier.cs | 33 ++++++++++ 34 files changed, 404 insertions(+), 99 deletions(-) create mode 100644 src/Aspire.Cli/Mcp/IMcpNotifier.cs create mode 100644 src/Aspire.Cli/Mcp/McpServerNotifier.cs create mode 100644 src/Aspire.Cli/Mcp/Tools/CallToolContext.cs create mode 100644 src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs create mode 100644 tests/Aspire.Cli.Tests/Mcp/ListDocsToolTests.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/CallToolContextTestHelper.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/TestMcpNotifier.cs diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index ef5a35719d5..ce9106bc126 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -83,7 +83,7 @@ public AgentMcpCommand( [KnownMcpTools.Doctor] = new DoctorTool(environmentChecker), [KnownMcpTools.RefreshTools] = new RefreshToolsTool(RefreshResourceToolMapAsync, SendToolsListChangedNotificationAsync), [KnownMcpTools.ListDocs] = new ListDocsTool(docsIndexService), - [KnownMcpTools.SearchDocs] = new SearchDocsTool(docsSearchService), + [KnownMcpTools.SearchDocs] = new SearchDocsTool(docsSearchService, docsIndexService), [KnownMcpTools.GetDoc] = new GetDocTool(docsIndexService) }; } @@ -124,19 +124,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Keep a reference to the server for sending notifications _server = server; - // Start indexing aspire.dev documentation in the background (fire-and-forget) - _ = Task.Run(async () => - { - try - { - await _docsIndexService.EnsureIndexedAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to index aspire.dev documentation in background"); - } - }, cancellationToken); - // Starts the MCP server, it's blocking until cancellation is requested await server.RunAsync(cancellationToken); @@ -202,13 +189,20 @@ private async ValueTask HandleCallToolAsync(RequestContext HandleCallToolAsync(RequestContext CallDashboardToolAsync( string toolName, CliMcpTool tool, + ProgressToken? progressToken, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) { @@ -318,7 +313,14 @@ private async ValueTask CallDashboardToolAsync( try { _logger.LogDebug("Invoking CallToolAsync for tool {ToolName} with arguments: {Arguments}", toolName, arguments); - var result = await tool.CallToolAsync(mcpClient, arguments, cancellationToken).ConfigureAwait(false); + var context = new CallToolContext + { + Notifier = new McpServerNotifier(_server!), + McpClient = mcpClient, + Arguments = arguments, + ProgressToken = progressToken + }; + var result = await tool.CallToolAsync(context, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Tool {ToolName} completed successfully", toolName); return result; } diff --git a/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs b/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs index c4a3218c879..f706f41bcac 100644 --- a/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs +++ b/src/Aspire.Cli/Mcp/Docs/DocsIndexService.cs @@ -12,6 +12,11 @@ namespace Aspire.Cli.Mcp.Docs; /// internal interface IDocsIndexService { + /// + /// Gets a value indicating whether the documentation has been indexed. + /// + bool IsIndexed { get; } + /// /// Ensures documentation is loaded and indexed. /// @@ -121,6 +126,9 @@ internal sealed partial class DocsIndexService(IDocsFetcher docsFetcher, IDocsCa private volatile List? _indexedDocuments; private readonly SemaphoreSlim _indexLock = new(1, 1); + /// + public bool IsIndexed => _indexedDocuments is not null; + public async ValueTask EnsureIndexedAsync(CancellationToken cancellationToken = default) { if (_indexedDocuments is not null) diff --git a/src/Aspire.Cli/Mcp/IMcpNotifier.cs b/src/Aspire.Cli/Mcp/IMcpNotifier.cs new file mode 100644 index 00000000000..6556e1be712 --- /dev/null +++ b/src/Aspire.Cli/Mcp/IMcpNotifier.cs @@ -0,0 +1,20 @@ +// 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.Mcp; + +/// +/// Interface for sending MCP notifications. +/// +internal interface IMcpNotifier +{ + /// + /// Sends a notification to the MCP client. + /// + /// The notification method name. + /// The cancellation token. + /// A task representing the asynchronous operation. + Task SendNotificationAsync(string method, CancellationToken cancellationToken = default); + + Task SendNotificationAsync(string method, TParams parameters, CancellationToken cancellationToken = default); +} diff --git a/src/Aspire.Cli/Mcp/McpServerNotifier.cs b/src/Aspire.Cli/Mcp/McpServerNotifier.cs new file mode 100644 index 00000000000..fc271c6c2a6 --- /dev/null +++ b/src/Aspire.Cli/Mcp/McpServerNotifier.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using ModelContextProtocol.Server; + +namespace Aspire.Cli.Mcp; + +/// +/// Implementation of that wraps an . +/// +internal sealed class McpServerNotifier(McpServer server) : IMcpNotifier +{ + /// + public Task SendNotificationAsync(string method, CancellationToken cancellationToken = default) + { + return server.SendNotificationAsync(method, cancellationToken); + } + + /// + public Task SendNotificationAsync(string method, TParams parameters, CancellationToken cancellationToken = default) + { + return server.SendNotificationAsync(method, parameters, cancellationToken: cancellationToken); + } +} diff --git a/src/Aspire.Cli/Mcp/Tools/CallToolContext.cs b/src/Aspire.Cli/Mcp/Tools/CallToolContext.cs new file mode 100644 index 00000000000..e0ce091b24f --- /dev/null +++ b/src/Aspire.Cli/Mcp/Tools/CallToolContext.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. + +using System.Text.Json; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Mcp.Tools; + +/// +/// Provides context for executing MCP tools. +/// +internal sealed class CallToolContext +{ + /// + /// Gets the MCP notifier for sending notifications. + /// + public required IMcpNotifier Notifier { get; init; } + + /// + /// Gets the MCP client instance to use for communicating with the dashboard. + /// + public required ModelContextProtocol.Client.McpClient? McpClient { get; init; } + + /// + /// Gets the arguments passed to the tool. + /// + public required IReadOnlyDictionary? Arguments { get; init; } + + /// + /// Gets the progress token for reporting progress updates, if provided by the client. + /// + public required ProgressToken? ProgressToken { get; init; } +} diff --git a/src/Aspire.Cli/Mcp/Tools/CliMcpTool.cs b/src/Aspire.Cli/Mcp/Tools/CliMcpTool.cs index bd6cd221024..f69d8c8bac8 100644 --- a/src/Aspire.Cli/Mcp/Tools/CliMcpTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/CliMcpTool.cs @@ -28,11 +28,10 @@ internal abstract class CliMcpTool public abstract JsonElement GetInputSchema(); /// - /// Executes the tool with the provided arguments. + /// Executes the tool with the provided context. /// - /// The MCP client instance to use for communicating with the dashboard. - /// The arguments passed to the tool. + /// The call context containing the MCP server, client, and arguments. /// The cancellation token. /// The result of the tool execution. - public abstract ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken); + public abstract ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken); } diff --git a/src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs b/src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs new file mode 100644 index 00000000000..b2ff0e533b9 --- /dev/null +++ b/src/Aspire.Cli/Mcp/Tools/DocsToolHelper.cs @@ -0,0 +1,61 @@ +// 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.Mcp.Docs; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Mcp.Tools; + +/// +/// Helper methods for documentation tool operations. +/// +internal static class DocsToolHelper +{ + /// + /// Ensures the documentation index is ready, sending progress notifications if indexing is needed. + /// + public static async ValueTask EnsureIndexedWithNotificationsAsync( + IDocsIndexService docsIndexService, + ProgressToken? progressToken, + IMcpNotifier notifier, + CancellationToken cancellationToken) + { + if (docsIndexService.IsIndexed) + { + return; + } + + if (progressToken != null) + { + await notifier.SendNotificationAsync( + NotificationMethods.ProgressNotification, + new ProgressNotificationParams + { + ProgressToken = progressToken.Value, + Progress = new ProgressNotificationValue + { + Message = "Indexing Aspire docs...", + Progress = 1 + } + }, cancellationToken).ConfigureAwait(false); + } + + await docsIndexService.EnsureIndexedAsync(cancellationToken).ConfigureAwait(false); + + if (progressToken != null) + { + await notifier.SendNotificationAsync( + NotificationMethods.ProgressNotification, + new ProgressNotificationParams + { + ProgressToken = progressToken.Value, + Progress = new ProgressNotificationValue + { + Message = "Aspire docs indexed", + Progress = 2 + } + }, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs b/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs index 541347375da..846fbea7715 100644 --- a/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs @@ -28,11 +28,10 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // This tool does not use the MCP client or arguments - _ = mcpClient; - _ = arguments; + _ = context; try { diff --git a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs index cf285d044a5..b369d343c56 100644 --- a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs @@ -41,10 +41,9 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client as it operates via backchannel - _ = mcpClient; + var arguments = context.Arguments; if (arguments is null || !arguments.TryGetValue("resourceName", out var resourceNameElement) || diff --git a/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs b/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs index 5dde5c0f5e1..0e21d6b3e11 100644 --- a/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs @@ -45,11 +45,10 @@ public override JsonElement GetInputSchema() } public override async ValueTask CallToolAsync( - ModelContextProtocol.Client.McpClient mcpClient, - IReadOnlyDictionary? arguments, + CallToolContext context, CancellationToken cancellationToken) { - _ = mcpClient; + var arguments = context.Arguments; if (arguments is null || !arguments.TryGetValue("slug", out var slugElement)) { @@ -76,6 +75,8 @@ public override async ValueTask CallToolAsync( section = sectionElement.GetString(); } + await DocsToolHelper.EnsureIndexedWithNotificationsAsync(_docsIndexService, context.ProgressToken, context.Notifier, cancellationToken).ConfigureAwait(false); + var doc = await _docsIndexService.GetDocumentAsync(slug, section, cancellationToken).ConfigureAwait(false); if (doc is null) diff --git a/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs index 4a8e4bb468c..14ac11b57a9 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs @@ -35,11 +35,10 @@ public override JsonElement GetInputSchema() return JsonDocument.Parse("{ \"type\": \"object\", \"properties\": {} }").RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // This tool does not use the MCP client as it operates locally - _ = mcpClient; - _ = arguments; + _ = context; // Trigger an immediate scan to ensure we have the latest AppHost connections await auxiliaryBackchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs index 445b0710850..56fa8aade4f 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs @@ -36,10 +36,9 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client as it operates via backchannel - _ = mcpClient; + var arguments = context.Arguments; // Get the resource name from arguments string? resourceName = null; diff --git a/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs index 429a6795891..6969f0925cd 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs @@ -39,12 +39,10 @@ public override JsonElement GetInputSchema() } public override async ValueTask CallToolAsync( - ModelContextProtocol.Client.McpClient mcpClient, - IReadOnlyDictionary? arguments, + CallToolContext context, CancellationToken cancellationToken) { - _ = mcpClient; - _ = arguments; + await DocsToolHelper.EnsureIndexedWithNotificationsAsync(_docsIndexService, context.ProgressToken, context.Notifier, cancellationToken).ConfigureAwait(false); var docs = await _docsIndexService.ListDocumentsAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs index 205820b64db..443702cb971 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs @@ -67,11 +67,10 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // This tool does not use the MCP client as it operates locally - _ = mcpClient; - _ = arguments; + _ = context; try { diff --git a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs index 5eb3a39e7b7..8e579c8d115 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs @@ -55,11 +55,10 @@ public override JsonElement GetInputSchema() return JsonDocument.Parse("{ \"type\": \"object\", \"properties\": {} }").RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // This tool does not use the MCP client as it operates via backchannel - _ = mcpClient; - _ = arguments; + _ = context; var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); if (connection is null) diff --git a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs index ed9271dfcbc..17eab72cfe7 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs @@ -28,21 +28,21 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // Convert JsonElement arguments to Dictionary Dictionary? convertedArgs = null; - if (arguments != null) + if (context.Arguments != null) { convertedArgs = new Dictionary(); - foreach (var kvp in arguments) + foreach (var kvp in context.Arguments) { convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; } } // Forward the call to the dashboard's MCP server - return await mcpClient.CallToolAsync( + return await context.McpClient!.CallToolAsync( Name, convertedArgs, serializerOptions: McpJsonUtilities.DefaultOptions, diff --git a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs index 756f2c489b1..7127c546758 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs @@ -29,21 +29,21 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // Convert JsonElement arguments to Dictionary Dictionary? convertedArgs = null; - if (arguments != null) + if (context.Arguments != null) { convertedArgs = new Dictionary(); - foreach (var kvp in arguments) + foreach (var kvp in context.Arguments) { convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; } } // Forward the call to the dashboard's MCP server - return await mcpClient.CallToolAsync( + return await context.McpClient!.CallToolAsync( Name, convertedArgs, serializerOptions: McpJsonUtilities.DefaultOptions, diff --git a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs index 498c5067e46..4e589bcffba 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs @@ -28,21 +28,21 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // Convert JsonElement arguments to Dictionary Dictionary? convertedArgs = null; - if (arguments != null) + if (context.Arguments != null) { convertedArgs = new Dictionary(); - foreach (var kvp in arguments) + foreach (var kvp in context.Arguments) { convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; } } // Forward the call to the dashboard's MCP server - return await mcpClient.CallToolAsync( + return await context.McpClient!.CallToolAsync( Name, convertedArgs, serializerOptions: McpJsonUtilities.DefaultOptions, diff --git a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs index c7caf14ed92..9eee648bf78 100644 --- a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs @@ -17,10 +17,9 @@ public override JsonElement GetInputSchema() return JsonDocument.Parse("{ \"type\": \"object\", \"properties\": {} }").RootElement; } - public override async ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - _ = mcpClient; - _ = arguments; + _ = context; var count = await refreshToolsAsync(cancellationToken).ConfigureAwait(false); await sendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs b/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs index 4906ecb289f..8b1524bf9c7 100644 --- a/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs @@ -10,9 +10,10 @@ namespace Aspire.Cli.Mcp.Tools; /// /// MCP tool for searching aspire.dev documentation using lexical search. /// -internal sealed class SearchDocsTool(IDocsSearchService docsSearchService) : CliMcpTool +internal sealed class SearchDocsTool(IDocsSearchService docsSearchService, IDocsIndexService docsIndexService) : CliMcpTool { private readonly IDocsSearchService _docsSearchService = docsSearchService; + private readonly IDocsIndexService _docsIndexService = docsIndexService; public override string Name => KnownMcpTools.SearchDocs; @@ -48,12 +49,10 @@ public override JsonElement GetInputSchema() } public override async ValueTask CallToolAsync( - ModelContextProtocol.Client.McpClient mcpClient, - IReadOnlyDictionary? arguments, + CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client as it operates locally - _ = mcpClient; + var arguments = context.Arguments; if (arguments is null || !arguments.TryGetValue("query", out var queryElement)) { @@ -80,6 +79,8 @@ public override async ValueTask CallToolAsync( topK = Math.Clamp(topKValue, 1, 10); } + await DocsToolHelper.EnsureIndexedWithNotificationsAsync(_docsIndexService, context.ProgressToken, context.Notifier, cancellationToken).ConfigureAwait(false); + var response = await _docsSearchService.SearchAsync(query, topK, cancellationToken); if (response is null) diff --git a/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs b/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs index 012e88df8d7..1024bcc8331 100644 --- a/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs @@ -32,12 +32,13 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override ValueTask CallToolAsync(ModelContextProtocol.Client.McpClient mcpClient, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + public override ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { // This tool does not use the MCP client as it operates locally - _ = mcpClient; _ = cancellationToken; + var arguments = context.Arguments; + if (arguments == null || !arguments.TryGetValue("appHostPath", out var appHostPathElement)) { return ValueTask.FromResult(new CallToolResult diff --git a/tests/Aspire.Cli.Tests/Commands/DocsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DocsCommandTests.cs index ad9791b6ab6..403692be9d4 100644 --- a/tests/Aspire.Cli.Tests/Commands/DocsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DocsCommandTests.cs @@ -170,6 +170,8 @@ public async Task DocsGetCommand_WithInvalidSlug_ReturnsError() internal sealed class TestDocsIndexService : IDocsIndexService { + public bool IsIndexed => true; + public ValueTask EnsureIndexedAsync(CancellationToken cancellationToken = default) { return ValueTask.CompletedTask; diff --git a/tests/Aspire.Cli.Tests/Mcp/AppHostConnectionSelectionLogicTests.cs b/tests/Aspire.Cli.Tests/Mcp/AppHostConnectionSelectionLogicTests.cs index 5fa4cc733c9..975669d056d 100644 --- a/tests/Aspire.Cli.Tests/Mcp/AppHostConnectionSelectionLogicTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/AppHostConnectionSelectionLogicTests.cs @@ -76,3 +76,4 @@ private static AppHostAuxiliaryBackchannel CreateConnection(string hash, string isInScope); } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs index a83f51ff414..8e923c714ba 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ExecuteResourceCommandToolTests.cs @@ -31,7 +31,7 @@ public async Task ExecuteResourceCommandTool_ThrowsException_WhenNoAppHostRunnin var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); var exception = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, CreateArguments("test-resource", "resource-start"), CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("test-resource", "resource-start")), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("No Aspire AppHost", exception.Message); Assert.Contains("--detach", exception.Message); @@ -48,7 +48,7 @@ public async Task ExecuteResourceCommandTool_ReturnsSuccess_WhenCommandExecutedS monitor.AddConnection("hash1", "socket.hash1", connection); var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); - var result = await tool.CallToolAsync(null!, CreateArguments("api-service", "resource-start"), CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("api-service", "resource-start")), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); Assert.NotNull(result.Content); @@ -77,7 +77,7 @@ public async Task ExecuteResourceCommandTool_ThrowsException_WhenCommandFails() var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); var exception = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, CreateArguments("nonexistent", "resource-start"), CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("nonexistent", "resource-start")), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("Resource not found", exception.Message); } @@ -99,7 +99,7 @@ public async Task ExecuteResourceCommandTool_ThrowsException_WhenCommandCanceled var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); var exception = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, CreateArguments("api-service", "resource-stop"), CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("api-service", "resource-stop")), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("cancelled", exception.Message); } @@ -117,15 +117,15 @@ public async Task ExecuteResourceCommandTool_WorksWithKnownCommands() var tool = new ExecuteResourceCommandTool(monitor, NullLogger.Instance); // Test with resource-start - var startResult = await tool.CallToolAsync(null!, CreateArguments("api-service", "resource-start"), CancellationToken.None).DefaultTimeout(); + var startResult = await tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("api-service", "resource-start")), CancellationToken.None).DefaultTimeout(); Assert.True(startResult.IsError is null or false); // Test with resource-stop - var stopResult = await tool.CallToolAsync(null!, CreateArguments("api-service", "resource-stop"), CancellationToken.None).DefaultTimeout(); + var stopResult = await tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("api-service", "resource-stop")), CancellationToken.None).DefaultTimeout(); Assert.True(stopResult.IsError is null or false); // Test with resource-restart - var restartResult = await tool.CallToolAsync(null!, CreateArguments("api-service", "resource-restart"), CancellationToken.None).DefaultTimeout(); + var restartResult = await tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments("api-service", "resource-restart")), CancellationToken.None).DefaultTimeout(); Assert.True(restartResult.IsError is null or false); } @@ -140,14 +140,15 @@ public async Task ExecuteResourceCommandTool_ThrowsException_WhenMissingArgument // Test with null arguments var exception1 = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, null, CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("Missing required arguments", exception1.Message); // Test with only resourceName var partialArgs = JsonDocument.Parse("""{"resourceName": "test"}""").RootElement .EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()); var exception2 = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, partialArgs, CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(partialArgs), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("Missing required arguments", exception2.Message); } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs index 8d7738f7698..736a02e2383 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs @@ -20,7 +20,7 @@ public async Task ListAppHostsTool_ReturnsEmptyListWhenNoConnections() var executionContext = CreateCliExecutionContext(workspace.WorkspaceRoot); var tool = new ListAppHostsTool(monitor, executionContext); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.Null(result.IsError); Assert.NotNull(result.Content); @@ -55,7 +55,7 @@ public async Task ListAppHostsTool_ReturnsInScopeAppHosts() monitor.AddConnection("hash1", "socket.hash1", connection); var tool = new ListAppHostsTool(monitor, executionContext); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.Null(result.IsError); var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; @@ -87,7 +87,7 @@ public async Task ListAppHostsTool_ReturnsOutOfScopeAppHosts() monitor.AddConnection("hash2", "socket.hash2", connection); var tool = new ListAppHostsTool(monitor, executionContext); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.Null(result.IsError); var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; @@ -130,7 +130,7 @@ public async Task ListAppHostsTool_SeparatesInScopeAndOutOfScopeAppHosts() monitor.AddConnection("hash2", "socket.hash2", outOfScopeConnection); var tool = new ListAppHostsTool(monitor, executionContext); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.Null(result.IsError); var textContent = result.Content[0] as ModelContextProtocol.Protocol.TextContentBlock; @@ -154,12 +154,12 @@ public async Task ListAppHostsTool_CallsScanAsyncBeforeReturningResults() Assert.Equal(0, monitor.ScanCallCount); var tool = new ListAppHostsTool(monitor, executionContext); - await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.Equal(1, monitor.ScanCallCount); // Call again to verify it scans each time - await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.Equal(2, monitor.ScanCallCount); } @@ -178,3 +178,4 @@ private static AppHostAuxiliaryBackchannel CreateAppHostConnection(string hash, return new AppHostAuxiliaryBackchannel(hash, socketPath, rpc, mcpInfo: null, appHostInfo, isInScope); } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs index 7f7b23bf0cb..13a4cafb8ed 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListConsoleLogsToolTests.cs @@ -25,7 +25,7 @@ public async Task ListConsoleLogsTool_ThrowsException_WhenNoAppHostRunning() }; var exception = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, arguments, CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("No Aspire AppHost", exception.Message); Assert.Contains("--detach", exception.Message); @@ -41,7 +41,7 @@ public async Task ListConsoleLogsTool_ThrowsException_WhenResourceNameNotProvide var tool = new ListConsoleLogsTool(monitor, NullLogger.Instance); var exception = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, null, CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("resourceName", exception.Message); } @@ -63,7 +63,7 @@ public async Task ListConsoleLogsTool_ReturnsLogs_WhenResourceHasNoLogs() ["resourceName"] = JsonDocument.Parse("\"test-resource\"").RootElement }; - var result = await tool.CallToolAsync(null!, arguments, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); Assert.NotNull(result.Content); @@ -98,7 +98,7 @@ public async Task ListConsoleLogsTool_ReturnsLogs_ForSpecificResource() ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement }; - var result = await tool.CallToolAsync(null!, arguments, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; @@ -132,7 +132,7 @@ public async Task ListConsoleLogsTool_ReturnsPlainTextFormat() ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement }; - var result = await tool.CallToolAsync(null!, arguments, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; Assert.NotNull(textContent); @@ -162,7 +162,7 @@ public async Task ListConsoleLogsTool_StripsTimestamps() ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement }; - var result = await tool.CallToolAsync(null!, arguments, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; Assert.NotNull(textContent); @@ -191,7 +191,7 @@ public async Task ListConsoleLogsTool_StripsAnsiSequences() ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement }; - var result = await tool.CallToolAsync(null!, arguments, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; Assert.NotNull(textContent); @@ -206,3 +206,4 @@ private static string ExtractCodeBlockContent(string text) return match.Success ? match.Groups[1].Value : string.Empty; } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/ListDocsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListDocsToolTests.cs new file mode 100644 index 00000000000..11234d9cd15 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/ListDocsToolTests.cs @@ -0,0 +1,61 @@ +// 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.Mcp.Tools; +using Aspire.Cli.Tests.TestServices; +using Microsoft.AspNetCore.InternalTesting; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Tests.Mcp; + +public class ListDocsToolTests +{ + [Fact] + public async Task ListDocsTool_CallToolAsync_ReturnsDocumentList() + { + var indexService = new TestDocsIndexService(); + var tool = new ListDocsTool(indexService); + + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("Aspire Documentation Pages", textContent.Text); + Assert.Contains("Getting Started", textContent.Text); + Assert.Contains("App Host", textContent.Text); + Assert.Contains("Deploy to Azure", textContent.Text); + } + + [Fact] + public async Task ListDocsTool_CallToolAsync_SendsProgressNotifications_WhenIndexingRequired() + { + var indexService = new TestDocsIndexService(documents: null, isIndexed: false); + var notifier = new TestMcpNotifier(); + var tool = new ListDocsTool(indexService); + + var context = CallToolContextTestHelper.Create(notifier: notifier, progressToken: "test-progress-token"); + var result = await tool.CallToolAsync(context, CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + Assert.Contains(NotificationMethods.ProgressNotification, notifier.Notifications); + // Should have two progress notifications: start and complete + Assert.Equal(2, notifier.Notifications.Count(n => n == NotificationMethods.ProgressNotification)); + } + + [Fact] + public async Task ListDocsTool_CallToolAsync_DoesNotSendProgressNotifications_WhenAlreadyIndexed() + { + var indexService = new TestDocsIndexService(); // IsIndexed = true by default + var notifier = new TestMcpNotifier(); + var tool = new ListDocsTool(indexService); + + var context = CallToolContextTestHelper.Create(notifier: notifier, progressToken: "test-progress-token"); + var result = await tool.CallToolAsync(context, CancellationToken.None).DefaultTimeout(); + + Assert.True(result.IsError is null or false); + Assert.DoesNotContain(NotificationMethods.ProgressNotification, notifier.Notifications); + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs index ecb7e9ee88c..d99a2792030 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListIntegrationsToolTests.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.InternalTesting; using System.Text.Json; using Aspire.Cli.Mcp.Tools; +using Aspire.Cli.Tests.TestServices; namespace Aspire.Cli.Tests.Mcp; @@ -49,7 +50,7 @@ public async Task ListIntegrationsTool_CallToolAsync_ReturnsEmptyJsonArray_WhenN var mockPackagingService = new MockPackagingService(); var tool = new ListIntegrationsTool(mockPackagingService, TestExecutionContextFactory.CreateTestContext(), new MockAuxiliaryBackchannelMonitor()); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); Assert.NotNull(result.Content); @@ -74,7 +75,7 @@ public async Task ListIntegrationsTool_CallToolAsync_ReturnsJsonWithPackages_Whe }); var tool = new ListIntegrationsTool(mockPackagingService, TestExecutionContextFactory.CreateTestContext(), new MockAuxiliaryBackchannelMonitor()); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); Assert.NotNull(result.Content); @@ -112,7 +113,7 @@ public async Task ListIntegrationsTool_UsesDefaultChannelOnly() }); var tool = new ListIntegrationsTool(mockPackagingService, TestExecutionContextFactory.CreateTestContext(), new MockAuxiliaryBackchannelMonitor()); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); @@ -122,3 +123,4 @@ public async Task ListIntegrationsTool_UsesDefaultChannelOnly() Assert.Equal(1, integrations.GetArrayLength()); } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs index 26384f0af29..1d68fce864b 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListResourcesToolTests.cs @@ -19,7 +19,7 @@ public async Task ListResourcesTool_ThrowsException_WhenNoAppHostRunning() var tool = new ListResourcesTool(monitor, NullLogger.Instance); var exception = await Assert.ThrowsAsync( - () => tool.CallToolAsync(null!, null, CancellationToken.None).AsTask()).DefaultTimeout(); + () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout(); Assert.Contains("No Aspire AppHost", exception.Message); Assert.Contains("--detach", exception.Message); @@ -37,7 +37,7 @@ public async Task ListResourcesTool_ReturnsNoResourcesFound_WhenSnapshotsAreEmpt monitor.AddConnection("hash1", "socket.hash1", connection); var tool = new ListResourcesTool(monitor, NullLogger.Instance); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); Assert.NotNull(result.Content); @@ -82,7 +82,7 @@ public async Task ListResourcesTool_ReturnsMultipleResources() monitor.AddConnection("hash1", "socket.hash1", connection); var tool = new ListResourcesTool(monitor, NullLogger.Instance); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); Assert.True(result.IsError is null or false); var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; @@ -118,7 +118,7 @@ public async Task ListResourcesTool_IncludesEnvironmentVariableNamesButNotValues monitor.AddConnection("hash1", "socket.hash1", connection); var tool = new ListResourcesTool(monitor, NullLogger.Instance); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; Assert.NotNull(textContent); @@ -159,7 +159,7 @@ public async Task ListResourcesTool_ReturnsValidJson() monitor.AddConnection("hash1", "socket.hash1", connection); var tool = new ListResourcesTool(monitor, NullLogger.Instance); - var result = await tool.CallToolAsync(null!, null, CancellationToken.None).DefaultTimeout(); + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); var textContent = result.Content![0] as ModelContextProtocol.Protocol.TextContentBlock; Assert.NotNull(textContent); @@ -183,3 +183,4 @@ public async Task ListResourcesTool_ReturnsValidJson() Assert.Equal("Running", resource.GetProperty("state").GetString()); } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs index 2733ad4a126..c1d55866cb9 100644 --- a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs +++ b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs @@ -77,3 +77,4 @@ public IReadOnlyList GetConnectionsForWorkingDirec return []; } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs b/tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs index e464cdf51c9..1c559defb31 100644 --- a/tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs +++ b/tests/Aspire.Cli.Tests/Mcp/TestDocsIndexService.cs @@ -10,15 +10,39 @@ namespace Aspire.Cli.Tests.Mcp; /// internal sealed class TestDocsIndexService : IDocsIndexService { - private readonly List _documents = + private static readonly List s_defaultDocuments = [ new DocsListItem { Slug = "getting-started", Title = "Getting Started", Summary = "Learn how to get started with Aspire" }, new DocsListItem { Slug = "fundamentals/app-host", Title = "App Host", Summary = "Learn about the Aspire app host" }, new DocsListItem { Slug = "deployment/azure", Title = "Deploy to Azure", Summary = "Deploy your Aspire app to Azure" }, ]; + private readonly List _documents; + private bool _isIndexed; + + /// + /// Creates a new instance with default documents and already indexed. + /// + public TestDocsIndexService() : this(s_defaultDocuments, isIndexed: true) + { + } + + /// + /// Creates a new instance with specified documents and indexing state. + /// + /// The documents to return. If null, uses default documents. + /// Whether the service starts in an indexed state. + public TestDocsIndexService(IEnumerable? documents, bool isIndexed = true) + { + _documents = documents?.ToList() ?? [.. s_defaultDocuments]; + _isIndexed = isIndexed; + } + + public bool IsIndexed => _isIndexed; + public ValueTask EnsureIndexedAsync(CancellationToken cancellationToken = default) { + _isIndexed = true; return ValueTask.CompletedTask; } @@ -58,3 +82,4 @@ public ValueTask> SearchAsync(string query, int return ValueTask.FromResult>(results); } } + diff --git a/tests/Aspire.Cli.Tests/Mcp/TestMcpServerTransport.cs b/tests/Aspire.Cli.Tests/Mcp/TestMcpServerTransport.cs index 8cdde5c7825..432366d7ae6 100644 --- a/tests/Aspire.Cli.Tests/Mcp/TestMcpServerTransport.cs +++ b/tests/Aspire.Cli.Tests/Mcp/TestMcpServerTransport.cs @@ -79,3 +79,4 @@ public void Dispose() CompletePipes(); } } + diff --git a/tests/Aspire.Cli.Tests/TestServices/CallToolContextTestHelper.cs b/tests/Aspire.Cli.Tests/TestServices/CallToolContextTestHelper.cs new file mode 100644 index 00000000000..3afb22a32e5 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/CallToolContextTestHelper.cs @@ -0,0 +1,34 @@ +// 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.Mcp.Tools; + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// Provides helper methods for creating instances in tests. +/// +internal static class CallToolContextTestHelper +{ + /// + /// Creates a for testing. + /// + /// Optional arguments to pass to the tool. + /// Optional notifier to use. If null, a new is created. + /// Optional progress token to include in the context. + /// A new configured for testing. + public static CallToolContext Create( + IReadOnlyDictionary? arguments = null, + TestMcpNotifier? notifier = null, + string? progressToken = null) + { + return new CallToolContext + { + Notifier = notifier ?? new TestMcpNotifier(), + McpClient = null, + Arguments = arguments, + ProgressToken = progressToken is not null ? new ModelContextProtocol.Protocol.ProgressToken(progressToken) : null + }; + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestMcpNotifier.cs b/tests/Aspire.Cli.Tests/TestServices/TestMcpNotifier.cs new file mode 100644 index 00000000000..936f42cc7fc --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TestMcpNotifier.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. + +using Aspire.Cli.Mcp; + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// A test implementation of that collects notifications. +/// +internal sealed class TestMcpNotifier : IMcpNotifier +{ + private readonly List _notifications = []; + + /// + /// Gets the list of notification methods that have been sent. + /// + public IReadOnlyList Notifications => _notifications; + + /// + public Task SendNotificationAsync(string method, CancellationToken cancellationToken = default) + { + _notifications.Add(method); + return Task.CompletedTask; + } + + /// + public Task SendNotificationAsync(string method, TParams parameters, CancellationToken cancellationToken = default) + { + _notifications.Add(method); + return Task.CompletedTask; + } +} From f2d1ba81c57dc49c81fe02458261d08b5847537c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 05:14:24 +0000 Subject: [PATCH 041/256] Hide "Show/Hide Hidden Resources" menu item when viewing single resource logs (#14288) --- .../Components/Pages/ConsoleLogs.razor.cs | 40 ++++---- .../Pages/ConsoleLogsTests.cs | 96 ++++++++++++++----- 2 files changed, 92 insertions(+), 44 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index 2391d64f1c7..1090db849d9 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -427,7 +427,7 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa private bool IsAllSelected() { - return PageViewModel.SelectedResource == _allResource; + return PageViewModel?.SelectedResource is not null && PageViewModel.SelectedResource == _allResource; } private void UpdateMenuButtons() @@ -451,27 +451,27 @@ private void UpdateMenuButtons() var selectedResource = GetSelectedResource(); - CommonMenuItems.AddToggleHiddenResourcesMenuItem( - _logsMenuItems, - ControlsStringsLoc, - _showHiddenResources, - _resourceByName.Values, - SessionStorage, - EventCallback.Factory.Create(this, async - value => - { - _showHiddenResources = value; - UpdateResourcesList(); - UpdateMenuButtons(); - - if (!_showHiddenResources && selectedResource?.IsResourceHidden(showHiddenResources: false) is true) + // Only show the "Hide hidden resources" menu item when viewing all resources + // Use IsAllSelected() instead of _isSubscribedToAll because UpdateMenuButtons() + // can be called before the subscription is established + if (IsAllSelected()) + { + CommonMenuItems.AddToggleHiddenResourcesMenuItem( + _logsMenuItems, + ControlsStringsLoc, + _showHiddenResources, + _resourceByName.Values, + SessionStorage, + EventCallback.Factory.Create(this, async + value => { - PageViewModel.SelectedResource = _allResource; - await this.AfterViewModelChangedAsync(_contentLayout, false); - } + _showHiddenResources = value; + UpdateResourcesList(); + UpdateMenuButtons(); - await this.RefreshIfMobileAsync(_contentLayout); - })); + await this.RefreshIfMobileAsync(_contentLayout); + })); + } _logsMenuItems.Add(new() { diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs index a843ef20aea..7654c343d14 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs @@ -171,7 +171,8 @@ public async Task ResourceName_SubscribeOnLoadAndChange_SubscribeConsoleLogsOnce public void ToggleHiddenResources_HiddenResourceVisibilityAndSelection_WorksCorrectly() { // Arrange - var regularResource = ModelTestHelpers.CreateResource(resourceName: "regular-resource", state: KnownResourceState.Running); + var regularResource1 = ModelTestHelpers.CreateResource(resourceName: "regular-resource1", state: KnownResourceState.Running); + var regularResource2 = ModelTestHelpers.CreateResource(resourceName: "regular-resource2", state: KnownResourceState.Running); var hiddenResource = ModelTestHelpers.CreateResource(resourceName: "hidden-resource", state: KnownResourceState.Running, hidden: true); var consoleLogsChannel = Channel.CreateUnbounded>(); @@ -180,7 +181,7 @@ public void ToggleHiddenResources_HiddenResourceVisibilityAndSelection_WorksCorr isEnabled: true, consoleLogsChannelProvider: name => consoleLogsChannel, resourceChannelProvider: () => resourceChannel, - initialResources: [regularResource, hiddenResource]); + initialResources: [regularResource1, regularResource2, hiddenResource]); SetupConsoleLogsServices(dashboardClient); @@ -203,8 +204,8 @@ public void ToggleHiddenResources_HiddenResourceVisibilityAndSelection_WorksCorr var selectElement = resourceSelect.Find("fluent-select"); var selectOptions = selectElement.QuerySelectorAll("fluent-option"); - // Should have at least 1 option (regular resource) when resources are loaded - Assert.True(selectOptions.Length >= 1); + // Should have "All" + 2 regular resources when resources are loaded + Assert.Equal(3, selectOptions.Length); }); // Initially, hidden resources should not be shown @@ -212,10 +213,11 @@ public void ToggleHiddenResources_HiddenResourceVisibilityAndSelection_WorksCorr var selectElement = resourceSelect.Find("fluent-select"); var selectOptions = selectElement.QuerySelectorAll("fluent-option"); - // Should only have regular resource (hidden resource filtered out) - Assert.Equal(1, selectOptions.Length); // regular-resource + // Should have "All" + 2 regular resources (hidden resource filtered out) + Assert.Equal(3, selectOptions.Length); var optionValues = selectOptions.Select(opt => opt.GetAttribute("value")).ToList(); - Assert.Contains("regular-resource", optionValues); + Assert.Contains("regular-resource1", optionValues); + Assert.Contains("regular-resource2", optionValues); Assert.DoesNotContain("hidden-resource", optionValues); // Act & Assert 2: Click the settings menu button to show the menu, then click "Show hidden resources" @@ -235,21 +237,16 @@ public void ToggleHiddenResources_HiddenResourceVisibilityAndSelection_WorksCorr cut.WaitForAssertion(() => { var updatedOptions = selectElement.QuerySelectorAll("fluent-option"); - // Should now have both resources - Assert.Equal(3, updatedOptions.Length); // "None" + regular-resource + hidden-resource + // Should now have "All" + all three resources + Assert.Equal(4, updatedOptions.Length); var updatedOptionValues = updatedOptions.Select(opt => opt.GetAttribute("value")).ToList(); - Assert.Contains("regular-resource", updatedOptionValues); + Assert.Contains("regular-resource1", updatedOptionValues); + Assert.Contains("regular-resource2", updatedOptionValues); Assert.Contains("hidden-resource", updatedOptionValues); }); - // Act & Assert 3: Select the hidden resource - var hiddenResourceOption = selectElement.QuerySelector("fluent-option[value='hidden-resource']"); - Assert.NotNull(hiddenResourceOption); - selectElement.Change("hidden-resource"); - - cut.WaitForState(() => instance.PageViewModel.SelectedResource?.Name == "hidden-resource"); - - // Act & Assert 4: Click the settings menu button again and click "Hide hidden resources" to hide them again + // Act & Assert 3: Click the settings menu button again and click "Hide hidden resources" to hide them again + // Note: We stay on "All" view to test the hide functionality settingsMenuButton.Click(); cut.WaitForAssertion(() => @@ -259,19 +256,70 @@ public void ToggleHiddenResources_HiddenResourceVisibilityAndSelection_WorksCorr hideHiddenMenuItem.Click(); }); - // Wait for UI to update - hidden resource should be filtered out and selection should be cleared + // Wait for UI to update - hidden resource should be filtered out cut.WaitForAssertion(() => { var finalOptions = selectElement.QuerySelectorAll("fluent-option"); - // Should be back to regular resource only - Assert.Equal(1, finalOptions.Length); // regular-resource + // Should be back to "All" + 2 regular resources only + Assert.Equal(3, finalOptions.Length); var finalOptionValues = finalOptions.Select(opt => opt.GetAttribute("value")).ToList(); - Assert.Contains("regular-resource", finalOptionValues); + Assert.Contains("regular-resource1", finalOptionValues); + Assert.Contains("regular-resource2", finalOptionValues); Assert.DoesNotContain("hidden-resource", finalOptionValues); }); + } + + [Fact] + public void ToggleHiddenResourcesMenuItem_WhenSingleResourceSelected_NotShown() + { + // Arrange + var testResource = ModelTestHelpers.CreateResource(resourceName: "test-resource", state: KnownResourceState.Running); + var hiddenResource = ModelTestHelpers.CreateResource(resourceName: "hidden-resource", state: KnownResourceState.Running, hidden: true); + + var consoleLogsChannel = Channel.CreateUnbounded>(); + var resourceChannel = Channel.CreateUnbounded>(); + var dashboardClient = new TestDashboardClient( + isEnabled: true, + consoleLogsChannelProvider: name => consoleLogsChannel, + resourceChannelProvider: () => resourceChannel, + initialResources: [testResource, hiddenResource]); + + SetupConsoleLogsServices(dashboardClient); + + var dimensionManager = Services.GetRequiredService(); + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + dimensionManager.InvokeOnViewportInformationChanged(viewport); + + // Act: Render component with a specific resource selected + var cut = RenderComponent(builder => + { + builder.Add(p => p.ViewportInformation, viewport); + builder.Add(p => p.ResourceName, "test-resource"); + }); + + var instance = cut.Instance; - // Selection should be cleared since selected resource is now hidden - cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == regularResource.Name); + // Wait for resources to load and specific resource to be selected + cut.WaitForState(() => instance.PageViewModel.SelectedResource?.Id?.InstanceId == "test-resource"); + + // Act: Click the settings menu button + var settingsMenuButton = cut.Find("fluent-button[title='" + Resources.ConsoleLogs.ConsoleLogsSettings + "']"); + Assert.NotNull(settingsMenuButton); + settingsMenuButton.Click(); + + // Assert: The "Show hidden resources" / "Hide hidden resources" menu item should NOT be present + cut.WaitForAssertion(() => + { + var menuItems = cut.FindAll("fluent-menu-item"); + var hiddenResourcesMenuItems = menuItems.Where(item => + { + var text = item.TextContent; + return text.Contains(Resources.ControlsStrings.ShowHiddenResources) || + text.Contains(Resources.ControlsStrings.HideHiddenResources); + }).ToList(); + + Assert.Empty(hiddenResourcesMenuItems); + }); } [Fact] From 32f1b3f1d83a6ac5167130a4d8a0b21dda7d66c0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 5 Feb 2026 16:22:59 +1100 Subject: [PATCH 042/256] Upload CLI E2E recordings as dedicated artifacts (#14355) - Add step in run-tests.yml to copy .cast files and upload as 'cli-e2e-recordings-{TestName}' artifacts - Simplify cli-e2e-recording-comment.yml to filter by artifact name prefix - Removes need for hardcoded test class name list Co-authored-by: Mitch Denny --- .../workflows/cli-e2e-recording-comment.yml | 17 ++++------------- .github/workflows/run-tests.yml | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/.github/workflows/cli-e2e-recording-comment.yml b/.github/workflows/cli-e2e-recording-comment.yml index 4a6c0415930..04bbf26c889 100644 --- a/.github/workflows/cli-e2e-recording-comment.yml +++ b/.github/workflows/cli-e2e-recording-comment.yml @@ -104,22 +104,13 @@ jobs: console.log(`Total artifacts found: ${allArtifacts.length}`); - // Filter for CLI E2E test logs (they contain recordings) + // Filter for CLI E2E recording artifacts (simple pattern match) + // These are uploaded by the run-tests workflow with name: cli-e2e-recordings-{TestName} const cliE2eArtifacts = allArtifacts.filter(a => - a.name.startsWith('logs-') && - a.name.includes('Tests-ubuntu-latest') && - (a.name.includes('SmokeTests') || - a.name.includes('EmptyAppHostTemplateTests') || - a.name.includes('JsReactTemplateTests') || - a.name.includes('PythonReactTemplateTests') || - a.name.includes('DockerDeploymentTests') || - a.name.includes('TypeScriptPolyglotTests') || - a.name.includes('DoctorCommandTests') || - a.name.includes('StartStopTests') || - a.name.includes('PsCommandTests')) + a.name.startsWith('cli-e2e-recordings-') ); - console.log(`Found ${cliE2eArtifacts.length} CLI E2E artifacts`); + console.log(`Found ${cliE2eArtifacts.length} CLI E2E recording artifacts`); // Create recordings directory const recordingsDir = 'recordings'; diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5114af9d611..6a4b210a99a 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -516,6 +516,25 @@ jobs: artifacts/bin/Aspire.Templates.Tests/Debug/net8.0/logs/** artifacts/log/test-logs/** + - name: Copy CLI E2E recordings for upload + if: always() + shell: bash + run: | + mkdir -p cli-e2e-recordings + find testresults -name "*.cast" -exec cp {} cli-e2e-recordings/ \; 2>/dev/null || true + if [ -n "$(ls -A cli-e2e-recordings 2>/dev/null)" ]; then + echo "Found recordings:" + ls -la cli-e2e-recordings/ + fi + + - name: Upload CLI E2E recordings + if: always() + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: cli-e2e-recordings-${{ inputs.testShortName }} + path: cli-e2e-recordings/*.cast + if-no-files-found: ignore + - name: Generate test results summary if: always() shell: pwsh From 488c444c2f1ff9ef061205fc2fe4e89cc68046b3 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 5 Feb 2026 14:11:23 +0800 Subject: [PATCH 043/256] Refactor resource MCP refresh (#14353) --- src/Aspire.Cli/Commands/AgentMcpCommand.cs | 116 +++------------- .../Mcp/IMcpResourceToolRefreshService.cs | 51 +++++++ src/Aspire.Cli/Mcp/KnownMcpTools.cs | 21 +++ .../Mcp/McpResourceToolRefreshService.cs | 126 ++++++++++++++++++ src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs | 9 +- .../Commands/AgentMcpCommandTests.cs | 4 +- 6 files changed, 222 insertions(+), 105 deletions(-) create mode 100644 src/Aspire.Cli/Mcp/IMcpResourceToolRefreshService.cs create mode 100644 src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index ce9106bc126..36df5b2a4dd 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text.Json; using Aspire.Cli.Backchannel; @@ -32,15 +31,12 @@ namespace Aspire.Cli.Commands; internal sealed class AgentMcpCommand : BaseCommand { private readonly Dictionary _knownTools; - private string? _selectedAppHostPath; - private Dictionary? _resourceToolMap; + private readonly IMcpResourceToolRefreshService _resourceToolRefreshService; private McpServer? _server; private readonly IAuxiliaryBackchannelMonitor _auxiliaryBackchannelMonitor; - private readonly CliExecutionContext _executionContext; private readonly IMcpTransportFactory _transportFactory; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; - private readonly IDocsIndexService _docsIndexService; /// /// Gets the dictionary of known MCP tools. Exposed for testing purposes. @@ -64,11 +60,10 @@ public AgentMcpCommand( : base("mcp", AgentCommandStrings.McpCommand_Description, features, updateNotifier, executionContext, interactionService, telemetry) { _auxiliaryBackchannelMonitor = auxiliaryBackchannelMonitor; - _executionContext = executionContext; _transportFactory = transportFactory; _loggerFactory = loggerFactory; _logger = logger; - _docsIndexService = docsIndexService; + _resourceToolRefreshService = new McpResourceToolRefreshService(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()); _knownTools = new Dictionary { [KnownMcpTools.ListResources] = new ListResourcesTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), @@ -81,7 +76,7 @@ public AgentMcpCommand( [KnownMcpTools.ListAppHosts] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext), [KnownMcpTools.ListIntegrations] = new ListIntegrationsTool(packagingService, executionContext, auxiliaryBackchannelMonitor), [KnownMcpTools.Doctor] = new DoctorTool(environmentChecker), - [KnownMcpTools.RefreshTools] = new RefreshToolsTool(RefreshResourceToolMapAsync, SendToolsListChangedNotificationAsync), + [KnownMcpTools.RefreshTools] = new RefreshToolsTool(_resourceToolRefreshService), [KnownMcpTools.ListDocs] = new ListDocsTool(docsIndexService), [KnownMcpTools.SearchDocs] = new SearchDocsTool(docsSearchService, docsIndexService), [KnownMcpTools.GetDoc] = new GetDocTool(docsIndexService) @@ -121,13 +116,15 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var transport = _transportFactory.CreateTransport(); await using var server = McpServer.Create(transport, options, _loggerFactory); - // Keep a reference to the server for sending notifications + // Configure the refresh service with the server + _resourceToolRefreshService.SetMcpServer(server); _server = server; // Starts the MCP server, it's blocking until cancellation is requested await server.RunAsync(cancellationToken); // Clear the server reference on exit + _resourceToolRefreshService.SetMcpServer(null); _server = null; return ExitCodeConstants.Success; @@ -135,8 +132,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell private async ValueTask HandleListToolsAsync(RequestContext request, CancellationToken cancellationToken) { - _ = request; - _logger.LogDebug("MCP ListTools request received"); var tools = new List(); @@ -150,15 +145,14 @@ private async ValueTask HandleListToolsAsync(RequestContext new Tool + tools.AddRange(resourceToolMap.Select(x => new Tool { Name = x.Key, Description = x.Value.Tool.Description, @@ -213,17 +207,16 @@ private async ValueTask HandleCallToolAsync(RequestContext HandleCallToolAsync(RequestContext CallDashboardToolAsync( } } - private Task SendToolsListChangedNotificationAsync(CancellationToken cancellationToken) - { - var server = _server; - if (server is null) - { - throw new InvalidOperationException("MCP server is not running."); - } - - return server.SendNotificationAsync(NotificationMethods.ToolListChangedNotification, cancellationToken); - } - - [MemberNotNull(nameof(_resourceToolMap))] - private async Task RefreshResourceToolMapAsync(CancellationToken cancellationToken) - { - var refreshedMap = new Dictionary(StringComparer.Ordinal); - - try - { - var connection = await GetSelectedConnectionAsync(cancellationToken).ConfigureAwait(false); - - if (connection is not null) - { - // Collect initial snapshots from the stream - // The stream yields initial snapshots for all resources first - var resourcesWithTools = new List(); - var seenResources = new HashSet(StringComparer.Ordinal); - - await foreach (var snapshot in connection.WatchResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false)) - { - // Stop after we've seen all resources once (initial batch) - if (!seenResources.Add(snapshot.Name)) - { - break; - } - - if (snapshot.McpServer is not null) - { - resourcesWithTools.Add(snapshot); - } - } - - _logger.LogDebug("Resources with MCP tools received: {Count}", resourcesWithTools.Count); - - foreach (var resource in resourcesWithTools) - { - if (resource.McpServer is null) - { - continue; - } - - foreach (var tool in resource.McpServer.Tools) - { - var exposedName = $"{resource.Name.Replace("-", "_")}_{tool.Name}"; - refreshedMap[exposedName] = (resource.Name, tool); - - _logger.LogDebug("{Tool}: {Description}", exposedName, tool.Description); - } - } - } - - } - catch (Exception ex) - { - // Don't fail refresh_tools if resource discovery fails; still emit notification. - _logger.LogDebug(ex, "Failed to refresh resource MCP tool routing map"); - } - finally - { - // Ensure _resourceToolMap is always non-null when exiting, even if connection is null or an exception occurs. - _resourceToolMap = refreshedMap; - } - - return _resourceToolMap.Count + KnownTools.Count; - } - /// /// Gets the appropriate AppHost connection based on the selection logic. /// diff --git a/src/Aspire.Cli/Mcp/IMcpResourceToolRefreshService.cs b/src/Aspire.Cli/Mcp/IMcpResourceToolRefreshService.cs new file mode 100644 index 00000000000..b08357049e3 --- /dev/null +++ b/src/Aspire.Cli/Mcp/IMcpResourceToolRefreshService.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Aspire.Cli.Mcp; + +/// +/// Service responsible for refreshing resource-based MCP tools and sending tool list change notifications. +/// +internal interface IMcpResourceToolRefreshService +{ + /// + /// Attempts to get the current resource tool map if it is valid (not invalidated and AppHost hasn't changed). + /// + /// When this method returns true, contains the current resource tool map. + /// true if the tool map is valid and no refresh is needed; otherwise, false. + bool TryGetResourceToolMap(out IReadOnlyDictionary resourceToolMap); + + /// + /// Marks the resource tool map as needing a refresh. + /// + void InvalidateToolMap(); + + /// + /// Refreshes the resource tool map by discovering MCP tools from connected resources. + /// + /// The cancellation token. + /// The refreshed resource tool map. + Task> RefreshResourceToolMapAsync(CancellationToken cancellationToken); + + /// + /// Sends a tools list changed notification to connected MCP clients. + /// + /// The cancellation token. + Task SendToolsListChangedNotificationAsync(CancellationToken cancellationToken); + + /// + /// Sets the MCP server instance used for sending notifications. + /// + /// The MCP server, or null to clear. + void SetMcpServer(McpServer? server); +} + +/// +/// Represents an entry in the resource tool map. +/// +/// The name of the resource that exposes the tool. +/// The MCP tool definition. +internal sealed record ResourceToolEntry(string ResourceName, Tool Tool); diff --git a/src/Aspire.Cli/Mcp/KnownMcpTools.cs b/src/Aspire.Cli/Mcp/KnownMcpTools.cs index 78e65c1d01a..de9276b63c0 100644 --- a/src/Aspire.Cli/Mcp/KnownMcpTools.cs +++ b/src/Aspire.Cli/Mcp/KnownMcpTools.cs @@ -24,6 +24,27 @@ internal static class KnownMcpTools internal const string SearchDocs = "search_docs"; internal const string GetDoc = "get_doc"; + /// + /// Gets all known MCP tool names. + /// + public static IReadOnlyList All { get; } = + [ + ListResources, + ListConsoleLogs, + ExecuteResourceCommand, + ListStructuredLogs, + ListTraces, + ListTraceStructuredLogs, + SelectAppHost, + ListAppHosts, + ListIntegrations, + Doctor, + RefreshTools, + ListDocs, + SearchDocs, + GetDoc + ]; + public static bool IsLocalTool(string toolName) => toolName is SelectAppHost or ListAppHosts or diff --git a/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs b/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs new file mode 100644 index 00000000000..95ecf656214 --- /dev/null +++ b/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs @@ -0,0 +1,126 @@ +// 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 Aspire.Cli.Backchannel; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Aspire.Cli.Mcp; + +/// +/// Service responsible for refreshing resource-based MCP tools and sending tool list change notifications. +/// +internal sealed class McpResourceToolRefreshService : IMcpResourceToolRefreshService +{ + private readonly IAuxiliaryBackchannelMonitor _auxiliaryBackchannelMonitor; + private readonly ILogger _logger; + private readonly object _lock = new(); + private McpServer? _server; + private Dictionary _resourceToolMap = new(StringComparer.Ordinal); + private bool _invalidated = true; + private string? _selectedAppHostPath; + + public McpResourceToolRefreshService( + IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, + ILogger logger) + { + _auxiliaryBackchannelMonitor = auxiliaryBackchannelMonitor; + _logger = logger; + } + + /// + public bool TryGetResourceToolMap(out IReadOnlyDictionary resourceToolMap) + { + lock (_lock) + { + if (_invalidated || _selectedAppHostPath != _auxiliaryBackchannelMonitor.SelectedAppHostPath) + { + resourceToolMap = null!; + return false; + } + + resourceToolMap = _resourceToolMap; + return true; + } + } + + /// + public void InvalidateToolMap() + { + lock (_lock) + { + _invalidated = true; + } + } + + /// + public void SetMcpServer(McpServer? server) + { + _server = server; + } + + /// + public async Task SendToolsListChangedNotificationAsync(CancellationToken cancellationToken) + { + if (_server is { } server) + { + await server.SendNotificationAsync(NotificationMethods.ToolListChangedNotification, cancellationToken).ConfigureAwait(false); + } + } + + /// + public async Task> RefreshResourceToolMapAsync(CancellationToken cancellationToken) + { + _logger.LogDebug("Refreshing resource tool map."); + + var refreshedMap = new Dictionary(StringComparer.Ordinal); + + string? selectedAppHostPath = null; + try + { + var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(_auxiliaryBackchannelMonitor, _logger, cancellationToken).ConfigureAwait(false); + + if (connection is not null) + { + selectedAppHostPath = connection.AppHostInfo?.AppHostPath; + + var allResources = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); + var resourcesWithTools = allResources.Where(r => r.McpServer is not null).ToList(); + + _logger.LogDebug("Resources with MCP tools received: {Count}", resourcesWithTools.Count); + + foreach (var resource in resourcesWithTools) + { + Debug.Assert(resource.McpServer is not null); + + foreach (var tool in resource.McpServer.Tools) + { + var exposedName = $"{resource.Name.Replace("-", "_")}_{tool.Name}"; + refreshedMap[exposedName] = new ResourceToolEntry(resource.Name, tool); + + _logger.LogDebug("{Tool}: {Description}", exposedName, tool.Description); + } + } + } + else + { + _logger.LogDebug("Unable to refresh resource tool map because there's no selected connection."); + } + } + catch (Exception ex) + { + // Don't fail refresh_tools if resource discovery fails; still emit notification. + _logger.LogDebug(ex, "Failed to refresh resource MCP tool routing map."); + } + + lock (_lock) + { + _resourceToolMap = refreshedMap; + _selectedAppHostPath = selectedAppHostPath; + _invalidated = false; + return _resourceToolMap; + } + } +} diff --git a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs index 9eee648bf78..502f00251a3 100644 --- a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs @@ -6,7 +6,7 @@ namespace Aspire.Cli.Mcp.Tools; -internal sealed class RefreshToolsTool(Func> refreshToolsAsync, Func sendToolsListChangedNotificationAsync) : CliMcpTool +internal sealed class RefreshToolsTool(IMcpResourceToolRefreshService refreshService) : CliMcpTool { public override string Name => KnownMcpTools.RefreshTools; @@ -21,12 +21,13 @@ public override async ValueTask CallToolAsync(CallToolContext co { _ = context; - var count = await refreshToolsAsync(cancellationToken).ConfigureAwait(false); - await sendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false); + var resourceToolMap = await refreshService.RefreshResourceToolMapAsync(cancellationToken).ConfigureAwait(false); + await refreshService.SendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false); + var totalToolCount = KnownMcpTools.All.Count + resourceToolMap.Count; return new CallToolResult { - Content = [new TextContentBlock { Text = $"Tools refreshed: {count} tools available" }] + Content = [new TextContentBlock { Text = $"Tools refreshed: {totalToolCount} tools available" }] }; } } diff --git a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs index e0b3f071083..dd7cb3e3587 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs @@ -337,8 +337,8 @@ public async Task McpServer_CallTool_RefreshTools_ReturnsResult() var textContent = result.Content[0] as TextContentBlock; Assert.NotNull(textContent); - // Verify the exact text content with the correct tool count - var expectedToolCount = _agentMcpCommand.KnownTools.Count; + // Verify the text content indicates refresh success (resource tool count is 0 in this test, so total = known tools) + var expectedToolCount = KnownMcpTools.All.Count; Assert.Equal($"Tools refreshed: {expectedToolCount} tools available", textContent.Text); // Assert - Verify the ToolListChanged notification was received From befae9a11379300dfa6c18133f465b7e843e73cd Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 5 Feb 2026 17:50:06 +1100 Subject: [PATCH 044/256] Add Kubernetes/Helm E2E test for CLI publishing (#14352) * Add Kubernetes/Helm E2E test for CLI publishing - Add KubernetesPublishTests.cs to correct project (Aspire.Cli.EndToEnd.Tests) - Delete orphaned tests/Aspire.Cli.EndToEndTests folder (misnamed, no csproj) * Address PR feedback: improve test reliability and cleanup - Add .WithDimensions(160, 48) to terminal builder for consistency - Make KinD/Helm versions configurable via environment variables - Use unique cluster names (GUID-based) for parallel test execution - Add kubectl wait --for=condition=Ready for proper pod verification - Wrap test in try/finally with best-effort cleanup via Process API - Improve comment explaining port override rationale * Pass CancellationToken to WaitForExitAsync in cleanup --------- Co-authored-by: Mitch Denny --- .../KubernetesPublishTests.cs | 85 ++++++++++++++----- 1 file changed, 62 insertions(+), 23 deletions(-) rename tests/{Aspire.Cli.EndToEndTests => Aspire.Cli.EndToEnd.Tests}/KubernetesPublishTests.cs (81%) diff --git a/tests/Aspire.Cli.EndToEndTests/KubernetesPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs similarity index 81% rename from tests/Aspire.Cli.EndToEndTests/KubernetesPublishTests.cs rename to tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs index 88a58ff4669..42b57f0ecb4 100644 --- a/tests/Aspire.Cli.EndToEndTests/KubernetesPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs @@ -1,13 +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 Aspire.Cli.EndToEndTests.Helpers; +using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; using Hex1b; using Hex1b.Automation; using Xunit; -namespace Aspire.Cli.EndToEndTests; +namespace Aspire.Cli.EndToEnd.Tests; /// /// End-to-end tests for Aspire CLI publishing to Kubernetes/Helm. @@ -18,9 +18,13 @@ namespace Aspire.Cli.EndToEndTests; public sealed class KubernetesPublishTests(ITestOutputHelper output) { private const string ProjectName = "AspireKubernetesPublishTest"; - private const string KindVersion = "v0.31.0"; - private const string HelmVersion = "v3.17.3"; - private const string ClusterName = "aspire-e2e-test"; + 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 GenerateUniqueClusterName() => + $"{ClusterNamePrefix}-{Guid.NewGuid():N}"[..32]; // KinD cluster names max 32 chars [Fact] public async Task CreateAndPublishToKubernetes() @@ -31,9 +35,15 @@ public async Task CreateAndPublishToKubernetes() var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndPublishToKubernetes)); + var clusterName = GenerateUniqueClusterName(); + + output.WriteLine($"Using KinD version: {KindVersion}"); + output.WriteLine($"Using Helm version: {HelmVersion}"); + output.WriteLine($"Using cluster name: {clusterName}"); var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); @@ -116,12 +126,17 @@ public async Task CreateAndPublishToKubernetes() // Phase 2: Create KinD cluster // ===================================================================== - sequenceBuilder.Type($"kind create cluster --name={ClusterName} --wait=120s") + // Delete any existing cluster with the same name to ensure a clean state + sequenceBuilder.Type($"kind delete cluster --name={clusterName} || true") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + sequenceBuilder.Type($"kind create cluster --name={clusterName} --wait=120s") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); // Verify cluster is ready - sequenceBuilder.Type($"kubectl cluster-info --context kind-{ClusterName}") + sequenceBuilder.Type($"kubectl cluster-info --context kind-{clusterName}") .Enter() .WaitForSuccessPrompt(counter); @@ -162,18 +177,12 @@ public async Task CreateAndPublishToKubernetes() // Step 3: Add Aspire.Hosting.Kubernetes package using aspire add // Pass the package name directly as an argument to avoid interactive selection + // The version selection prompt always appears for 'aspire add' sequenceBuilder.Type("aspire add Aspire.Hosting.Kubernetes") - .Enter(); - - // In CI, aspire add shows a version selection prompt (unlike aspire new which auto-selects when channel is set) - if (isCI) - { - sequenceBuilder - .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) - .Enter(); // select first version (PR build) - } - - sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + .Enter() + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // select first version + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); // Step 4: Modify AppHost's main file to add Kubernetes environment // We'll use a callback to modify the file during sequence execution @@ -262,11 +271,11 @@ public async Task CreateAndPublishToKubernetes() // Load the built images into the KinD cluster // KinD runs containers inside Docker, so we need to load images into the cluster's nodes - sequenceBuilder.Type($"kind load docker-image apiservice:latest --name={ClusterName}") + sequenceBuilder.Type($"kind load docker-image apiservice:latest --name={clusterName}") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); - sequenceBuilder.Type($"kind load docker-image webfrontend:latest --name={ClusterName}") + sequenceBuilder.Type($"kind load docker-image webfrontend:latest --name={clusterName}") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); @@ -292,7 +301,8 @@ public async Task CreateAndPublishToKubernetes() // Install the Helm chart using the real container images built by Aspire // The images are already loaded into KinD, so we use the default values.yaml // which references apiservice:latest and webfrontend:latest - // Override ports to ensure unique values and avoid any duplicate port issues + // Override ports to ensure unique values per service - the Helm chart may have + // duplicate port defaults that cause "port already allocated" errors during deployment sequenceBuilder.Type("helm install aspire-app helm-output " + "--set parameters.apiservice.port_http=8080 " + "--set parameters.apiservice.port_https=8443 " + @@ -312,6 +322,11 @@ public async Task CreateAndPublishToKubernetes() .Enter() .WaitForSuccessPrompt(counter); + // Wait for all pods to be ready (not just created) + sequenceBuilder.Type("kubectl wait --for=condition=Ready pod --all --timeout=120s") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + // Check all Kubernetes resources were created sequenceBuilder.Type("kubectl get all") .Enter() @@ -332,7 +347,7 @@ public async Task CreateAndPublishToKubernetes() .WaitForSuccessPrompt(counter); // Delete the KinD cluster - sequenceBuilder.Type($"kind delete cluster --name={ClusterName}") + sequenceBuilder.Type($"kind delete cluster --name={clusterName}") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); @@ -341,7 +356,31 @@ public async Task CreateAndPublishToKubernetes() var sequence = sequenceBuilder.Build(); - await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + try + { + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + } + finally + { + // Best-effort cleanup: ensure cluster is deleted even if test fails + // This runs outside the terminal sequence to guarantee execution + try + { + using var cleanupProcess = new System.Diagnostics.Process(); + cleanupProcess.StartInfo.FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "kind"); + cleanupProcess.StartInfo.Arguments = $"delete cluster --name={clusterName}"; + cleanupProcess.StartInfo.RedirectStandardOutput = true; + cleanupProcess.StartInfo.RedirectStandardError = true; + cleanupProcess.StartInfo.UseShellExecute = false; + cleanupProcess.Start(); + await cleanupProcess.WaitForExitAsync(TestContext.Current.CancellationToken); + output.WriteLine($"Cleanup: KinD cluster '{clusterName}' deleted (exit code: {cleanupProcess.ExitCode})"); + } + catch (Exception ex) + { + output.WriteLine($"Cleanup: Failed to delete KinD cluster '{clusterName}': {ex.Message}"); + } + } await pendingRun; } From de7ec1bae833381d1bcc6897c2988893ed026f52 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 5 Feb 2026 17:41:45 +0800 Subject: [PATCH 045/256] Refactor CLI telemetry MCP tools to use dashboard APIs (#14337) --- src/Aspire.Cli/Aspire.Cli.csproj | 2 + src/Aspire.Cli/Commands/AgentMcpCommand.cs | 110 +-- .../Commands/TelemetryCommandHelpers.cs | 59 +- .../Commands/TelemetryLogsCommand.cs | 7 +- .../Commands/TelemetrySpansCommand.cs | 7 +- .../Commands/TelemetryTracesCommand.cs | 7 +- src/Aspire.Cli/Mcp/KnownMcpTools.cs | 18 - src/Aspire.Cli/Mcp/Tools/DoctorTool.cs | 3 - .../Mcp/Tools/ExecuteResourceCommandTool.cs | 1 - src/Aspire.Cli/Mcp/Tools/GetDocTool.cs | 4 +- src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs | 3 - .../Mcp/Tools/ListConsoleLogsTool.cs | 6 +- src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs | 4 +- .../Mcp/Tools/ListIntegrationsTool.cs | 3 - src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs | 3 - .../Mcp/Tools/ListStructuredLogsTool.cs | 84 ++- .../Mcp/Tools/ListTraceStructuredLogsTool.cs | 83 ++- src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs | 84 ++- src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs | 52 ++ src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs | 2 - src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs | 5 +- src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs | 3 - .../Otlp/OtlpCliJsonSerializerContext.cs | 23 +- .../Api/TelemetryApiService.cs | 13 +- src/Aspire.Dashboard/Aspire.Dashboard.csproj | 2 + .../Dialogs/ManageDataDialog.razor.cs | 2 +- .../Components/Pages/StructuredLogs.razor.cs | 2 +- .../Components/Pages/Traces.razor.cs | 4 +- .../Mcp/AspireResourceMcpTools.cs | 6 +- .../Mcp/AspireTelemetryMcpTools.cs | 27 +- .../Model/Assistant/AIHelpers.cs | 294 +++----- .../Assistant/AssistantChatDataContext.cs | 23 +- .../Assistant/Prompts/KnownChatMessages.cs | 37 +- .../Prompts/PromptContextsBuilder.cs | 18 +- src/Aspire.Dashboard/Model/ExportHelpers.cs | 14 +- .../GenAI/GenAIVisualizerDialogViewModel.cs | 4 +- .../Model/Otlp/ResourcesSelectHelpers.cs | 2 +- .../Model/Otlp/SpanWaterfallViewModel.cs | 2 +- .../Model/SpanDetailsViewModel.cs | 2 +- src/Aspire.Dashboard/Model/SpanMenuBuilder.cs | 7 +- .../Model/TelemetryExportService.cs | 96 ++- .../Model/TraceMenuBuilder.cs | 7 +- .../Otlp/Model/OtlpLogEntry.cs | 1 + .../Otlp/Model/OtlpResource.cs | 43 +- .../Otlp/Storage/TelemetryRepository.cs | 2 +- .../ConsoleLogs}/PromptContext.cs | 4 +- src/Shared/ConsoleLogs/SharedAIHelpers.cs | 628 +++++++++++++++++- src/Shared/Otlp/IOtlpResource.cs | 28 + src/Shared/Otlp/OtlpHelpers.cs | 48 ++ .../Mcp/Docs/DocsFetcherTests.cs | 50 +- .../Mcp/ListStructuredLogsToolTests.cs | 429 ++++++++++++ .../Mcp/ListTracesToolTests.cs | 468 +++++++++++++ .../Mcp/McpToolHelpersTests.cs | 24 + .../Utils/MockHttpClientFactory.cs | 21 + .../Utils/MockHttpMessageHandler.cs | 73 ++ .../Model/AIAssistant/AIHelpersTests.cs | 12 +- .../AssistantChatDataContextTests.cs | 11 +- .../Model/TelemetryExportServiceTests.cs | 146 +++- .../Model/TelemetryImportServiceTests.cs | 64 +- .../TelemetryApiServiceTests.cs | 30 +- .../TelemetryRepositoryTests/ResourceTests.cs | 4 +- .../ExecutionConfigurationGathererTests.cs | 4 - 62 files changed, 2546 insertions(+), 679 deletions(-) create mode 100644 src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs rename src/{Aspire.Dashboard/Model/Assistant => Shared/ConsoleLogs}/PromptContext.cs (95%) create mode 100644 src/Shared/Otlp/IOtlpResource.cs create mode 100644 tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs create mode 100644 tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs create mode 100644 tests/Aspire.Cli.Tests/Mcp/McpToolHelpersTests.cs create mode 100644 tests/Aspire.Cli.Tests/Utils/MockHttpClientFactory.cs create mode 100644 tests/Aspire.Cli.Tests/Utils/MockHttpMessageHandler.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index fa4d3683a6f..8d8ed84f6f3 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -75,6 +75,7 @@ + @@ -84,6 +85,7 @@ + diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index 36df5b2a4dd..bb239d4ee48 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using System.Globalization; using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Configuration; @@ -18,7 +17,6 @@ using Aspire.Shared.Mcp; using Microsoft.Extensions.Logging; using ModelContextProtocol; -using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -56,6 +54,7 @@ public AgentMcpCommand( IEnvironmentChecker environmentChecker, IDocsSearchService docsSearchService, IDocsIndexService docsIndexService, + IHttpClientFactory httpClientFactory, AspireCliTelemetry telemetry) : base("mcp", AgentCommandStrings.McpCommand_Description, features, updateNotifier, executionContext, interactionService, telemetry) { @@ -69,9 +68,9 @@ public AgentMcpCommand( [KnownMcpTools.ListResources] = new ListResourcesTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), [KnownMcpTools.ListConsoleLogs] = new ListConsoleLogsTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), [KnownMcpTools.ExecuteResourceCommand] = new ExecuteResourceCommandTool(auxiliaryBackchannelMonitor, loggerFactory.CreateLogger()), - [KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(), - [KnownMcpTools.ListTraces] = new ListTracesTool(), - [KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(), + [KnownMcpTools.ListStructuredLogs] = new ListStructuredLogsTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger()), + [KnownMcpTools.ListTraces] = new ListTracesTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger()), + [KnownMcpTools.ListTraceStructuredLogs] = new ListTraceStructuredLogsTool(auxiliaryBackchannelMonitor, httpClientFactory, loggerFactory.CreateLogger()), [KnownMcpTools.SelectAppHost] = new SelectAppHostTool(auxiliaryBackchannelMonitor, executionContext), [KnownMcpTools.ListAppHosts] = new ListAppHostsTool(auxiliaryBackchannelMonitor, executionContext), [KnownMcpTools.ListIntegrations] = new ListIntegrationsTool(packagingService, executionContext, auxiliaryBackchannelMonitor), @@ -176,33 +175,17 @@ private async ValueTask HandleCallToolAsync(RequestContext HandleCallToolAsync(RequestContext CallDashboardToolAsync( - string toolName, - CliMcpTool tool, - ProgressToken? progressToken, - IReadOnlyDictionary? arguments, - CancellationToken cancellationToken) - { - var connection = await GetSelectedConnectionAsync(cancellationToken).ConfigureAwait(false); - if (connection is null) - { - _logger.LogWarning("No Aspire AppHost is currently running"); - throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError); - } - - if (connection.McpInfo is null) - { - _logger.LogWarning("Dashboard is not available in the running AppHost"); - throw new McpProtocolException(McpErrorMessages.DashboardNotAvailable, McpErrorCode.InternalError); - } - - _logger.LogInformation( - "Connecting to dashboard MCP server. " + - "Dashboard URL: {EndpointUrl}, " + - "AppHost Path: {AppHostPath}, " + - "AppHost PID: {AppHostPid}, " + - "CLI PID: {CliPid}", - connection.McpInfo.EndpointUrl, - connection.AppHostInfo?.AppHostPath ?? "N/A", - connection.AppHostInfo?.ProcessId.ToString(CultureInfo.InvariantCulture) ?? "N/A", - connection.AppHostInfo?.CliProcessId?.ToString(CultureInfo.InvariantCulture) ?? "N/A"); - - var transportOptions = new HttpClientTransportOptions - { - Endpoint = new Uri(connection.McpInfo.EndpointUrl), - AdditionalHeaders = new Dictionary - { - ["x-mcp-api-key"] = connection.McpInfo.ApiToken - } - }; - - using var httpClient = new HttpClient(); - await using var transport = new HttpClientTransport(transportOptions, httpClient, _loggerFactory, ownsHttpClient: true); - - // Create MCP client to communicate with the dashboard - await using var mcpClient = await McpClient.CreateAsync(transport, cancellationToken: cancellationToken); - - _logger.LogDebug("Calling tool {ToolName} on dashboard MCP server", toolName); - - try - { - _logger.LogDebug("Invoking CallToolAsync for tool {ToolName} with arguments: {Arguments}", toolName, arguments); - var context = new CallToolContext - { - Notifier = new McpServerNotifier(_server!), - McpClient = mcpClient, - Arguments = arguments, - ProgressToken = progressToken - }; - var result = await tool.CallToolAsync(context, cancellationToken).ConfigureAwait(false); - _logger.LogDebug("Tool {ToolName} completed successfully", toolName); - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while calling tool {ToolName}", toolName); - throw; - } - } - /// /// Gets the appropriate AppHost connection based on the selection logic. /// diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs index 9d4e8241e53..27287908a2b 100644 --- a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs +++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs @@ -3,7 +3,7 @@ using System.CommandLine; using System.Globalization; -using System.Text.Json; +using System.Net.Http.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Interaction; using Aspire.Cli.Otlp; @@ -165,61 +165,58 @@ public static HttpClient CreateApiClient(IHttpClientFactory factory, string apiT return client; } - /// - /// Fetches available resources from the Dashboard API and resolves a resource name to specific instances. - /// If the resource name matches a base name with multiple replicas, returns all matching replica names. - /// - /// The HTTP client configured for Dashboard API access. - /// The Dashboard API base URL. - /// The resource name to resolve (can be base name or full instance name). - /// Cancellation token. - /// A list of resolved resource display names to query, or null if resource not found. - public static async Task?> ResolveResourceNamesAsync( - HttpClient client, - string baseUrl, + public static bool TryResolveResourceNames( string? resourceName, - CancellationToken cancellationToken) + IList resources, + out List? resolvedResources) { if (string.IsNullOrEmpty(resourceName)) { - // No filter - return null to indicate no resource filter - return null; + // No filter - return true to indicate success + resolvedResources = null; + return true; } - // Fetch available resources - var url = DashboardUrls.TelemetryResourcesApiUrl(baseUrl); - var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - var resources = JsonSerializer.Deserialize(json, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); - - if (resources is null || resources.Length == 0) + if (resources is null || resources.Count == 0) { - return null; + resolvedResources = null; + return false; } // First, try exact match on display name (full instance name like "catalogservice-abc123") var exactMatch = resources.FirstOrDefault(r => - string.Equals(r.DisplayName, resourceName, StringComparison.OrdinalIgnoreCase)); + string.Equals(r.GetCompositeName(), resourceName, StringComparison.OrdinalIgnoreCase)); if (exactMatch is not null) { - return [exactMatch.DisplayName]; + resolvedResources = [exactMatch.GetCompositeName()]; + return true; } // Then, try matching by base name to find all replicas var matchingReplicas = resources .Where(r => string.Equals(r.Name, resourceName, StringComparison.OrdinalIgnoreCase)) - .Select(r => r.DisplayName) + .Select(r => r.GetCompositeName()) .ToList(); if (matchingReplicas.Count > 0) { - return matchingReplicas; + resolvedResources = matchingReplicas; + return true; } // No match found - return []; + resolvedResources = null; + return false; + } + + public static async Task GetAllResourcesAsync(HttpClient client, string baseUrl, CancellationToken cancellationToken) + { + var url = DashboardUrls.TelemetryResourcesApiUrl(baseUrl); + var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var resources = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray, cancellationToken).ConfigureAwait(false); + return resources!; } /// diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs index 46afd08b45b..c363581dbb7 100644 --- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs @@ -111,11 +111,10 @@ private async Task FetchLogsAsync( using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); // Resolve resource name to specific instances (handles replicas) - var resolvedResources = await TelemetryCommandHelpers.ResolveResourceNamesAsync( - client, baseUrl, resource, cancellationToken); + var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false); - // If a resource was specified but not found, show error - if (!string.IsNullOrEmpty(resource) && resolvedResources?.Count == 0) + // If a resource was specified but not found, return error + if (!TelemetryCommandHelpers.TryResolveResourceNames(resource, resources, out var resolvedResources)) { _interactionService.DisplayError($"Resource '{resource}' not found."); return ExitCodeConstants.InvalidCommand; diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs index 4f884ee9a65..a82e6163976 100644 --- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs @@ -107,11 +107,10 @@ private async Task FetchSpansAsync( using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); // Resolve resource name to specific instances (handles replicas) - var resolvedResources = await TelemetryCommandHelpers.ResolveResourceNamesAsync( - client, baseUrl, resource, cancellationToken); + var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false); - // If a resource was specified but not found, show error - if (!string.IsNullOrEmpty(resource) && resolvedResources?.Count == 0) + // If a resource was specified but not found, return error + if (!TelemetryCommandHelpers.TryResolveResourceNames(resource, resources, out var resolvedResources)) { _interactionService.DisplayError($"Resource '{resource}' not found."); return ExitCodeConstants.InvalidCommand; diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs index efadfc7efcc..442d664a383 100644 --- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs @@ -160,11 +160,10 @@ private async Task FetchTracesAsync( using var client = TelemetryCommandHelpers.CreateApiClient(_httpClientFactory, apiToken); // Resolve resource name to specific instances (handles replicas) - var resolvedResources = await TelemetryCommandHelpers.ResolveResourceNamesAsync( - client, baseUrl, resource, cancellationToken); + var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, baseUrl, cancellationToken).ConfigureAwait(false); - // If a resource was specified but not found, show error - if (!string.IsNullOrEmpty(resource) && resolvedResources?.Count == 0) + // If a resource was specified but not found, return error + if (!TelemetryCommandHelpers.TryResolveResourceNames(resource, resources, out var resolvedResources)) { _interactionService.DisplayError($"Resource '{resource}' not found."); return ExitCodeConstants.InvalidCommand; diff --git a/src/Aspire.Cli/Mcp/KnownMcpTools.cs b/src/Aspire.Cli/Mcp/KnownMcpTools.cs index de9276b63c0..346211b7846 100644 --- a/src/Aspire.Cli/Mcp/KnownMcpTools.cs +++ b/src/Aspire.Cli/Mcp/KnownMcpTools.cs @@ -14,7 +14,6 @@ internal static class KnownMcpTools internal const string ListStructuredLogs = "list_structured_logs"; internal const string ListTraces = "list_traces"; internal const string ListTraceStructuredLogs = "list_trace_structured_logs"; - internal const string SelectAppHost = "select_apphost"; internal const string ListAppHosts = "list_apphosts"; internal const string ListIntegrations = "list_integrations"; @@ -45,21 +44,4 @@ internal static class KnownMcpTools GetDoc ]; - public static bool IsLocalTool(string toolName) => toolName is - SelectAppHost or - ListAppHosts or - ListIntegrations or - Doctor or - RefreshTools or - ListDocs or - SearchDocs or - GetDoc or - ListResources or - ListConsoleLogs or - ExecuteResourceCommand; - - public static bool IsDashboardTool(string toolName) => toolName is - ListStructuredLogs or - ListTraces or - ListTraceStructuredLogs; } diff --git a/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs b/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs index 846fbea7715..3b09c3e0583 100644 --- a/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/DoctorTool.cs @@ -30,9 +30,6 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client or arguments - _ = context; - try { // Run all environment checks diff --git a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs index b369d343c56..4e8ec36df79 100644 --- a/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ExecuteResourceCommandTool.cs @@ -44,7 +44,6 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { var arguments = context.Arguments; - if (arguments is null || !arguments.TryGetValue("resourceName", out var resourceNameElement) || !arguments.TryGetValue("commandName", out var commandNameElement)) diff --git a/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs b/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs index 0e21d6b3e11..aa6cfda5e32 100644 --- a/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/GetDocTool.cs @@ -44,9 +44,7 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync( - CallToolContext context, - CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { var arguments = context.Arguments; diff --git a/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs index 14ac11b57a9..6e906ae8f96 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListAppHostsTool.cs @@ -37,9 +37,6 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client as it operates locally - _ = context; - // Trigger an immediate scan to ensure we have the latest AppHost connections await auxiliaryBackchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs index 56fa8aade4f..e5e2cc06975 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListConsoleLogsTool.cs @@ -72,15 +72,15 @@ public override async ValueTask CallToolAsync(CallToolContext co var entries = logEntries.GetEntries().ToList(); var totalLogsCount = entries.Count == 0 ? 0 : entries.Last().LineNumber; - var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( + var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( entries, totalLogsCount, SharedAIHelpers.ConsoleLogsLimit, "console log", "console logs", SharedAIHelpers.SerializeLogEntry, - logEntry => SharedAIHelpers.EstimateTokenCount((string)logEntry)); - var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems.Cast().ToList()); + SharedAIHelpers.EstimateTokenCount); + var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems); var consoleLogsData = $""" {limitMessage} diff --git a/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs index 6969f0925cd..14387d20a05 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListDocsTool.cs @@ -38,9 +38,7 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync( - CallToolContext context, - CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { await DocsToolHelper.EnsureIndexedWithNotificationsAsync(_docsIndexService, context.ProgressToken, context.Notifier, cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs index 443702cb971..9e74b3d46b3 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListIntegrationsTool.cs @@ -69,9 +69,6 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client as it operates locally - _ = context; - try { // Get all channels diff --git a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs index 8e579c8d115..1a55f716492 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs @@ -57,9 +57,6 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client as it operates via backchannel - _ = context; - var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); if (connection is null) { diff --git a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs index 17eab72cfe7..ffe2e8163e2 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListStructuredLogsTool.cs @@ -1,13 +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.Net.Http.Json; using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Commands; +using Aspire.Cli.Otlp; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; +using Aspire.Shared.ConsoleLogs; +using Microsoft.Extensions.Logging; using ModelContextProtocol; using ModelContextProtocol.Protocol; namespace Aspire.Cli.Mcp.Tools; -internal sealed class ListStructuredLogsTool : CliMcpTool +/// +/// MCP tool for listing structured logs. +/// Gets log data directly from the Dashboard telemetry API. +/// +internal sealed class ListStructuredLogsTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListStructuredLogs; @@ -30,22 +42,66 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // Convert JsonElement arguments to Dictionary - Dictionary? convertedArgs = null; - if (context.Arguments != null) + var arguments = context.Arguments; + var (apiToken, apiBaseUrl, dashboardBaseUrl) = await McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + + // Extract resourceName from arguments + string? resourceName = null; + if (arguments?.TryGetValue("resourceName", out var resourceNameElement) == true && + resourceNameElement.ValueKind == JsonValueKind.String) { - convertedArgs = new Dictionary(); - foreach (var kvp in context.Arguments) + resourceName = resourceNameElement.GetString(); + } + + try + { + using var client = TelemetryCommandHelpers.CreateApiClient(httpClientFactory, apiToken); + + // Resolve resource name to specific instances (handles replicas) + var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, apiBaseUrl, cancellationToken).ConfigureAwait(false); + + // If a resource was specified but not found, return error + if (!TelemetryCommandHelpers.TryResolveResourceNames(resourceName, resources, out var resolvedResources)) { - convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"Resource '{resourceName}' not found." }], + IsError = true + }; } - } - // Forward the call to the dashboard's MCP server - return await context.McpClient!.CallToolAsync( - Name, - convertedArgs, - serializerOptions: McpJsonUtilities.DefaultOptions, - cancellationToken: cancellationToken); + var url = DashboardUrls.TelemetryLogsApiUrl(apiBaseUrl, resolvedResources); + + logger.LogDebug("Fetching structured logs from {Url}", url); + + var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var apiResponse = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.TelemetryApiResponse, cancellationToken).ConfigureAwait(false); + var resourceLogs = apiResponse?.Data?.ResourceLogs; + + var (logsData, limitMessage) = SharedAIHelpers.GetStructuredLogsJson( + resourceLogs, + getResourceName: s => OtlpHelpers.GetResourceName(s, resources.Select(r => new SimpleOtlpResource(r.Name, r.InstanceId)).ToList()), + dashboardBaseUrl: dashboardBaseUrl); + + var text = $""" + {limitMessage} + + # STRUCTURED LOGS DATA + + {logsData} + """; + + return new CallToolResult + { + Content = [new TextContentBlock { Text = text }] + }; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Failed to fetch structured logs from Dashboard API"); + throw new McpProtocolException($"Failed to fetch structured logs: {ex.Message}", McpErrorCode.InternalError); + } } } diff --git a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs index 7127c546758..05e55e55b2e 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTraceStructuredLogsTool.cs @@ -1,13 +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.Net.Http.Json; using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Commands; +using Aspire.Cli.Otlp; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; +using Aspire.Shared.ConsoleLogs; +using Microsoft.Extensions.Logging; using ModelContextProtocol; using ModelContextProtocol.Protocol; namespace Aspire.Cli.Mcp.Tools; -internal sealed class ListTraceStructuredLogsTool : CliMcpTool +/// +/// MCP tool for listing structured logs for a specific distributed trace. +/// Gets log data directly from the Dashboard telemetry API. +/// +internal sealed class ListTraceStructuredLogsTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListTraceStructuredLogs; @@ -31,22 +43,65 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // Convert JsonElement arguments to Dictionary - Dictionary? convertedArgs = null; - if (context.Arguments != null) + var arguments = context.Arguments; + var (apiToken, apiBaseUrl, dashboardBaseUrl) = await McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + + // Extract traceId from arguments (required) + string? traceId = null; + if (arguments?.TryGetValue("traceId", out var traceIdElement) == true && + traceIdElement.ValueKind == JsonValueKind.String) { - convertedArgs = new Dictionary(); - foreach (var kvp in context.Arguments) + traceId = traceIdElement.GetString(); + } + + if (string.IsNullOrEmpty(traceId)) + { + return new CallToolResult { - convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; - } + Content = [new TextContentBlock { Text = "The 'traceId' parameter is required." }], + IsError = true + }; } - // Forward the call to the dashboard's MCP server - return await context.McpClient!.CallToolAsync( - Name, - convertedArgs, - serializerOptions: McpJsonUtilities.DefaultOptions, - cancellationToken: cancellationToken); + try + { + using var client = TelemetryCommandHelpers.CreateApiClient(httpClientFactory, apiToken); + + var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, apiBaseUrl, cancellationToken).ConfigureAwait(false); + + // Build the logs API URL with traceId filter + var url = DashboardUrls.TelemetryLogsApiUrl(apiBaseUrl, resources: null, ("traceId", traceId)); + + logger.LogDebug("Fetching structured logs from {Url}", url); + + var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var apiResponse = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.TelemetryApiResponse, cancellationToken).ConfigureAwait(false); + var resourceLogs = apiResponse?.Data?.ResourceLogs; + + var (logsData, limitMessage) = SharedAIHelpers.GetStructuredLogsJson( + resourceLogs, + getResourceName: s => OtlpHelpers.GetResourceName(s, resources.Select(r => new SimpleOtlpResource(r.Name, r.InstanceId)).ToList()), + dashboardBaseUrl: dashboardBaseUrl); + + var text = $""" + {limitMessage} + + # STRUCTURED LOGS DATA + + {logsData} + """; + + return new CallToolResult + { + Content = [new TextContentBlock { Text = text }] + }; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Failed to fetch structured logs for trace from Dashboard API"); + throw new McpProtocolException($"Failed to fetch structured logs for trace: {ex.Message}", McpErrorCode.InternalError); + } } } diff --git a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs index 4e589bcffba..c9b032366c5 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListTracesTool.cs @@ -1,13 +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.Net.Http.Json; using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Commands; +using Aspire.Cli.Otlp; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; +using Aspire.Shared.ConsoleLogs; +using Microsoft.Extensions.Logging; using ModelContextProtocol; using ModelContextProtocol.Protocol; namespace Aspire.Cli.Mcp.Tools; -internal sealed class ListTracesTool : CliMcpTool +/// +/// MCP tool for listing distributed traces. +/// Gets trace data directly from the Dashboard telemetry API. +/// +internal sealed class ListTracesTool(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, IHttpClientFactory httpClientFactory, ILogger logger) : CliMcpTool { public override string Name => KnownMcpTools.ListTraces; @@ -30,22 +42,66 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // Convert JsonElement arguments to Dictionary - Dictionary? convertedArgs = null; - if (context.Arguments != null) + var arguments = context.Arguments; + var (apiToken, apiBaseUrl, dashboardBaseUrl) = await McpToolHelpers.GetDashboardInfoAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + + // Extract resourceName from arguments + string? resourceName = null; + if (arguments?.TryGetValue("resourceName", out var resourceNameElement) == true && + resourceNameElement.ValueKind == JsonValueKind.String) { - convertedArgs = new Dictionary(); - foreach (var kvp in context.Arguments) + resourceName = resourceNameElement.GetString(); + } + + try + { + using var client = TelemetryCommandHelpers.CreateApiClient(httpClientFactory, apiToken); + + // Resolve resource name to specific instances (handles replicas) + var resources = await TelemetryCommandHelpers.GetAllResourcesAsync(client, apiBaseUrl, cancellationToken).ConfigureAwait(false); + + // If a resource was specified but not found, return error + if (!TelemetryCommandHelpers.TryResolveResourceNames(resourceName, resources, out var resolvedResources)) { - convertedArgs[kvp.Key] = kvp.Value.ValueKind == JsonValueKind.Null ? null : kvp.Value; + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"Resource '{resourceName}' not found." }], + IsError = true + }; } - } - // Forward the call to the dashboard's MCP server - return await context.McpClient!.CallToolAsync( - Name, - convertedArgs, - serializerOptions: McpJsonUtilities.DefaultOptions, - cancellationToken: cancellationToken); + var url = DashboardUrls.TelemetryTracesApiUrl(apiBaseUrl, resolvedResources); + + logger.LogDebug("Fetching traces from {Url}", url); + + var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var apiResponse = await response.Content.ReadFromJsonAsync(OtlpCliJsonSerializerContext.Default.TelemetryApiResponse, cancellationToken).ConfigureAwait(false); + var resourceSpans = apiResponse?.Data?.ResourceSpans; + + var (tracesData, limitMessage) = SharedAIHelpers.GetTracesJson( + resourceSpans, + getResourceName: s => OtlpHelpers.GetResourceName(s, resources.Select(r => new SimpleOtlpResource(r.Name, r.InstanceId)).ToList()), + dashboardBaseUrl: dashboardBaseUrl); + + var text = $""" + {limitMessage} + + # TRACES DATA + + {tracesData} + """; + + return new CallToolResult + { + Content = [new TextContentBlock { Text = text }] + }; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Failed to fetch traces from Dashboard API"); + throw new McpProtocolException($"Failed to fetch traces: {ex.Message}", McpErrorCode.InternalError); + } } } diff --git a/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs b/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs new file mode 100644 index 00000000000..20f520e173b --- /dev/null +++ b/src/Aspire.Cli/Mcp/Tools/McpToolHelpers.cs @@ -0,0 +1,52 @@ +// 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.Backchannel; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; + +namespace Aspire.Cli.Mcp.Tools; + +internal static class McpToolHelpers +{ + public static async Task<(string apiToken, string apiBaseUrl, string? dashboardBaseUrl)> GetDashboardInfoAsync(IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, ILogger logger, CancellationToken cancellationToken) + { + var connection = await AppHostConnectionHelper.GetSelectedConnectionAsync(auxiliaryBackchannelMonitor, logger, cancellationToken).ConfigureAwait(false); + if (connection is null) + { + logger.LogWarning("No Aspire AppHost is currently running"); + throw new McpProtocolException(McpErrorMessages.NoAppHostRunning, McpErrorCode.InternalError); + } + + var dashboardInfo = await connection.GetDashboardInfoV2Async(cancellationToken).ConfigureAwait(false); + if (dashboardInfo?.ApiBaseUrl is null || dashboardInfo.ApiToken is null) + { + logger.LogWarning("Dashboard API is not available"); + throw new McpProtocolException(McpErrorMessages.DashboardNotAvailable, McpErrorCode.InternalError); + } + + var dashboardBaseUrl = GetBaseUrl(dashboardInfo.DashboardUrls.FirstOrDefault()); + + return (dashboardInfo.ApiToken, dashboardInfo.ApiBaseUrl, dashboardBaseUrl); + } + + /// + /// Extracts the base URL (scheme, host, and port) from a URL, removing any path and query string. + /// + /// The full URL that may contain path and query string. + /// The base URL with only scheme, host, and port, or null if the input is null or invalid. + internal static string? GetBaseUrl(string? url) + { + if (url is null) + { + return null; + } + + if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + return $"{uri.Scheme}://{uri.Authority}"; + } + + return url; + } +} diff --git a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs index 502f00251a3..c8d3d5c4fef 100644 --- a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs @@ -19,8 +19,6 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - _ = context; - var resourceToolMap = await refreshService.RefreshResourceToolMapAsync(cancellationToken).ConfigureAwait(false); await refreshService.SendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs b/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs index 8b1524bf9c7..0dd23d75ccf 100644 --- a/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/SearchDocsTool.cs @@ -48,12 +48,9 @@ public override JsonElement GetInputSchema() """).RootElement; } - public override async ValueTask CallToolAsync( - CallToolContext context, - CancellationToken cancellationToken) + public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { var arguments = context.Arguments; - if (arguments is null || !arguments.TryGetValue("query", out var queryElement)) { return new CallToolResult diff --git a/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs b/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs index 1024bcc8331..a151b4b8cf7 100644 --- a/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/SelectAppHostTool.cs @@ -34,9 +34,6 @@ public override JsonElement GetInputSchema() public override ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - // This tool does not use the MCP client as it operates locally - _ = cancellationToken; - var arguments = context.Arguments; if (arguments == null || !arguments.TryGetValue("appHostPath", out var appHostPathElement)) diff --git a/src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs b/src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs index 4f5a76f7bbd..ed8cfc4e697 100644 --- a/src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs +++ b/src/Aspire.Cli/Otlp/OtlpCliJsonSerializerContext.cs @@ -47,18 +47,11 @@ internal sealed class ResourceInfoJson public string Name { get; set; } = ""; /// - /// The instance ID if this is a replica (e.g., "abc123"), or null if single instance. + /// The instance ID (e.g., "abc123"). /// [JsonPropertyName("instanceId")] public string? InstanceId { get; set; } - /// - /// The full display name including instance ID (e.g., "catalogservice-abc123" or "catalogservice"). - /// Use this when querying the telemetry API. - /// - [JsonPropertyName("displayName")] - public string DisplayName { get; set; } = ""; - /// /// Whether this resource has structured logs. /// @@ -76,6 +69,20 @@ internal sealed class ResourceInfoJson /// [JsonPropertyName("hasMetrics")] public bool HasMetrics { get; set; } + + /// + /// Gets the full display name by combining Name and InstanceId. + /// + /// The full display name (e.g., "catalogservice-abc123" or "catalogservice"). + public string GetCompositeName() + { + if (InstanceId is null) + { + return Name; + } + + return $"{Name}-{InstanceId}"; + } } /// diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs index 38fd9574f01..4f636ff25ef 100644 --- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs +++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs @@ -16,12 +16,15 @@ namespace Aspire.Dashboard.Api; /// Handles telemetry API requests, returning data in OTLP JSON format. /// internal sealed class TelemetryApiService( - TelemetryRepository telemetryRepository) + TelemetryRepository telemetryRepository, + IEnumerable outgoingPeerResolvers) { private const int DefaultLimit = 200; private const int DefaultTraceLimit = 100; private const int MaxQueryCount = 10000; + private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); + /// /// Gets spans in OTLP JSON format. /// Returns null if resource filter is specified but not found. @@ -83,7 +86,7 @@ internal sealed class TelemetryApiService( spans = spans.Skip(spans.Count - effectiveLimit).ToList(); } - var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans); + var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers); return new TelemetryApiResponse { @@ -148,7 +151,7 @@ internal sealed class TelemetryApiService( // Get all spans from filtered traces var spans = traces.SelectMany(t => t.Spans).ToList(); - var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans); + var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers); return new TelemetryApiResponse { @@ -181,7 +184,7 @@ internal sealed class TelemetryApiService( var spans = trace.Spans.ToList(); - var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans); + var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers); return new TelemetryApiResponse { @@ -315,7 +318,7 @@ public async IAsyncEnumerable FollowSpansAsync( } // Use compact JSON for NDJSON streaming (no indentation) - yield return TelemetryExportService.ConvertSpanToJson(span, logs: null, indent: false); + yield return TelemetryExportService.ConvertSpanToJson(span, _outgoingPeerResolvers, logs: null, indent: false); } } diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 79511ee4e99..fa1447d8f10 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -300,6 +300,7 @@ + @@ -309,6 +310,7 @@ + diff --git a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs index fe541a121bb..96a92c20eec 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs @@ -341,7 +341,7 @@ private void OnToggleExpand(ResourceDataRow resourceRow) private string GetResourceName(ResourceViewModel resource) => ResourceViewModel.GetResourceName(resource, _resourceByName); - private string GetOtlpResourceName(OtlpResource resource) => OtlpResource.GetResourceName(resource, TelemetryRepository.GetResources()); + private string GetOtlpResourceName(OtlpResource resource) => OtlpHelpers.GetResourceName(resource, TelemetryRepository.GetResources()); private string GetDataTypeDisplayName(AspireDataType dataType) => dataType switch { diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs index 151b4ac1d62..d287a8f7c92 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs @@ -414,7 +414,7 @@ private async Task HandleAfterFilterBindAsync() await ClearSelectedLogEntryAsync(); } - private string GetResourceName(OtlpResourceView app) => OtlpResource.GetResourceName(app.Resource, _resources); + private string GetResourceName(OtlpResourceView app) => OtlpHelpers.GetResourceName(app.Resource, _resources); private string GetRowClass(OtlpLogEntry entry) { diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs index c9dafd543c6..6e48f33524e 100644 --- a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs @@ -248,8 +248,8 @@ private async Task HandleAfterFilterBindAsync() await InvokeAsync(_dataGrid.SafeRefreshDataAsync); } - private string GetResourceName(OtlpResource app) => OtlpResource.GetResourceName(app, _resources); - private string GetResourceName(OtlpResourceView app) => OtlpResource.GetResourceName(app, _resources); + private string GetResourceName(OtlpResource app) => OtlpHelpers.GetResourceName(app, _resources); + private string GetResourceName(OtlpResourceView app) => OtlpHelpers.GetResourceName(app.Resource, _resources); private static string GetRowClass(OtlpTrace entry) { diff --git a/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs b/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs index 5e81a195940..b38155710be 100644 --- a/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs +++ b/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs @@ -115,15 +115,15 @@ public async Task ListConsoleLogsAsync( var entries = logEntries.GetEntries().ToList(); var totalLogsCount = entries.Count == 0 ? 0 : entries.Last().LineNumber; - var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( + var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( entries, totalLogsCount, AIHelpers.ConsoleLogsLimit, "console log", "console logs", SharedAIHelpers.SerializeLogEntry, - logEntry => SharedAIHelpers.EstimateTokenCount((string)logEntry)); - var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems.Cast().ToList()); + SharedAIHelpers.EstimateTokenCount); + var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems); var consoleLogsData = $""" {limitMessage} diff --git a/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs b/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs index f3cef5d4b83..b94a37944c4 100644 --- a/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs +++ b/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs @@ -9,6 +9,7 @@ using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; +using Aspire.Shared.ConsoleLogs; using Microsoft.Extensions.Options; using ModelContextProtocol.Server; @@ -20,7 +21,7 @@ namespace Aspire.Dashboard.Mcp; internal sealed class AspireTelemetryMcpTools { private readonly TelemetryRepository _telemetryRepository; - private readonly IEnumerable _outgoingPeerResolvers; + private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; private readonly IOptionsMonitor _dashboardOptions; private readonly IDashboardClient _dashboardClient; private readonly ILogger _logger; @@ -32,7 +33,7 @@ public AspireTelemetryMcpTools(TelemetryRepository telemetryRepository, ILogger logger) { _telemetryRepository = telemetryRepository; - _outgoingPeerResolvers = outgoingPeerResolvers; + _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); _dashboardOptions = dashboardOptions; _dashboardClient = dashboardClient; _logger = logger; @@ -70,13 +71,13 @@ public string ListStructuredLogs( } } + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(logs); var resources = _telemetryRepository.GetResources(); - var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson( - logs, + otlpData, _dashboardOptions.CurrentValue, includeDashboardUrl: true, - getResourceName: r => OtlpResource.GetResourceName(r, resources)); + getResourceName: r => OtlpHelpers.GetResourceName(r, resources)); var response = $""" Always format log_id in the response as code like this: `log_id: 123`. @@ -123,12 +124,10 @@ public string ListTraces( var resources = _telemetryRepository.GetResources(); - var (tracesData, limitMessage) = AIHelpers.GetTracesJson( - traces, - _outgoingPeerResolvers, - _dashboardOptions.CurrentValue, - includeDashboardUrl: true, - getResourceName: r => OtlpResource.GetResourceName(r, resources)); + var (tracesData, limitMessage) = SharedAIHelpers.GetTracesJson( + TelemetryExportService.ConvertTracesToOtlpJson(traces, _outgoingPeerResolvers).ResourceSpans, + getResourceName: r => OtlpHelpers.GetResourceName(r, resources), + AIHelpers.GetDashboardUrl(_dashboardOptions.CurrentValue)); var response = $""" {limitMessage} @@ -165,13 +164,13 @@ public string ListTraceStructuredLogs( Filters = [traceIdFilter] }); + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(logs.Items); var resources = _telemetryRepository.GetResources(); - var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson( - logs.Items, + otlpData, _dashboardOptions.CurrentValue, includeDashboardUrl: true, - getResourceName: r => OtlpResource.GetResourceName(r, resources)); + getResourceName: r => OtlpHelpers.GetResourceName(r, resources)); var response = $""" {limitMessage} diff --git a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs index 47cc68b30a8..434b10965ba 100644 --- a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs +++ b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs @@ -3,12 +3,13 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Nodes; using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Extensions; using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Model.Serialization; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Resources; using Aspire.Dashboard.Utils; @@ -22,16 +23,16 @@ internal static class AIHelpers { public const int TracesLimit = 200; public const int StructuredLogsLimit = 200; - public const int ConsoleLogsLimit = SharedAIHelpers.ConsoleLogsLimit; + public const int ConsoleLogsLimit = 500; // There is currently a 64K token limit in VS. // Limit the result from individual token calls to a smaller number so multiple results can live inside the context. - public const int MaximumListTokenLength = SharedAIHelpers.MaximumListTokenLength; + public const int MaximumListTokenLength = 8192; // This value is chosen to balance: // - Providing enough data to the model for it to provide accurate answers. // - Providing too much data and exceeding length limits. - public const int MaximumStringLength = SharedAIHelpers.MaximumStringLength; + public const int MaximumStringLength = 2048; // Always pass English translations to AI private static readonly IStringLocalizer s_columnsLoc = new InvariantStringLocalizer(); @@ -41,177 +42,101 @@ internal static class AIHelpers private static readonly JsonSerializerOptions s_jsonSerializerOptions = new JsonSerializerOptions { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; - internal static object GetTraceDto(OtlpTrace trace, IEnumerable outgoingPeerResolvers, PromptContext context, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null) + internal static string GetResponseGraphJson(List resources, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null, bool includeEnvironmentVariables = false) { - var spanData = trace.Spans.Select(s => new - { - span_id = OtlpHelpers.ToShortenedId(s.SpanId), - parent_span_id = s.ParentSpanId is { } id ? OtlpHelpers.ToShortenedId(id) : null, - kind = s.Kind.ToString(), - name = context.AddValue(s.Name, id => $@"Duplicate of ""name"" for span {OtlpHelpers.ToShortenedId(id)}", s.SpanId), - status = s.Status != OtlpSpanStatusCode.Unset ? s.Status.ToString() : null, - status_message = context.AddValue(s.StatusMessage, id => $@"Duplicate of ""status_message"" for span {OtlpHelpers.ToShortenedId(id)}", s.SpanId), - source = getResourceName?.Invoke(s.Source.Resource) ?? s.Source.ResourceKey.GetCompositeName(), - destination = GetDestination(s, outgoingPeerResolvers), - duration_ms = ConvertToMilliseconds(s.Duration), - attributes = s.Attributes - .ToDictionary(a => a.Key, a => context.AddValue(MapOtelAttributeValue(a), id => $@"Duplicate of attribute ""{id.Key}"" for span {OtlpHelpers.ToShortenedId(id.SpanId)}", (s.SpanId, a.Key))), - links = s.Links.Select(l => new { trace_id = OtlpHelpers.ToShortenedId(l.TraceId), span_id = OtlpHelpers.ToShortenedId(l.SpanId) }).ToList(), - back_links = s.BackLinks.Select(l => new { source_trace_id = OtlpHelpers.ToShortenedId(l.SourceTraceId), source_span_id = OtlpHelpers.ToShortenedId(l.SourceSpanId) }).ToList() - }).ToList(); - - var traceId = OtlpHelpers.ToShortenedId(trace.TraceId); - var traceData = new Dictionary - { - ["trace_id"] = traceId, - ["duration_ms"] = ConvertToMilliseconds(trace.Duration), - ["title"] = trace.RootOrFirstSpan.Name, - ["spans"] = spanData, - ["has_error"] = trace.Spans.Any(s => s.Status == OtlpSpanStatusCode.Error), - ["timestamp"] = trace.TimeStamp, - }; - - if (includeDashboardUrl) - { - traceData["dashboard_link"] = GetDashboardLink(options, DashboardUrls.TraceDetailUrl(traceId), traceId); - } - - return traceData; + var dashboardBaseUrl = includeDashboardUrl ? GetDashboardUrl(options) : null; + return GetResponseGraphJson(resources, dashboardBaseUrl, includeDashboardUrl, getResourceName, includeEnvironmentVariables); } - private static string MapOtelAttributeValue(KeyValuePair attribute) + internal static string GetResponseGraphJson(List resources, string? dashboardBaseUrl, bool includeDashboardUrl = false, Func? getResourceName = null, bool includeEnvironmentVariables = false) { - switch (attribute.Key) + var dataArray = new JsonArray(); + + foreach (var resource in resources.Where(resource => !resource.IsResourceHidden(false))) { - case "http.response.status_code": + var resourceName = getResourceName?.Invoke(resource) ?? resource.Name; + + var endpointUrlsArray = new JsonArray(); + foreach (var u in resource.Urls.Where(u => !u.IsInternal)) + { + var urlObj = new JsonObject { - if (int.TryParse(attribute.Value, CultureInfo.InvariantCulture, out var value)) - { - return OtelAttributeHelpers.GetHttpStatusName(value); - } - goto default; - } - case "rpc.grpc.status_code": + ["name"] = u.EndpointName, + ["url"] = u.Url.ToString() + }; + if (!string.IsNullOrEmpty(u.DisplayProperties.DisplayName)) { - if (int.TryParse(attribute.Value, CultureInfo.InvariantCulture, out var value)) - { - return OtelAttributeHelpers.GetGrpcStatusName(value); - } - goto default; + urlObj["display_name"] = u.DisplayProperties.DisplayName; } - default: - return attribute.Value; - } - } - - private static int ConvertToMilliseconds(TimeSpan duration) - { - return (int)Math.Round(duration.TotalMilliseconds, 0, MidpointRounding.AwayFromZero); - } - - public static (string json, string limitMessage) GetTracesJson(List traces, IEnumerable outgoingPeerResolvers, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null) - { - var promptContext = new PromptContext(); - var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( - traces, - TracesLimit, - "trace", - "traces", - trace => GetTraceDto(trace, outgoingPeerResolvers, promptContext, options, includeDashboardUrl, getResourceName), - EstimateSerializedJsonTokenSize); - var tracesData = SerializeJson(trimmedItems); - - return (tracesData, limitMessage); - } - - internal static string GetTraceJson(OtlpTrace trace, IEnumerable outgoingPeerResolvers, PromptContext context, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null) - { - var dto = GetTraceDto(trace, outgoingPeerResolvers, context, options, includeDashboardUrl, getResourceName); - - var json = SerializeJson(dto); - return json; - } - - private static string? GetDestination(OtlpSpan s, IEnumerable outgoingPeerResolvers) - { - return ResolveUninstrumentedPeerName(s, outgoingPeerResolvers); - } + endpointUrlsArray.Add(urlObj); + } - private static string? ResolveUninstrumentedPeerName(OtlpSpan span, IEnumerable outgoingPeerResolvers) - { - // Attempt to resolve uninstrumented peer to a friendly name from the span. - foreach (var resolver in outgoingPeerResolvers) - { - if (resolver.TryResolvePeer(span.Attributes, out var name, out _)) + var healthReportsArray = new JsonArray(); + foreach (var report in resource.HealthReports) { - return name; + healthReportsArray.Add(new JsonObject + { + ["name"] = report.Name, + ["health_status"] = GetReportHealthStatus(resource, report), + ["exception"] = report.ExceptionText + }); } - } - // Fallback to the peer address. - return span.Attributes.GetPeerAddress(); - } + var healthObj = new JsonObject + { + ["resource_health_status"] = GetResourceHealthStatus(resource), + ["health_reports"] = healthReportsArray + }; - internal static string GetResponseGraphJson(List resources, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null, bool includeEnvironmentVariables = false) - { - var data = resources.Where(resource => !resource.IsResourceHidden(false)).Select(resource => - { - var resourceName = getResourceName?.Invoke(resource) ?? resource.Name; + var commandsArray = new JsonArray(); + foreach (var cmd in resource.Commands.Where(cmd => cmd.State == CommandViewModelState.Enabled)) + { + commandsArray.Add(new JsonObject + { + ["name"] = cmd.Name, + ["description"] = cmd.GetDisplayDescription() + }); + } - var resourceObj = new Dictionary + var resourceObj = new JsonObject { ["resource_name"] = resourceName, ["type"] = resource.ResourceType, ["state"] = resource.State, ["state_description"] = ResourceStateViewModel.GetResourceStateTooltip(resource, s_columnsLoc), - ["relationships"] = GetResourceRelationships(resources, resource, getResourceName), - ["endpoint_urls"] = resource.Urls.Where(u => !u.IsInternal).Select(u => new - { - name = u.EndpointName, - url = u.Url, - display_name = !string.IsNullOrEmpty(u.DisplayProperties.DisplayName) ? u.DisplayProperties.DisplayName : null, - }).ToList(), - ["health"] = new - { - resource_health_status = GetResourceHealthStatus(resource), - health_reports = resource.HealthReports.Select(report => new - { - name = report.Name, - health_status = GetReportHealthStatus(resource, report), - exception = report.ExceptionText - }).ToList() - }, + ["relationships"] = GetResourceRelationshipsJson(resources, resource, getResourceName), + ["endpoint_urls"] = endpointUrlsArray, + ["health"] = healthObj, ["source"] = ResourceSourceViewModel.GetSourceViewModel(resource)?.Value, - ["commands"] = resource.Commands.Where(cmd => cmd.State == CommandViewModelState.Enabled).Select(cmd => new - { - name = cmd.Name, - description = cmd.GetDisplayDescription() - }).ToList() + ["commands"] = commandsArray }; - if (includeDashboardUrl) + if (includeDashboardUrl && dashboardBaseUrl != null) { - resourceObj["dashboard_link"] = GetDashboardLink(options, DashboardUrls.ResourcesUrl(resource: resource.Name), resourceName); + resourceObj["dashboard_link"] = SharedAIHelpers.GetDashboardLinkObject(dashboardBaseUrl, DashboardUrls.ResourcesUrl(resource: resource.Name), resourceName); } if (includeEnvironmentVariables) { - resourceObj["environment_variables"] = resource.Environment.Where(e => e.FromSpec).Select(e => e.Name).ToList(); + var envVarsArray = new JsonArray(); + foreach (var e in resource.Environment.Where(e => e.FromSpec)) + { + envVarsArray.Add(JsonValue.Create(e.Name)); + } + resourceObj["environment_variables"] = envVarsArray; } - return resourceObj; - }).ToList(); + dataArray.Add(resourceObj); + } - var resourceGraphData = SerializeJson(data); - return resourceGraphData; + return dataArray.ToJsonString(s_jsonSerializerOptions); - static List GetResourceRelationships(List allResources, ResourceViewModel resourceViewModel, Func? getResourceName) + static JsonArray GetResourceRelationshipsJson(List allResources, ResourceViewModel resourceViewModel, Func? getResourceName) { - var relationships = new List(); + var relationships = new JsonArray(); foreach (var relationship in resourceViewModel.Relationships) { @@ -222,10 +147,10 @@ static List GetResourceRelationships(List allResource foreach (var match in matches) { - relationships.Add(new + relationships.Add(new JsonObject { - resource_name = getResourceName?.Invoke(match) ?? match.Name, - Types = relationship.Type + ["resource_name"] = getResourceName?.Invoke(match) ?? match.Name, + ["Types"] = relationship.Type }); } } @@ -259,22 +184,7 @@ static List GetResourceRelationships(List allResource } } - public static object? GetDashboardLink(DashboardOptions options, string path, string text) - { - var url = GetDashboardUrl(options, path); - if (string.IsNullOrEmpty(url)) - { - return null; - } - - return new - { - url = url, - text = text - }; - } - - public static string? GetDashboardUrl(DashboardOptions options, string path) + public static string? GetDashboardUrl(DashboardOptions options) { var frontendEndpoints = options.Frontend.GetEndpointAddresses(); @@ -282,73 +192,17 @@ static List GetResourceRelationships(List allResource ?? frontendEndpoints.FirstOrDefault(e => string.Equals(e.Scheme, "https", StringComparison.Ordinal))?.ToString() ?? frontendEndpoints.FirstOrDefault(e => string.Equals(e.Scheme, "http", StringComparison.Ordinal))?.ToString(); - if (frontendUrl == null) - { - return null; - } - - return new Uri(new Uri(frontendUrl), path).ToString(); - } - - public static int EstimateSerializedJsonTokenSize(T value) - { - var json = SerializeJson(value); - return SharedAIHelpers.EstimateTokenCount(json); - } - - private static string SerializeJson(T value) - { - return JsonSerializer.Serialize(value, s_jsonSerializerOptions); - } - - public static (string json, string limitMessage) GetStructuredLogsJson(List errorLogs, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null) - { - var promptContext = new PromptContext(); - var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( - errorLogs, - StructuredLogsLimit, - "log entry", - "log entries", - i => GetLogEntryDto(i, promptContext, options, includeDashboardUrl, getResourceName), - EstimateSerializedJsonTokenSize); - var logsData = SerializeJson(trimmedItems); - - return (logsData, limitMessage); + return frontendUrl; } - internal static string GetStructuredLogJson(OtlpLogEntry l, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null) + public static (string json, string limitMessage) GetStructuredLogsJson(OtlpTelemetryDataJson otlpData, DashboardOptions options, Func getResourceName, bool includeDashboardUrl = false) { - var dto = GetLogEntryDto(l, new PromptContext(), options, includeDashboardUrl, getResourceName); - - var json = SerializeJson(dto); - return json; + return SharedAIHelpers.GetStructuredLogsJson(otlpData.ResourceLogs, getResourceName, includeDashboardUrl ? GetDashboardUrl(options) : null); } - public static object GetLogEntryDto(OtlpLogEntry l, PromptContext context, DashboardOptions options, bool includeDashboardUrl = false, Func? getResourceName = null) + internal static string GetStructuredLogJson(OtlpTelemetryDataJson otlpData, DashboardOptions options, Func getResourceName, bool includeDashboardUrl = false) { - var exceptionText = OtlpLogEntry.GetExceptionText(l); - - var log = new Dictionary - { - ["log_id"] = l.InternalId, - ["span_id"] = OtlpHelpers.ToShortenedId(l.SpanId), - ["trace_id"] = OtlpHelpers.ToShortenedId(l.TraceId), - ["message"] = context.AddValue(l.Message, id => $@"Duplicate of ""message"" for log entry {id.InternalId}", l), - ["severity"] = l.Severity.ToString(), - ["resource_name"] = getResourceName?.Invoke(l.ResourceView.Resource) ?? l.ResourceView.Resource.ResourceKey.GetCompositeName(), - ["attributes"] = l.Attributes - .Where(l => l.Key is not (OtlpLogEntry.ExceptionStackTraceField or OtlpLogEntry.ExceptionMessageField or OtlpLogEntry.ExceptionTypeField)) - .ToDictionary(a => a.Key, a => context.AddValue(MapOtelAttributeValue(a), id => $@"Duplicate of attribute ""{id.Key}"" for log entry {id.InternalId}", (l.InternalId, a.Key))), - ["exception"] = context.AddValue(exceptionText, id => $@"Duplicate of ""exception"" for log entry {id.InternalId}", l), - ["source"] = l.Scope.Name - }; - - if (includeDashboardUrl) - { - log["dashboard_link"] = GetDashboardLink(options, DashboardUrls.StructuredLogsUrl(logEntryId: l.InternalId), $"log_id: {l.InternalId}"); - } - - return log; + return SharedAIHelpers.GetStructuredLogJson(otlpData.ResourceLogs, getResourceName, includeDashboardUrl ? GetDashboardUrl(options) : null); } public static bool TryGetSingleResult(IEnumerable source, Func predicate, [NotNullWhen(true)] out T? result) diff --git a/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs b/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs index 012ddbe4fed..a12198b516e 100644 --- a/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs +++ b/src/Aspire.Dashboard/Model/Assistant/AssistantChatDataContext.cs @@ -96,7 +96,10 @@ public async Task GetTraceAsync( _referencedTraces.TryAdd(trace.TraceId, trace); - return AIHelpers.GetTraceJson(trace, _outgoingPeerResolvers, new PromptContext(), _dashboardOptions.CurrentValue); + var spans = TelemetryExportService.ConvertTracesToOtlpJson([trace], _outgoingPeerResolvers.ToArray()).ResourceSpans; + var resources = TelemetryRepository.GetResources(); + + return SharedAIHelpers.GetTraceJson(spans, r => OtlpHelpers.GetResourceName(r, resources), AIHelpers.GetDashboardUrl(_dashboardOptions.CurrentValue)); } [Description("Get structured logs for resources.")] @@ -128,7 +131,9 @@ public async Task GetStructuredLogsAsync( Filters = [] }); - var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(logs.Items, _dashboardOptions.CurrentValue); + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(logs.Items); + var resources = TelemetryRepository.GetResources(); + var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(otlpData, _dashboardOptions.CurrentValue, r => OtlpHelpers.GetResourceName(r, resources)); var response = $""" Always format log_id in the response as code like this: `log_id: 123`. @@ -170,7 +175,9 @@ public async Task GetTracesAsync( FilterText = string.Empty }); - var (tracesData, limitMessage) = AIHelpers.GetTracesJson(traces.PagedResult.Items, _outgoingPeerResolvers, _dashboardOptions.CurrentValue); + var spans = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, _outgoingPeerResolvers.ToArray()).ResourceSpans; + var resources = TelemetryRepository.GetResources(); + var (tracesData, limitMessage) = SharedAIHelpers.GetTracesJson(spans, r => OtlpHelpers.GetResourceName(r, resources), AIHelpers.GetDashboardUrl(_dashboardOptions.CurrentValue)); var response = $""" {limitMessage} @@ -207,7 +214,9 @@ public async Task GetTraceStructuredLogsAsync( await InvokeToolCallbackAsync(nameof(GetTraceStructuredLogsAsync), _loc.GetString(nameof(AIAssistant.ToolNotificationTraceStructuredLogs), OtlpHelpers.ToShortenedId(traceId)), cancellationToken).ConfigureAwait(false); - var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(logs.Items, _dashboardOptions.CurrentValue); + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(logs.Items); + var resources = TelemetryRepository.GetResources(); + var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(otlpData, _dashboardOptions.CurrentValue, r => OtlpHelpers.GetResourceName(r, resources)); var response = $""" {limitMessage} @@ -264,15 +273,15 @@ public async Task GetConsoleLogsAsync( var entries = logEntries.GetEntries().ToList(); var totalLogsCount = entries.Count == 0 ? 0 : entries.Last().LineNumber; - var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( + var (trimmedItems, limitMessage) = SharedAIHelpers.GetLimitFromEndWithSummary( entries, totalLogsCount, AIHelpers.ConsoleLogsLimit, "console log", "console logs", SharedAIHelpers.SerializeLogEntry, - logEntry => SharedAIHelpers.EstimateTokenCount((string) logEntry)); - var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems.Cast().ToList()); + SharedAIHelpers.EstimateTokenCount); + var consoleLogsText = SharedAIHelpers.SerializeConsoleLogs(trimmedItems); var consoleLogsData = $""" {limitMessage} diff --git a/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs b/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs index 5b14dc3aa5d..b5aef2f6af2 100644 --- a/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs +++ b/src/Aspire.Dashboard/Model/Assistant/Prompts/KnownChatMessages.cs @@ -5,6 +5,7 @@ using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Utils; +using Aspire.Shared.ConsoleLogs; using Microsoft.Extensions.AI; namespace Aspire.Dashboard.Model.Assistant.Prompts; @@ -176,9 +177,10 @@ public static ChatMessage CreateHelpMessage() public static class StructuredLogs { - public static ChatMessage CreateErrorStructuredLogsMessage(List errorLogs, DashboardOptions options) + public static ChatMessage CreateErrorStructuredLogsMessage(List errorLogs, DashboardOptions options, Func getResourceName) { - var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(errorLogs, options); + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(errorLogs); + var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(otlpData, options, getResourceName); var prompt = $""" @@ -194,8 +196,10 @@ Explain the errors in the following log entries. Provide a summary of the errors return new(ChatRole.User, prompt); } - public static ChatMessage CreateAnalyzeLogEntryMessage(OtlpLogEntry logEntry, DashboardOptions options) + public static ChatMessage CreateAnalyzeLogEntryMessage(OtlpLogEntry logEntry, DashboardOptions options, Func getResourceName) { + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson([logEntry]); + var prompt = $""" My application has written a log entry. Provide context about the state of the app when the log entry was written and why. @@ -204,7 +208,7 @@ Investigate the root cause of any errors in the log entry. # LOG ENTRY DATA - {AIHelpers.GetStructuredLogJson(logEntry, options)} + {AIHelpers.GetStructuredLogJson(otlpData, options, getResourceName)} """; return new(ChatRole.User, prompt); @@ -280,9 +284,13 @@ public static ChatMessage CreateResourceTracesMessage(OtlpResource resource) return new ChatMessage(ChatRole.User, message); } - public static ChatMessage CreateAnalyzeTraceMessage(OtlpTrace trace, List traceLogEntries, IEnumerable outgoingPeerResolvers, DashboardOptions options) + public static ChatMessage CreateAnalyzeTraceMessage(OtlpTrace trace, List traceLogEntries, IEnumerable outgoingPeerResolvers, DashboardOptions options, Func getResourceName) { - var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(traceLogEntries, options); + var spans = TelemetryExportService.ConvertTracesToOtlpJson([trace], outgoingPeerResolvers.ToArray()).ResourceSpans; + var (tracesData, _) = SharedAIHelpers.GetTracesJson(spans, getResourceName, AIHelpers.GetDashboardUrl(options)); + + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(traceLogEntries); + var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(otlpData, options, getResourceName); var prompt = $""" @@ -291,7 +299,7 @@ Summarize the distributed trace. Focus on errors. # DISTRIBUTED TRACE DATA - {AIHelpers.GetTraceJson(trace, outgoingPeerResolvers, new PromptContext(), options)} + {tracesData} # STRUCTURED LOGS DATA @@ -303,9 +311,13 @@ Summarize the distributed trace. Focus on errors. return new(ChatRole.User, prompt); } - public static ChatMessage CreateAnalyzeSpanMessage(OtlpSpan span, List traceLogEntries, IEnumerable outgoingPeerResolvers, DashboardOptions options) + public static ChatMessage CreateAnalyzeSpanMessage(OtlpSpan span, List traceLogEntries, IEnumerable outgoingPeerResolvers, DashboardOptions options, Func getResourceName) { - var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(traceLogEntries, options); + var spans = TelemetryExportService.ConvertTracesToOtlpJson([span.Trace], outgoingPeerResolvers.ToArray()).ResourceSpans; + var (tracesData, _) = SharedAIHelpers.GetTracesJson(spans, getResourceName, AIHelpers.GetDashboardUrl(options)); + + var otlpData = TelemetryExportService.ConvertLogsToOtlpJson(traceLogEntries); + var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson(otlpData, options, getResourceName); var prompt = $""" @@ -314,7 +326,7 @@ public static ChatMessage CreateAnalyzeSpanMessage(OtlpSpan span, List errorTraces, IEnumerable outgoingPeerResolvers, DashboardOptions options) + public static ChatMessage CreateErrorTracesMessage(List errorTraces, IEnumerable outgoingPeerResolvers, DashboardOptions options, Func getResourceName) { - var (tracesData, limitMessage) = AIHelpers.GetTracesJson(errorTraces, outgoingPeerResolvers, options); + var spans = TelemetryExportService.ConvertTracesToOtlpJson(errorTraces, outgoingPeerResolvers.ToArray()).ResourceSpans; + var (tracesData, limitMessage) = SharedAIHelpers.GetTracesJson(spans, getResourceName, AIHelpers.GetDashboardUrl(options)); var prompt = $""" diff --git a/src/Aspire.Dashboard/Model/Assistant/Prompts/PromptContextsBuilder.cs b/src/Aspire.Dashboard/Model/Assistant/Prompts/PromptContextsBuilder.cs index 702bc2ed170..abfc836f865 100644 --- a/src/Aspire.Dashboard/Model/Assistant/Prompts/PromptContextsBuilder.cs +++ b/src/Aspire.Dashboard/Model/Assistant/Prompts/PromptContextsBuilder.cs @@ -11,6 +11,8 @@ internal static class PromptContextsBuilder public static Task ErrorTraces(InitializePromptContext promptContext, string displayText, Func> getErrorTraces) { var outgoingPeerResolvers = promptContext.ServiceProvider.GetRequiredService>(); + var repository = promptContext.ServiceProvider.GetRequiredService(); + var resources = repository.GetResources(); var errorTraces = getErrorTraces(); foreach (var trace in errorTraces.Items) { @@ -19,13 +21,15 @@ public static Task ErrorTraces(InitializePromptContext promptContext, string dis promptContext.ChatBuilder.AddUserMessage( displayText, - KnownChatMessages.Traces.CreateErrorTracesMessage(errorTraces.Items, outgoingPeerResolvers, promptContext.DashboardOptions).Text); + KnownChatMessages.Traces.CreateErrorTracesMessage(errorTraces.Items, outgoingPeerResolvers, promptContext.DashboardOptions, r => OtlpHelpers.GetResourceName(r, resources)).Text); return Task.CompletedTask; } public static Task ErrorStructuredLogs(InitializePromptContext promptContext, string displayText, Func> getErrorLogs) { + var repository = promptContext.ServiceProvider.GetRequiredService(); + var resources = repository.GetResources(); var errorLogs = getErrorLogs(); foreach (var log in errorLogs.Items) { @@ -34,7 +38,7 @@ public static Task ErrorStructuredLogs(InitializePromptContext promptContext, st promptContext.ChatBuilder.AddUserMessage( displayText, - KnownChatMessages.StructuredLogs.CreateErrorStructuredLogsMessage(errorLogs.Items, promptContext.DashboardOptions).Text); + KnownChatMessages.StructuredLogs.CreateErrorStructuredLogsMessage(errorLogs.Items, promptContext.DashboardOptions, r => OtlpHelpers.GetResourceName(r, resources)).Text); return Task.CompletedTask; } @@ -50,10 +54,12 @@ public static Task AnalyzeResource(InitializePromptContext promptContext, string public static Task AnalyzeLogEntry(InitializePromptContext promptContext, string displayText, OtlpLogEntry logEntry) { + var repository = promptContext.ServiceProvider.GetRequiredService(); + var resources = repository.GetResources(); promptContext.DataContext.AddReferencedLogEntry(logEntry); promptContext.ChatBuilder.AddUserMessage( displayText, - KnownChatMessages.StructuredLogs.CreateAnalyzeLogEntryMessage(logEntry, promptContext.DashboardOptions).Text); + KnownChatMessages.StructuredLogs.CreateAnalyzeLogEntryMessage(logEntry, promptContext.DashboardOptions, r => OtlpHelpers.GetResourceName(r, resources)).Text); return Task.CompletedTask; } @@ -64,6 +70,7 @@ public static Task AnalyzeTrace(InitializePromptContext context, string displayT var outgoingPeerResolvers = context.ServiceProvider.GetRequiredService>(); var repository = context.ServiceProvider.GetRequiredService(); + var resources = repository.GetResources(); var traceLogs = repository.GetLogsForTrace(trace.TraceId); foreach (var log in traceLogs) { @@ -72,7 +79,7 @@ public static Task AnalyzeTrace(InitializePromptContext context, string displayT context.ChatBuilder.AddUserMessage( displayText, - KnownChatMessages.Traces.CreateAnalyzeTraceMessage(trace, traceLogs, outgoingPeerResolvers, context.DashboardOptions).Text); + KnownChatMessages.Traces.CreateAnalyzeTraceMessage(trace, traceLogs, outgoingPeerResolvers, context.DashboardOptions, r => OtlpHelpers.GetResourceName(r, resources)).Text); return Task.CompletedTask; } @@ -83,6 +90,7 @@ public static Task AnalyzeSpan(InitializePromptContext context, string displayTe var outgoingPeerResolvers = context.ServiceProvider.GetRequiredService>(); var repository = context.ServiceProvider.GetRequiredService(); + var resources = repository.GetResources(); var traceLogs = repository.GetLogsForTrace(span.Trace.TraceId); foreach (var log in traceLogs) { @@ -91,7 +99,7 @@ public static Task AnalyzeSpan(InitializePromptContext context, string displayTe context.ChatBuilder.AddUserMessage( displayText, - KnownChatMessages.Traces.CreateAnalyzeSpanMessage(span, traceLogs, outgoingPeerResolvers, context.DashboardOptions).Text); + KnownChatMessages.Traces.CreateAnalyzeSpanMessage(span, traceLogs, outgoingPeerResolvers, context.DashboardOptions, r => OtlpHelpers.GetResourceName(r, resources)).Text); return Task.CompletedTask; } diff --git a/src/Aspire.Dashboard/Model/ExportHelpers.cs b/src/Aspire.Dashboard/Model/ExportHelpers.cs index 12d5e277cab..46f5475c68e 100644 --- a/src/Aspire.Dashboard/Model/ExportHelpers.cs +++ b/src/Aspire.Dashboard/Model/ExportHelpers.cs @@ -21,13 +21,10 @@ internal static class ExportHelpers /// /// Gets a span as a JSON export result, including associated log entries. /// - /// The span to convert. - /// The telemetry repository to fetch logs from. - /// A result containing the JSON representation and suggested file name. - public static ExportResult GetSpanAsJson(OtlpSpan span, TelemetryRepository telemetryRepository) + public static ExportResult GetSpanAsJson(OtlpSpan span, TelemetryRepository telemetryRepository, IOutgoingPeerResolver[] outgoingPeerResolvers) { var logs = telemetryRepository.GetLogsForSpan(span.TraceId, span.SpanId); - var json = TelemetryExportService.ConvertSpanToJson(span, logs); + var json = TelemetryExportService.ConvertSpanToJson(span, outgoingPeerResolvers, logs); var fileName = $"span-{OtlpHelpers.ToShortenedId(span.SpanId)}.json"; return new ExportResult(json, fileName); } @@ -47,13 +44,10 @@ public static ExportResult GetLogEntryAsJson(OtlpLogEntry logEntry) /// /// Gets all spans in a trace as a JSON export result, including associated log entries. /// - /// The trace to convert. - /// The telemetry repository to fetch logs from. - /// A result containing the JSON representation and suggested file name. - public static ExportResult GetTraceAsJson(OtlpTrace trace, TelemetryRepository telemetryRepository) + public static ExportResult GetTraceAsJson(OtlpTrace trace, TelemetryRepository telemetryRepository, IOutgoingPeerResolver[] outgoingPeerResolvers) { var logs = telemetryRepository.GetLogsForTrace(trace.TraceId); - var json = TelemetryExportService.ConvertTraceToJson(trace, logs); + var json = TelemetryExportService.ConvertTraceToJson(trace, outgoingPeerResolvers, logs); var fileName = $"trace-{OtlpHelpers.ToShortenedId(trace.TraceId)}.json"; return new ExportResult(json, fileName); } diff --git a/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs b/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs index ac701dd5f03..c3c47a25703 100644 --- a/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs +++ b/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs @@ -63,9 +63,9 @@ public static GenAIVisualizerDialogViewModel Create( SpanDetailsViewModel = spanDetailsViewModel, SelectedLogEntryId = selectedLogEntryId, GetContextGenAISpans = getContextGenAISpans, - SourceName = OtlpResource.GetResourceName(spanDetailsViewModel.Span.Source, resources), + SourceName = OtlpHelpers.GetResourceName(spanDetailsViewModel.Span.Source.Resource, resources), PeerName = telemetryRepository.GetPeerResource(spanDetailsViewModel.Span) is { } peerResource - ? OtlpResource.GetResourceName(peerResource, resources) + ? OtlpHelpers.GetResourceName(peerResource, resources) : OtlpHelpers.GetPeerAddress(spanDetailsViewModel.Span.Attributes) ?? UnknownPeerName }; diff --git a/src/Aspire.Dashboard/Model/Otlp/ResourcesSelectHelpers.cs b/src/Aspire.Dashboard/Model/Otlp/ResourcesSelectHelpers.cs index 680acafbccb..1df031b5f6b 100644 --- a/src/Aspire.Dashboard/Model/Otlp/ResourcesSelectHelpers.cs +++ b/src/Aspire.Dashboard/Model/Otlp/ResourcesSelectHelpers.cs @@ -111,7 +111,7 @@ public static List> CreateResources(List { Id = ResourceTypeDetails.CreateReplicaInstance(replica.ResourceKey.ToString(), resourceName), - Name = OtlpResource.GetResourceName(replica, resources) + Name = OtlpHelpers.GetResourceName(replica, resources) })); } diff --git a/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs index 4f967a96385..3a852359f3c 100644 --- a/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs +++ b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs @@ -220,7 +220,7 @@ static double CalculatePercent(double value, double total) // If the span has a peer name, use it. Note that when the peer is a resource with replicas, it's possible the uninstrumented peer name returned here isn't the real replica. // We are matching an address to replicas which share the same address. There isn't a way to know exactly which replica was called. The first replica instance will be chosen. // This shouldn't be a big issue because typically project replicas will have OTEL setup, and so a child span is recorded. - return OtlpResource.GetResourceName(span.UninstrumentedPeer, allResources); + return OtlpHelpers.GetResourceName(span.UninstrumentedPeer, allResources); } // Attempt to resolve uninstrumented peer to a friendly name from the span. diff --git a/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs b/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs index 5bb0cbafa96..faeac64969f 100644 --- a/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs +++ b/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs @@ -32,7 +32,7 @@ public static SpanDetailsViewModel Create(OtlpSpan span, TelemetryRepository tel { Name = "Destination", Key = KnownTraceFields.DestinationField, - Value = OtlpResource.GetResourceName(destination, resources) + Value = OtlpHelpers.GetResourceName(destination, resources) }); } entryProperties.AddRange(span.GetAttributeProperties().Select(CreateTelemetryProperty)); diff --git a/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs b/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs index fb721adc7df..4ad20a1189a 100644 --- a/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs @@ -35,6 +35,7 @@ public sealed class SpanMenuBuilder private readonly IAIContextProvider _aiContextProvider; private readonly DashboardDialogService _dialogService; private readonly TelemetryRepository _telemetryRepository; + private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; /// /// Initializes a new instance of the class. @@ -46,7 +47,8 @@ public SpanMenuBuilder( NavigationManager navigationManager, IAIContextProvider aiContextProvider, DashboardDialogService dialogService, - TelemetryRepository telemetryRepository) + TelemetryRepository telemetryRepository, + IEnumerable outgoingPeerResolvers) { _controlsLoc = controlsLoc; _aiAssistantLoc = aiAssistantLoc; @@ -55,6 +57,7 @@ public SpanMenuBuilder( _aiContextProvider = aiContextProvider; _dialogService = dialogService; _telemetryRepository = telemetryRepository; + _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); } /// @@ -109,7 +112,7 @@ public void AddMenuItems( Icon = s_bracesIcon, OnClick = async () => { - var result = ExportHelpers.GetSpanAsJson(span, _telemetryRepository); + var result = ExportHelpers.GetSpanAsJson(span, _telemetryRepository, _outgoingPeerResolvers); await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { DialogService = _dialogService, diff --git a/src/Aspire.Dashboard/Model/TelemetryExportService.cs b/src/Aspire.Dashboard/Model/TelemetryExportService.cs index a3598f6f103..20584c2d8b7 100644 --- a/src/Aspire.Dashboard/Model/TelemetryExportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryExportService.cs @@ -25,6 +25,7 @@ public sealed class TelemetryExportService private readonly TelemetryRepository _telemetryRepository; private readonly ConsoleLogsFetcher _consoleLogsFetcher; private readonly IDashboardClient _dashboardClient; + private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; /// /// Initializes a new instance of the class. @@ -32,11 +33,13 @@ public sealed class TelemetryExportService /// The telemetry repository. /// The console log fetcher. /// The dashboard client for fetching resources. - public TelemetryExportService(TelemetryRepository telemetryRepository, ConsoleLogsFetcher consoleLogsFetcher, IDashboardClient dashboardClient) + /// The outgoing peer resolvers for destination name resolution. + public TelemetryExportService(TelemetryRepository telemetryRepository, ConsoleLogsFetcher consoleLogsFetcher, IDashboardClient dashboardClient, IEnumerable outgoingPeerResolvers) { _telemetryRepository = telemetryRepository; _consoleLogsFetcher = consoleLogsFetcher; _dashboardClient = dashboardClient; + _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); } /// @@ -163,7 +166,7 @@ private void ExportStructuredLogs(ZipArchive archive, List resourc continue; } - var resourceName = OtlpResource.GetResourceName(resource, resources); + var resourceName = OtlpHelpers.GetResourceName(resource, resources); var logsJson = ConvertLogsToOtlpJson(logs.Items); WriteJsonToArchive(archive, $"structuredlogs/{SanitizeFileName(resourceName)}.json", logsJson); } @@ -180,8 +183,8 @@ private void ExportTraces(ZipArchive archive, List resources) continue; } - var resourceName = OtlpResource.GetResourceName(resource, resources); - var tracesJson = ConvertTracesToOtlpJson(tracesResponse.PagedResult.Items); + var resourceName = OtlpHelpers.GetResourceName(resource, resources); + var tracesJson = ConvertTracesToOtlpJson(tracesResponse.PagedResult.Items, _outgoingPeerResolvers); WriteJsonToArchive(archive, $"traces/{SanitizeFileName(resourceName)}.json", tracesJson); } } @@ -221,7 +224,7 @@ private void ExportMetrics(ZipArchive archive, List resources) continue; } - var resourceName = OtlpResource.GetResourceName(resource, resources); + var resourceName = OtlpHelpers.GetResourceName(resource, resources); var metricsJson = ConvertMetricsToOtlpJson(resource, instrumentsData); WriteJsonToArchive(archive, $"metrics/{SanitizeFileName(resourceName)}.json", metricsJson); } @@ -262,7 +265,10 @@ private static OtlpLogRecordJson ConvertLogEntry(OtlpLogEntry log) SeverityNumber = log.SeverityNumber, SeverityText = log.Severity.ToString(), Body = new OtlpAnyValueJson { StringValue = log.Message }, - Attributes = ConvertAttributes(log.Attributes), + Attributes = ConvertAttributes(log.Attributes, () => + [ + new KeyValuePair(OtlpHelpers.AspireLogIdAttribute, log.InternalId.ToString(CultureInfo.InvariantCulture)) + ]), TraceId = string.IsNullOrEmpty(log.TraceId) ? null : log.TraceId, SpanId = string.IsNullOrEmpty(log.SpanId) ? null : log.SpanId, Flags = log.Flags, @@ -270,7 +276,7 @@ private static OtlpLogRecordJson ConvertLogEntry(OtlpLogEntry log) }; } - internal static OtlpTelemetryDataJson ConvertSpansToOtlpJson(IReadOnlyList spans) + internal static OtlpTelemetryDataJson ConvertSpansToOtlpJson(IReadOnlyList spans, IOutgoingPeerResolver[] outgoingPeerResolvers) { // Group spans by resource and scope var resourceSpans = spans @@ -286,7 +292,7 @@ internal static OtlpTelemetryDataJson ConvertSpansToOtlpJson(IReadOnlyList new OtlpScopeSpansJson { Scope = ConvertScope(scopeGroup.Key), - Spans = scopeGroup.Select(ConvertSpan).ToArray() + Spans = scopeGroup.Select(s => ConvertSpan(s, outgoingPeerResolvers)).ToArray() }).ToArray() }; }).ToArray(); @@ -297,14 +303,14 @@ internal static OtlpTelemetryDataJson ConvertSpansToOtlpJson(IReadOnlyList traces) + internal static OtlpTelemetryDataJson ConvertTracesToOtlpJson(IReadOnlyList traces, IOutgoingPeerResolver[] outgoingPeerResolvers) { // Group spans by resource and scope var allSpans = traces.SelectMany(t => t.Spans).ToList(); - return ConvertSpansToOtlpJson(allSpans); + return ConvertSpansToOtlpJson(allSpans, outgoingPeerResolvers); } - internal static string ConvertSpanToJson(OtlpSpan span, List? logs = null, bool indent = true) + internal static string ConvertSpanToJson(OtlpSpan span, IOutgoingPeerResolver[] outgoingPeerResolvers, List? logs = null, bool indent = true) { var data = new OtlpTelemetryDataJson { @@ -318,7 +324,7 @@ internal static string ConvertSpanToJson(OtlpSpan span, List? logs new OtlpScopeSpansJson { Scope = ConvertScope(span.Scope), - Spans = [ConvertSpan(span)] + Spans = [ConvertSpan(span, outgoingPeerResolvers)] } ] } @@ -329,7 +335,7 @@ internal static string ConvertSpanToJson(OtlpSpan span, List? logs return JsonSerializer.Serialize(data, options); } - internal static string ConvertTraceToJson(OtlpTrace trace, List? logs = null) + internal static string ConvertTraceToJson(OtlpTrace trace, IOutgoingPeerResolver[] outgoingPeerResolvers, List? logs = null) { // Group spans by resource and scope var spansByResourceAndScope = trace.Spans @@ -345,7 +351,7 @@ internal static string ConvertTraceToJson(OtlpTrace trace, List? l .Select(scopeGroup => new OtlpScopeSpansJson { Scope = ConvertScope(scopeGroup.Key), - Spans = scopeGroup.Select(ConvertSpan).ToArray() + Spans = scopeGroup.Select(s => ConvertSpan(s, outgoingPeerResolvers)).ToArray() }).ToArray() }; }).ToArray(); @@ -381,8 +387,12 @@ internal static string ConvertLogEntryToJson(OtlpLogEntry logEntry) return JsonSerializer.Serialize(data, OtlpJsonSerializerContext.IndentedOptions); } - private static OtlpSpanJson ConvertSpan(OtlpSpan span) + private static OtlpSpanJson ConvertSpan(OtlpSpan span, IOutgoingPeerResolver[] outgoingPeerResolvers) { + var destinationName = outgoingPeerResolvers.Length > 0 + ? GetDestination(span, outgoingPeerResolvers) + : null; + return new OtlpSpanJson { TraceId = span.TraceId, @@ -392,7 +402,9 @@ private static OtlpSpanJson ConvertSpan(OtlpSpan span) Kind = (int)span.Kind, StartTimeUnixNano = OtlpHelpers.DateTimeToUnixNanoseconds(span.StartTime), EndTimeUnixNano = OtlpHelpers.DateTimeToUnixNanoseconds(span.EndTime), - Attributes = ConvertAttributes(span.Attributes), + Attributes = ConvertAttributes(span.Attributes, destinationName is not null + ? () => [new KeyValuePair(OtlpHelpers.AspireDestinationNameAttribute, destinationName)] + : null), Status = ConvertSpanStatus(span.Status, span.StatusMessage), Events = span.Events.Count > 0 ? span.Events.Select(ConvertSpanEvent).ToArray() : null, Links = span.Links.Count > 0 ? span.Links.Select(ConvertSpanLink).ToArray() : null, @@ -643,18 +655,40 @@ private static OtlpInstrumentationScopeJson ConvertScope(OtlpScope scope) }; } - private static OtlpKeyValueJson[]? ConvertAttributes(KeyValuePair[] attributes) + private static OtlpKeyValueJson[]? ConvertAttributes(KeyValuePair[] attributes, Func[]>? getAdditionalAttributes = null) { - if (attributes.Length == 0) + var additionalAttributes = getAdditionalAttributes?.Invoke(); + var additionalCount = additionalAttributes?.Length ?? 0; + + if (attributes.Length == 0 && additionalCount == 0) { return null; } - return attributes.Select(a => new OtlpKeyValueJson + var result = new OtlpKeyValueJson[attributes.Length + additionalCount]; + + for (var i = 0; i < attributes.Length; i++) { - Key = a.Key, - Value = new OtlpAnyValueJson { StringValue = a.Value } - }).ToArray(); + result[i] = new OtlpKeyValueJson + { + Key = attributes[i].Key, + Value = new OtlpAnyValueJson { StringValue = attributes[i].Value } + }; + } + + if (additionalAttributes is not null) + { + for (var i = 0; i < additionalAttributes.Length; i++) + { + result[attributes.Length + i] = new OtlpKeyValueJson + { + Key = additionalAttributes[i].Key, + Value = new OtlpAnyValueJson { StringValue = additionalAttributes[i].Value } + }; + } + } + + return result; } private static void WriteJsonToArchive(ZipArchive archive, string path, T data) @@ -769,4 +803,22 @@ internal static string ConvertResourceToJson(ResourceViewModel resource, IReadOn return JsonSerializer.Serialize(resourceJson, ResourceJsonSerializerContext.IndentedOptions); } + + /// + /// Gets the destination name for a span by resolving uninstrumented peer names. + /// + private static string? GetDestination(OtlpSpan span, IEnumerable outgoingPeerResolvers) + { + // Attempt to resolve uninstrumented peer to a friendly name from the span. + foreach (var resolver in outgoingPeerResolvers) + { + if (resolver.TryResolvePeer(span.Attributes, out var name, out _)) + { + return name; + } + } + + // Fallback to the peer address. + return span.Attributes.GetPeerAddress(); + } } diff --git a/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs b/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs index 141cfcea3f7..1596af60a24 100644 --- a/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs @@ -33,6 +33,7 @@ public sealed class TraceMenuBuilder private readonly IAIContextProvider _aiContextProvider; private readonly DashboardDialogService _dialogService; private readonly TelemetryRepository _telemetryRepository; + private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; /// /// Initializes a new instance of the class. @@ -44,7 +45,8 @@ public TraceMenuBuilder( NavigationManager navigationManager, IAIContextProvider aiContextProvider, DashboardDialogService dialogService, - TelemetryRepository telemetryRepository) + TelemetryRepository telemetryRepository, + IEnumerable outgoingPeerResolvers) { _controlsLoc = controlsLoc; _aiAssistantLoc = aiAssistantLoc; @@ -53,6 +55,7 @@ public TraceMenuBuilder( _aiContextProvider = aiContextProvider; _dialogService = dialogService; _telemetryRepository = telemetryRepository; + _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); } /// @@ -97,7 +100,7 @@ public void AddMenuItems( Icon = s_bracesIcon, OnClick = async () => { - var result = ExportHelpers.GetTraceAsJson(trace, _telemetryRepository); + var result = ExportHelpers.GetTraceAsJson(trace, _telemetryRepository, _outgoingPeerResolvers); await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { DialogService = _dialogService, diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs index d2ed293037f..80af7ef0f0f 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs @@ -50,6 +50,7 @@ public OtlpLogEntry(LogRecord record, OtlpResourceView resourceView, OtlpScope s return false; case "SpanId": case "TraceId": + case OtlpHelpers.AspireLogIdAttribute: // Explicitly ignore these return false; case "logrecord.event.name": diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs index b3f9dc4cda9..c2c058901e4 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs @@ -13,7 +13,7 @@ namespace Aspire.Dashboard.Otlp.Model; [DebuggerDisplay("ResourceName = {ResourceName}, InstanceId = {InstanceId}")] -public class OtlpResource +public class OtlpResource : IOtlpResource { public const string SERVICE_NAME = "service.name"; public const string SERVICE_INSTANCE_ID = "service.instance.id"; @@ -281,45 +281,8 @@ public static Dictionary> GetReplicasByResourceName(I .ToDictionary(grouping => grouping.Key, grouping => grouping.ToList()); } - public static string GetResourceName(OtlpResourceView resource, List allResources) => - GetResourceName(resource.Resource, allResources); - - public static string GetResourceName(OtlpResource resource, List allResources) - { - var count = 0; - foreach (var item in allResources) - { - if (string.Equals(item.ResourceName, resource.ResourceName, StringComparisons.ResourceName)) - { - count++; - if (count >= 2) - { - var instanceId = resource.InstanceId; - - // Convert long GUID into a shorter, more human friendly format. - // Before: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee - // After: aaaaaaaa - if (instanceId != null && Guid.TryParse(instanceId, out var guid)) - { - Span chars = stackalloc char[32]; - var result = guid.TryFormat(chars, charsWritten: out _, format: "N"); - Debug.Assert(result, "Guid.TryFormat not successful."); - - instanceId = chars.Slice(0, 8).ToString(); - } - - if (instanceId == null) - { - return item.ResourceName; - } - - return $"{item.ResourceName}-{instanceId}"; - } - } - } - - return resource.ResourceName; - } + public static string GetResourceName(OtlpResourceView resource, IReadOnlyList allResources) => + OtlpHelpers.GetResourceName(resource.Resource, allResources); internal List GetViews() => _resourceViews.Values.ToList(); diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs index b11b3985b9c..0d46579ccd3 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs @@ -1449,7 +1449,7 @@ private static OtlpSpan CreateSpan(OtlpResourceView resourceView, Span span, Otl EndTime = OtlpHelpers.UnixNanoSecondsToDateTime(span.EndTimeUnixNano), Status = ConvertStatus(span.Status), StatusMessage = span.Status?.Message, - Attributes = span.Attributes.ToKeyValuePairs(context), + Attributes = span.Attributes.ToKeyValuePairs(context, filter: attribute => attribute.Key != OtlpHelpers.AspireDestinationNameAttribute), State = !string.IsNullOrEmpty(span.TraceState) ? span.TraceState : null, Events = events, Links = links, diff --git a/src/Aspire.Dashboard/Model/Assistant/PromptContext.cs b/src/Shared/ConsoleLogs/PromptContext.cs similarity index 95% rename from src/Aspire.Dashboard/Model/Assistant/PromptContext.cs rename to src/Shared/ConsoleLogs/PromptContext.cs index 1840d140ea8..7d0858592e1 100644 --- a/src/Aspire.Dashboard/Model/Assistant/PromptContext.cs +++ b/src/Shared/ConsoleLogs/PromptContext.cs @@ -1,9 +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.Shared.ConsoleLogs; - -namespace Aspire.Dashboard.Model.Assistant; +namespace Aspire.Shared.ConsoleLogs; internal sealed class PromptContext { diff --git a/src/Shared/ConsoleLogs/SharedAIHelpers.cs b/src/Shared/ConsoleLogs/SharedAIHelpers.cs index 1716d428312..13d8be72688 100644 --- a/src/Shared/ConsoleLogs/SharedAIHelpers.cs +++ b/src/Shared/ConsoleLogs/SharedAIHelpers.cs @@ -4,6 +4,12 @@ using System.Diagnostics; using System.Globalization; using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; +using Aspire.Otlp.Serialization; namespace Aspire.Shared.ConsoleLogs; @@ -13,10 +19,17 @@ namespace Aspire.Shared.ConsoleLogs; /// internal static class SharedAIHelpers { + public const int TracesLimit = 200; + public const int StructuredLogsLimit = 200; public const int ConsoleLogsLimit = 500; public const int MaximumListTokenLength = 8192; public const int MaximumStringLength = 2048; + private static readonly JsonSerializerOptions s_jsonSerializerOptions = new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + /// /// Estimates the token count for a string. /// This is a rough estimate - use a library for exact calculation. @@ -26,6 +39,586 @@ public static int EstimateTokenCount(string text) return text.Length / 4; } + /// + /// Estimates the serialized JSON token size for a JsonNode. + /// + public static int EstimateSerializedJsonTokenSize(JsonNode node) + { + var json = node.ToJsonString(s_jsonSerializerOptions); + return EstimateTokenCount(json); + } + + /// + /// Converts OTLP resource logs to structured logs JSON for AI processing. + /// + /// The OTLP resource logs containing log records. + /// Optional function to resolve resource names. + /// Optional dashboard URL. + /// A tuple containing the JSON string and a limit message. + public static (string json, string limitMessage) GetStructuredLogsJson( + IList? resourceLogs, + Func getResourceName, + string? dashboardBaseUrl = null) + { + var logRecords = GetLogRecordsFromOtlpData(resourceLogs); + var promptContext = new PromptContext(); + + var (trimmedItems, limitMessage) = GetLimitFromEndWithSummary( + logRecords, + StructuredLogsLimit, + "log entry", + "log entries", + i => GetLogEntryDto(i, promptContext, getResourceName, dashboardBaseUrl), + EstimateSerializedJsonTokenSize); + + var jsonArray = new JsonArray(trimmedItems.ToArray()); + var logsData = jsonArray.ToJsonString(s_jsonSerializerOptions); + + return (logsData, limitMessage); + } + + /// + /// Converts OTLP resource logs to a single structured log JSON for AI processing. + /// + /// The OTLP resource logs containing log records. + /// Optional function to resolve resource names. + /// Optional dashboard URL. + /// The JSON string for the first log entry. + public static string GetStructuredLogJson( + IList? resourceLogs, + Func getResourceName, + string? dashboardBaseUrl = null) + { + var logRecords = GetLogRecordsFromOtlpData(resourceLogs); + var logEntry = logRecords.FirstOrDefault() ?? throw new InvalidOperationException("No log entry found in OTLP data."); + var promptContext = new PromptContext(); + var dto = GetLogEntryDto(logEntry, promptContext, getResourceName, dashboardBaseUrl); + + return dto.ToJsonString(s_jsonSerializerOptions); + } + + /// + /// Converts OTLP resource spans to traces JSON for AI processing. + /// + /// The OTLP resource spans containing trace data. + /// Optional function to resolve resource names. + /// Optional dashboard URL. + /// A tuple containing the JSON string and a limit message. + public static (string json, string limitMessage) GetTracesJson( + IList? resourceSpans, + Func getResourceName, + string? dashboardBaseUrl = null) + { + var traces = GetTracesFromOtlpData(resourceSpans); + var promptContext = new PromptContext(); + + var (trimmedItems, limitMessage) = GetLimitFromEndWithSummary( + traces, + TracesLimit, + "trace", + "traces", + t => GetTraceDto(t, promptContext, getResourceName, dashboardBaseUrl), + EstimateSerializedJsonTokenSize); + + var jsonArray = new JsonArray(trimmedItems.ToArray()); + var tracesData = jsonArray.ToJsonString(s_jsonSerializerOptions); + + return (tracesData, limitMessage); + } + + /// + /// Converts OTLP resource spans to a single trace JSON for AI processing. + /// + /// The OTLP resource spans containing trace data. + /// Optional function to resolve resource names. + /// Optional dashboard URL. + /// The JSON string for the first trace. + public static string GetTraceJson( + IList? resourceSpans, + Func getResourceName, + string? dashboardBaseUrl = null) + { + var traces = GetTracesFromOtlpData(resourceSpans); + var trace = traces.FirstOrDefault() ?? throw new InvalidOperationException("No trace found in OTLP data."); + var promptContext = new PromptContext(); + var dto = GetTraceDto(trace, promptContext, getResourceName, dashboardBaseUrl); + + return dto.ToJsonString(s_jsonSerializerOptions); + } + + /// + /// Extracts traces from OTLP resource spans, grouping spans by trace ID. + /// + public static List GetTracesFromOtlpData(IList? resourceSpans) + { + var spansByTraceId = new Dictionary>(StringComparer.Ordinal); + + if (resourceSpans is null) + { + return []; + } + + foreach (var resourceSpan in resourceSpans) + { + var resource = CreateResourceFromOtlpJson(resourceSpan.Resource); + + if (resourceSpan.ScopeSpans is null) + { + continue; + } + + foreach (var scopeSpan in resourceSpan.ScopeSpans) + { + var scopeName = scopeSpan.Scope?.Name; + + if (scopeSpan.Spans is null) + { + continue; + } + + foreach (var span in scopeSpan.Spans) + { + var traceId = span.TraceId ?? string.Empty; + if (!spansByTraceId.TryGetValue(traceId, out var spanList)) + { + spanList = []; + spansByTraceId[traceId] = spanList; + } + + spanList.Add(new OtlpSpanDto(span, resource, scopeName)); + } + } + } + + return spansByTraceId + .Select(kvp => new OtlpTraceDto(kvp.Key, kvp.Value)) + .ToList(); + } + + /// + /// Creates a JsonObject representing a trace for AI processing. + /// + /// The trace DTO to convert. + /// The prompt context for tracking duplicate values. + /// Optional function to resolve resource names. + /// Optional dashboard URL. + /// A JsonObject containing the trace data. + public static JsonObject GetTraceDto( + OtlpTraceDto trace, + PromptContext context, + Func getResourceName, + string? dashboardBaseUrl = null) + { + var spanObjects = new List(); + foreach (var s in trace.Spans) + { + var span = s.Span; + var spanId = span.SpanId ?? string.Empty; + + var attributesObj = new JsonObject(); + if (span.Attributes is not null) + { + foreach (var attr in span.Attributes.Where(a => a.Key != OtlpHelpers.AspireDestinationNameAttribute)) + { + var attrValue = MapOtelAttributeValue(attr); + attributesObj[attr.Key!] = context.AddValue(attrValue, id => $@"Duplicate of attribute ""{id.Key}"" for span {OtlpHelpers.ToShortenedId(id.SpanId)}", (SpanId: spanId, attr.Key)); + } + } + + JsonArray? linksArray = null; + if (span.Links is { Length: > 0 }) + { + var linkObjects = span.Links.Select(link => (JsonNode)new JsonObject + { + ["trace_id"] = OtlpHelpers.ToShortenedId(link.TraceId ?? string.Empty), + ["span_id"] = OtlpHelpers.ToShortenedId(link.SpanId ?? string.Empty) + }).ToArray(); + linksArray = new JsonArray(linkObjects); + } + + var resourceName = getResourceName?.Invoke(s.Resource) ?? s.Resource.ResourceName; + var destination = GetAttributeStringValue(span.Attributes, OtlpHelpers.AspireDestinationNameAttribute); + var statusCode = span.Status?.Code; + var statusText = statusCode switch + { + 1 => "Ok", + 2 => "Error", + _ => null + }; + + var spanObj = new JsonObject + { + ["span_id"] = OtlpHelpers.ToShortenedId(spanId), + ["parent_span_id"] = span.ParentSpanId is { } id ? OtlpHelpers.ToShortenedId(id) : null, + ["kind"] = GetSpanKindName(span.Kind), + ["name"] = context.AddValue(span.Name, sId => $@"Duplicate of ""name"" for span {OtlpHelpers.ToShortenedId(sId)}", spanId), + ["status"] = statusText, + ["status_message"] = context.AddValue(span.Status?.Message, sId => $@"Duplicate of ""status_message"" for span {OtlpHelpers.ToShortenedId(sId)}", spanId), + ["source"] = resourceName, + ["destination"] = destination, + ["duration_ms"] = CalculateDurationMs(span.StartTimeUnixNano, span.EndTimeUnixNano), + ["attributes"] = attributesObj, + ["links"] = linksArray + }; + spanObjects.Add(spanObj); + } + + var spanArray = new JsonArray(spanObjects.ToArray()); + var traceId = OtlpHelpers.ToShortenedId(trace.TraceId); + var rootSpan = trace.Spans.FirstOrDefault(s => string.IsNullOrEmpty(s.Span.ParentSpanId)) ?? trace.Spans.FirstOrDefault(); + var hasError = trace.Spans.Any(s => s.Span.Status?.Code == 2); + var timestamp = rootSpan?.Span.StartTimeUnixNano is { } startNano + ? OtlpHelpers.UnixNanoSecondsToDateTime(startNano) + : (DateTime?)null; + + var traceData = new JsonObject + { + ["trace_id"] = traceId, + ["duration_ms"] = CalculateTraceDurationMs(trace.Spans), + ["title"] = rootSpan?.Span.Name, + ["spans"] = spanArray, + ["has_error"] = hasError, + ["timestamp"] = timestamp + }; + + if (dashboardBaseUrl is not null) + { + traceData["dashboard_link"] = GetDashboardLinkObject(dashboardBaseUrl, DashboardUrls.TraceDetailUrl(traceId), traceId); + } + + return traceData; + } + + private static string MapOtelAttributeValue(OtlpKeyValueJson attribute) + { + var key = attribute.Key; + var value = GetAttributeValue(attribute); + + switch (key) + { + case "http.response.status_code": + { + if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue)) + { + return GetHttpStatusName(intValue); + } + goto default; + } + case "rpc.grpc.status_code": + { + if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue)) + { + return GetGrpcStatusName(intValue); + } + goto default; + } + default: + return value; + } + } + + private static string GetHttpStatusName(int statusCode) + { + return statusCode switch + { + 200 => "200 OK", + 201 => "201 Created", + 204 => "204 No Content", + 301 => "301 Moved Permanently", + 302 => "302 Found", + 304 => "304 Not Modified", + 400 => "400 Bad Request", + 401 => "401 Unauthorized", + 403 => "403 Forbidden", + 404 => "404 Not Found", + 405 => "405 Method Not Allowed", + 408 => "408 Request Timeout", + 409 => "409 Conflict", + 422 => "422 Unprocessable Entity", + 429 => "429 Too Many Requests", + 500 => "500 Internal Server Error", + 501 => "501 Not Implemented", + 502 => "502 Bad Gateway", + 503 => "503 Service Unavailable", + 504 => "504 Gateway Timeout", + _ => statusCode.ToString(CultureInfo.InvariantCulture) + }; + } + + private static string GetGrpcStatusName(int statusCode) + { + return statusCode switch + { + 0 => "OK", + 1 => "CANCELLED", + 2 => "UNKNOWN", + 3 => "INVALID_ARGUMENT", + 4 => "DEADLINE_EXCEEDED", + 5 => "NOT_FOUND", + 6 => "ALREADY_EXISTS", + 7 => "PERMISSION_DENIED", + 8 => "RESOURCE_EXHAUSTED", + 9 => "FAILED_PRECONDITION", + 10 => "ABORTED", + 11 => "OUT_OF_RANGE", + 12 => "UNIMPLEMENTED", + 13 => "INTERNAL", + 14 => "UNAVAILABLE", + 15 => "DATA_LOSS", + 16 => "UNAUTHENTICATED", + _ => statusCode.ToString(CultureInfo.InvariantCulture) + }; + } + + private static string? GetSpanKindName(int? kind) + { + return kind switch + { + 1 => "Internal", + 2 => "Server", + 3 => "Client", + 4 => "Producer", + 5 => "Consumer", + _ => null + }; + } + + private static int? CalculateDurationMs(ulong? startTimeUnixNano, ulong? endTimeUnixNano) + { + if (startTimeUnixNano is null || endTimeUnixNano is null) + { + return null; + } + + var durationNano = endTimeUnixNano.Value - startTimeUnixNano.Value; + return (int)Math.Round(durationNano / 1_000_000.0, 0, MidpointRounding.AwayFromZero); + } + + private static int? CalculateTraceDurationMs(List spans) + { + if (spans.Count == 0) + { + return null; + } + + ulong? minStart = null; + ulong? maxEnd = null; + + foreach (var s in spans) + { + if (s.Span.StartTimeUnixNano is { } start) + { + minStart = minStart is null ? start : Math.Min(minStart.Value, start); + } + if (s.Span.EndTimeUnixNano is { } end) + { + maxEnd = maxEnd is null ? end : Math.Max(maxEnd.Value, end); + } + } + + return CalculateDurationMs(minStart, maxEnd); + } + + /// + /// Extracts log records from OTLP resource logs. + /// + public static List GetLogRecordsFromOtlpData(IList? resourceLogs) + { + var logRecords = new List(); + + if (resourceLogs is null) + { + return logRecords; + } + + foreach (var resourceLog in resourceLogs) + { + var resource = CreateResourceFromOtlpJson(resourceLog.Resource); + + if (resourceLog.ScopeLogs is null) + { + continue; + } + + foreach (var scopeLogs in resourceLog.ScopeLogs) + { + var scopeName = scopeLogs.Scope?.Name; + + if (scopeLogs.LogRecords is null) + { + continue; + } + + foreach (var logRecord in scopeLogs.LogRecords) + { + logRecords.Add(new OtlpLogEntryDto(logRecord, resource, scopeName)); + } + } + } + + return logRecords; + } + + /// + /// Gets the message from a log record. + /// + public static string? GetLogMessage(OtlpLogRecordJson logRecord) + { + return logRecord.Body?.StringValue; + } + + /// + /// Gets the attribute value as a string. + /// + public static string GetAttributeValue(OtlpKeyValueJson attribute) + { + if (attribute.Value is null) + { + return string.Empty; + } + + return attribute.Value.StringValue + ?? attribute.Value.IntValue?.ToString(CultureInfo.InvariantCulture) + ?? attribute.Value.DoubleValue?.ToString(CultureInfo.InvariantCulture) + ?? attribute.Value.BoolValue?.ToString(CultureInfo.InvariantCulture) + ?? string.Empty; + } + + /// + /// Gets the value of an attribute by key as a string, or null if not found. + /// + public static string? GetAttributeStringValue(OtlpKeyValueJson[]? attributes, string key) + { + if (attributes is null) + { + return null; + } + + foreach (var attr in attributes) + { + if (attr.Key == key) + { + var value = GetAttributeValue(attr); + return string.IsNullOrEmpty(value) ? null : value; + } + } + + return null; + } + + /// + /// Creates a SimpleOtlpResource from OTLP resource JSON. + /// + /// The OTLP resource JSON, or null. + /// A SimpleOtlpResource with the service name and instance ID extracted from attributes. + private static SimpleOtlpResource CreateResourceFromOtlpJson(OtlpResourceJson? resource) + { + var serviceName = GetAttributeStringValue(resource?.Attributes, "service.name"); + var serviceInstanceId = GetAttributeStringValue(resource?.Attributes, "service.instance.id"); + var resourceName = serviceName ?? "Unknown"; + return new SimpleOtlpResource(resourceName, serviceInstanceId); + } + + private const string ExceptionStackTraceField = "exception.stacktrace"; + private const string ExceptionMessageField = "exception.message"; + private const string ExceptionTypeField = "exception.type"; + + /// + /// Filters out exception-related attributes and internal Aspire attributes from the attributes list. + /// + public static IEnumerable GetFilteredAttributes(OtlpKeyValueJson[]? attributes) + { + if (attributes is null) + { + return []; + } + + return attributes.Where(a => a.Key is not (ExceptionStackTraceField or ExceptionMessageField or ExceptionTypeField or OtlpHelpers.AspireLogIdAttribute)); + } + + /// + /// Gets the exception text from a log entry's attributes. + /// + public static string? GetExceptionText(OtlpLogEntryDto logEntry) + { + var stackTrace = GetAttributeStringValue(logEntry.LogRecord.Attributes, ExceptionStackTraceField); + if (!string.IsNullOrEmpty(stackTrace)) + { + return stackTrace; + } + + var message = GetAttributeStringValue(logEntry.LogRecord.Attributes, ExceptionMessageField); + if (!string.IsNullOrEmpty(message)) + { + var type = GetAttributeStringValue(logEntry.LogRecord.Attributes, ExceptionTypeField); + if (!string.IsNullOrEmpty(type)) + { + return $"{type}: {message}"; + } + + return message; + } + + return null; + } + + /// + /// Creates a JsonObject representing a log entry for AI processing. + /// + /// The log entry to convert. + /// The prompt context for tracking duplicate values. + /// Optional function to resolve resource names. + /// Optional dashboard URL. + /// A JsonObject containing the log entry data. + public static JsonObject GetLogEntryDto( + OtlpLogEntryDto logEntry, + PromptContext context, + Func getResourceName, + string? dashboardBaseUrl = null) + { + var exceptionText = GetExceptionText(logEntry); + var logIdString = GetAttributeStringValue(logEntry.LogRecord.Attributes, OtlpHelpers.AspireLogIdAttribute); + var logId = long.TryParse(logIdString, CultureInfo.InvariantCulture, out var parsedLogId) ? parsedLogId : (long?)null; + var resourceName = getResourceName?.Invoke(logEntry.Resource) ?? logEntry.Resource.ResourceName; + + var attributesObject = new JsonObject(); + foreach (var attr in GetFilteredAttributes(logEntry.LogRecord.Attributes)) + { + var attrValue = GetAttributeValue(attr); + attributesObject[attr.Key!] = context.AddValue(attrValue, id => $@"Duplicate of attribute ""{id.Key}"" for log entry {id.LogId}", (LogId: logId, attr.Key)); + } + + var message = GetLogMessage(logEntry.LogRecord) ?? string.Empty; + var log = new JsonObject + { + ["log_id"] = logId, + ["span_id"] = OtlpHelpers.ToShortenedId(logEntry.LogRecord.SpanId ?? string.Empty), + ["trace_id"] = OtlpHelpers.ToShortenedId(logEntry.LogRecord.TraceId ?? string.Empty), + ["message"] = context.AddValue(message, id => $@"Duplicate of ""message"" for log entry {id}", logId), + ["severity"] = logEntry.LogRecord.SeverityText ?? "Unknown", + ["resource_name"] = resourceName, + ["attributes"] = attributesObject, + ["exception"] = context.AddValue(exceptionText, id => $@"Duplicate of ""exception"" for log entry {id}", logId), + ["source"] = logEntry.ScopeName + }; + + if (dashboardBaseUrl is not null && logId is not null) + { + log["dashboard_link"] = GetDashboardLinkObject(dashboardBaseUrl, DashboardUrls.StructuredLogsUrl(logEntryId: logId), $"log_id: {logId}"); + } + + return log; + } + + public static JsonObject? GetDashboardLinkObject(string dashboardBaseUrl, string path, string text) + { + return new JsonObject + { + ["url"] = DashboardUrls.CombineUrl(dashboardBaseUrl, path), + ["text"] = text + }; + } + /// /// Serializes a log entry to a string, stripping timestamps and ANSI control sequences. /// @@ -81,13 +674,13 @@ public static string LimitLength(string value) /// /// Gets items from the end of a list with a summary message, applying count and token limits. /// - public static (List items, string message) GetLimitFromEndWithSummary( + public static (List items, string message) GetLimitFromEndWithSummary( List values, int limit, string itemName, string pluralItemName, - Func convertToDto, - Func estimateTokenSize) + Func convertToDto, + Func estimateTokenSize) { return GetLimitFromEndWithSummary(values, values.Count, limit, itemName, pluralItemName, convertToDto, estimateTokenSize); } @@ -95,14 +688,14 @@ public static (List items, string message) GetLimitFromEndWithSummary /// /// Gets items from the end of a list with a summary message, applying count and token limits. /// - public static (List items, string message) GetLimitFromEndWithSummary( + public static (List items, string message) GetLimitFromEndWithSummary( List values, int totalValues, int limit, string itemName, string pluralItemName, - Func convertToDto, - Func estimateTokenSize) + Func convertToDto, + Func estimateTokenSize) { Debug.Assert(totalValues >= values.Count, "Total values should be large or equal to the values passed into the method."); @@ -157,3 +750,26 @@ private static string ToQuantity(int count, string itemName, string pluralItemNa return string.Create(CultureInfo.InvariantCulture, $"{count} {name}"); } } + +/// +/// Represents a log entry extracted from OTLP JSON format for AI processing. +/// +/// The OTLP log record JSON data. +/// The resource information from the resource attributes. +/// The instrumentation scope name. +internal sealed record OtlpLogEntryDto(OtlpLogRecordJson LogRecord, IOtlpResource Resource, string? ScopeName); + +/// +/// Represents a trace (collection of spans with the same trace ID) extracted from OTLP JSON format. +/// +/// The trace ID. +/// The spans belonging to this trace. +internal sealed record OtlpTraceDto(string TraceId, List Spans); + +/// +/// Represents a span extracted from OTLP JSON format for AI processing. +/// +/// The OTLP span JSON data. +/// The resource information from the resource attributes. +/// The instrumentation scope name. +internal sealed record OtlpSpanDto(OtlpSpanJson Span, IOtlpResource Resource, string? ScopeName); diff --git a/src/Shared/Otlp/IOtlpResource.cs b/src/Shared/Otlp/IOtlpResource.cs new file mode 100644 index 00000000000..303afe54d56 --- /dev/null +++ b/src/Shared/Otlp/IOtlpResource.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Otlp.Model; + +/// +/// Interface for OTLP resource data. +/// Used by both Dashboard and CLI. +/// +public interface IOtlpResource +{ + /// + /// Gets the resource name (typically the service.name attribute). + /// + string ResourceName { get; } + + /// + /// Gets the instance ID (typically the service.instance.id attribute). + /// + string? InstanceId { get; } +} + +/// +/// Simple implementation of for cases where only the name and instance ID are needed. +/// +/// The resource name (typically the service.name attribute). +/// The instance ID (typically the service.instance.id attribute). +public sealed record SimpleOtlpResource(string ResourceName, string? InstanceId) : IOtlpResource; diff --git a/src/Shared/Otlp/OtlpHelpers.cs b/src/Shared/Otlp/OtlpHelpers.cs index 0f7803e1384..e9899f87cd0 100644 --- a/src/Shared/Otlp/OtlpHelpers.cs +++ b/src/Shared/Otlp/OtlpHelpers.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; using System.Globalization; namespace Aspire.Dashboard.Otlp.Model; @@ -11,6 +12,16 @@ namespace Aspire.Dashboard.Otlp.Model; /// public static partial class OtlpHelpers { + /// + /// The attribute name for Aspire's log entry ID. + /// + public const string AspireLogIdAttribute = "aspire.log_id"; + + /// + /// The attribute name for the resolved destination name of a span. + /// + public const string AspireDestinationNameAttribute = "aspire.destination"; + /// /// The standard length for shortened trace/span IDs. /// @@ -88,4 +99,41 @@ public static string FormatNanoTimestamp(ulong? nanos) } return ""; } + + public static string GetResourceName(IOtlpResource resource, IReadOnlyList allResources) + { + var count = 0; + foreach (var item in allResources) + { + if (string.Equals(item.ResourceName, resource.ResourceName, StringComparisons.ResourceName)) + { + count++; + if (count >= 2) + { + var instanceId = resource.InstanceId; + + // Convert long GUID into a shorter, more human friendly format. + // Before: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee + // After: aaaaaaaa + if (instanceId != null && Guid.TryParse(instanceId, out var guid)) + { + Span chars = stackalloc char[32]; + var result = guid.TryFormat(chars, charsWritten: out _, format: "N"); + Debug.Assert(result, "Guid.TryFormat not successful."); + + instanceId = chars.Slice(0, 8).ToString(); + } + + if (instanceId == null) + { + return item.ResourceName; + } + + return $"{item.ResourceName}-{instanceId}"; + } + } + } + + return resource.ResourceName; + } } diff --git a/tests/Aspire.Cli.Tests/Mcp/Docs/DocsFetcherTests.cs b/tests/Aspire.Cli.Tests/Mcp/Docs/DocsFetcherTests.cs index 217d6c89759..6a10a091588 100644 --- a/tests/Aspire.Cli.Tests/Mcp/Docs/DocsFetcherTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/Docs/DocsFetcherTests.cs @@ -3,6 +3,7 @@ using System.Net; using Aspire.Cli.Mcp.Docs; +using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Tests.Mcp.Docs; @@ -377,55 +378,6 @@ protected override Task SendAsync( } } - private sealed class MockHttpMessageHandler : HttpMessageHandler - { - private readonly Func? _responseFactory; - private readonly HttpResponseMessage? _response; - private readonly Exception? _exception; - private readonly Action? _requestValidator; - - public bool RequestValidated { get; private set; } - - public MockHttpMessageHandler(HttpResponseMessage response, Action? requestValidator = null) - { - _response = response; - _requestValidator = requestValidator; - } - - public MockHttpMessageHandler(Func responseFactory) - { - _responseFactory = responseFactory; - } - - public MockHttpMessageHandler(Exception exception) - { - _exception = exception; - } - - protected override Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) - { - if (_exception is not null) - { - throw _exception; - } - - if (_requestValidator is not null) - { - _requestValidator(request); - RequestValidated = true; - } - - if (_responseFactory is not null) - { - return Task.FromResult(_responseFactory(request)); - } - - return Task.FromResult(_response!); - } - } - private sealed class MockDocsCache : IDocsCache { private readonly Dictionary _content = []; diff --git a/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs new file mode 100644 index 00000000000..0024b7849ba --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/ListStructuredLogsToolTests.cs @@ -0,0 +1,429 @@ +// 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.Text.Json; +using System.Text.Json.Nodes; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Mcp; +using Aspire.Cli.Mcp.Tools; +using Aspire.Cli.Otlp; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Otlp.Serialization; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Tests.Mcp; + +public class ListStructuredLogsToolTests +{ + private static readonly TestHttpClientFactory s_httpClientFactory = new(); + + [Fact] + public async Task ListStructuredLogsTool_ThrowsException_WhenNoAppHostRunning() + { + var tool = CreateTool(); + + var exception = await Assert.ThrowsAsync( + () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout(); + + Assert.Contains("No Aspire AppHost", exception.Message); + } + + [Fact] + public async Task ListStructuredLogsTool_ThrowsException_WhenDashboardApiNotAvailable() + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + DashboardInfoResponse = null + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var tool = CreateTool(monitor); + + var exception = await Assert.ThrowsAsync( + () => tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).AsTask()).DefaultTimeout(); + + Assert.Contains("Dashboard is not available", exception.Message); + } + + [Fact] + public async Task ListStructuredLogsTool_ReturnsFormattedLogs_WhenApiReturnsData() + { + // Local function to create OtlpResourceLogsJson with service name and instance ID + static OtlpResourceLogsJson CreateResourceLogs(string serviceName, string? serviceInstanceId, params OtlpLogRecordJson[] logRecords) + { + var attributes = new List + { + new() { Key = "service.name", Value = new OtlpAnyValueJson { StringValue = serviceName } } + }; + if (serviceInstanceId is not null) + { + attributes.Add(new OtlpKeyValueJson { Key = "service.instance.id", Value = new OtlpAnyValueJson { StringValue = serviceInstanceId } }); + } + + return new OtlpResourceLogsJson + { + Resource = new OtlpResourceJson + { + Attributes = [.. attributes] + }, + ScopeLogs = + [ + new OtlpScopeLogsJson + { + Scope = new OtlpInstrumentationScopeJson { Name = "Microsoft.Extensions.Logging" }, + LogRecords = logRecords + } + ] + }; + } + + // Arrange - Create mock HTTP handler with sample structured logs response + // Include aspire.log_id attribute to verify it's extracted to log_id field and filtered from attributes + var apiResponseObj = new TelemetryApiResponse + { + Data = new TelemetryDataJson + { + ResourceLogs = + [ + CreateResourceLogs("api-service", "instance-1", + new OtlpLogRecordJson + { + TimeUnixNano = 1706540400000000000, + SeverityNumber = 9, + SeverityText = "Information", + Body = new OtlpAnyValueJson { StringValue = "Application started successfully" }, + TraceId = "abc123", + SpanId = "def456", + Attributes = + [ + new OtlpKeyValueJson { Key = OtlpHelpers.AspireLogIdAttribute, Value = new OtlpAnyValueJson { StringValue = "42" } }, + new OtlpKeyValueJson { Key = "custom.attr", Value = new OtlpAnyValueJson { StringValue = "custom-value" } } + ] + }), + CreateResourceLogs("api-service", "instance-2", + new OtlpLogRecordJson + { + TimeUnixNano = 1706540401000000000, + SeverityNumber = 13, + SeverityText = "Warning", + Body = new OtlpAnyValueJson { StringValue = "Connection timeout warning" }, + TraceId = "abc123", + SpanId = "ghi789", + Attributes = + [ + new OtlpKeyValueJson { Key = OtlpHelpers.AspireLogIdAttribute, Value = new OtlpAnyValueJson { IntValue = 43 } } + ] + }), + CreateResourceLogs("worker-service", "instance-1", + new OtlpLogRecordJson + { + TimeUnixNano = 1706540402000000000, + SeverityNumber = 17, + SeverityText = "Error", + Body = new OtlpAnyValueJson { StringValue = "Worker failed to process message" }, + TraceId = "xyz789", + SpanId = "uvw123", + Attributes = + [ + new OtlpKeyValueJson { Key = OtlpHelpers.AspireLogIdAttribute, Value = new OtlpAnyValueJson { IntValue = 44 } } + ] + }) + ] + }, + TotalCount = 3, + ReturnedCount = 3 + }; + + var apiResponse = JsonSerializer.Serialize(apiResponseObj, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + + // Create resources that match the OtlpResourceLogsJson entries + var resources = new ResourceInfoJson[] + { + new() { Name = "api-service", InstanceId = "instance-1", HasLogs = true, HasTraces = true, HasMetrics = true }, + new() { Name = "api-service", InstanceId = "instance-2", HasLogs = true, HasTraces = true, HasMetrics = true }, + new() { Name = "worker-service", InstanceId = "instance-1", HasLogs = true, HasTraces = true, HasMetrics = true } + }; + var resourcesResponse = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + using var mockHandler = new MockHttpMessageHandler(request => + { + // Handle the resources endpoint + if (request.RequestUri?.AbsolutePath.Contains("/resources") == true) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + + // For logs endpoint, return the structured logs response + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(apiResponse, System.Text.Encoding.UTF8, "application/json") + }; + }); + var mockHttpClientFactory = new MockHttpClientFactory(mockHandler); + + // Use a dashboard URL with path and query string to test that only the base URL is used + var monitor = CreateMonitorWithDashboard(dashboardUrls: ["http://localhost:18888/login?t=authtoken123"]); + var tool = CreateTool(monitor, mockHttpClientFactory); + + // Act + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); + + // Assert + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + + // Parse the JSON array from the response to verify log_id extraction and attribute filtering + var jsonStartIndex = textContent.Text.IndexOf('['); + var jsonEndIndex = textContent.Text.LastIndexOf(']') + 1; + var jsonText = textContent.Text[jsonStartIndex..jsonEndIndex]; + var logsArray = JsonNode.Parse(jsonText)?.AsArray(); + + Assert.NotNull(logsArray); + Assert.Equal(3, logsArray.Count); + + // Verify first log entry has correct resource_name, log_id extracted, and aspire.log_id not in attributes + var firstLog = logsArray[0]?.AsObject(); + Assert.NotNull(firstLog); + Assert.Equal("api-service-instance-1", firstLog["resource_name"]?.GetValue()); + Assert.Equal(42, firstLog["log_id"]?.GetValue()); + var firstLogAttributes = firstLog["attributes"]?.AsObject(); + Assert.NotNull(firstLogAttributes); + Assert.False(firstLogAttributes.ContainsKey(OtlpHelpers.AspireLogIdAttribute), "aspire.log_id should be filtered from attributes"); + Assert.True(firstLogAttributes.ContainsKey("custom.attr"), "custom.attr should be present in attributes"); + + // Verify dashboard_link is included for each log entry with correct URLs + var firstDashboardLink = firstLog["dashboard_link"]?.AsObject(); + Assert.NotNull(firstDashboardLink); + Assert.Equal("http://localhost:18888/structuredlogs?logEntryId=42", firstDashboardLink["url"]?.GetValue()); + Assert.Equal("log_id: 42", firstDashboardLink["text"]?.GetValue()); + + // Verify second log entry has correct resource_name (different instance), log_id extracted (from intValue) + var secondLog = logsArray[1]?.AsObject(); + Assert.NotNull(secondLog); + Assert.Equal("api-service-instance-2", secondLog["resource_name"]?.GetValue()); + Assert.Equal(43, secondLog["log_id"]?.GetValue()); + var secondLogAttributes = secondLog["attributes"]?.AsObject(); + Assert.NotNull(secondLogAttributes); + Assert.False(secondLogAttributes.ContainsKey(OtlpHelpers.AspireLogIdAttribute), "aspire.log_id should be filtered from attributes"); + + var secondDashboardLink = secondLog["dashboard_link"]?.AsObject(); + Assert.NotNull(secondDashboardLink); + Assert.Equal("http://localhost:18888/structuredlogs?logEntryId=43", secondDashboardLink["url"]?.GetValue()); + Assert.Equal("log_id: 43", secondDashboardLink["text"]?.GetValue()); + + // Verify third log entry has correct resource_name (no instance ID) + var thirdLog = logsArray[2]?.AsObject(); + Assert.NotNull(thirdLog); + Assert.Equal("worker-service", thirdLog["resource_name"]?.GetValue()); + Assert.Equal(44, thirdLog["log_id"]?.GetValue()); + + var thirdDashboardLink = thirdLog["dashboard_link"]?.AsObject(); + Assert.NotNull(thirdDashboardLink); + Assert.Equal("http://localhost:18888/structuredlogs?logEntryId=44", thirdDashboardLink["url"]?.GetValue()); + Assert.Equal("log_id: 44", thirdDashboardLink["text"]?.GetValue()); + } + + [Fact] + public async Task ListStructuredLogsTool_ReturnsEmptyLogs_WhenApiReturnsNoData() + { + // Arrange - Create mock HTTP handler with empty logs response + var apiResponseObj = new TelemetryApiResponse + { + Data = new TelemetryDataJson { ResourceLogs = [] }, + TotalCount = 0, + ReturnedCount = 0 + }; + var apiResponse = JsonSerializer.Serialize(apiResponseObj, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + + var resources = new ResourceInfoJson[] + { + new() { Name = "api-service", InstanceId = null, HasLogs = true, HasTraces = true, HasMetrics = true } + }; + var resourcesResponse = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + using var mockHandler = new MockHttpMessageHandler(request => + { + // Handle the resources endpoint + if (request.RequestUri?.AbsolutePath.Contains("/resources") == true) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + + // For logs endpoint, return empty logs response + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(apiResponse, System.Text.Encoding.UTF8, "application/json") + }; + }); + var mockHttpClientFactory = new MockHttpClientFactory(mockHandler); + + var monitor = CreateMonitorWithDashboard(); + var tool = CreateTool(monitor, mockHttpClientFactory); + + // Act + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); + + // Assert + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("STRUCTURED LOGS DATA", textContent.Text); + // Empty array should be returned + Assert.Contains("[]", textContent.Text); + } + + [Fact] + public async Task ListStructuredLogsTool_ReturnsResourceNotFound_WhenResourceDoesNotExist() + { + // Arrange - Create mock HTTP handler that returns resources that don't match the requested name + var resources = new ResourceInfoJson[] + { + new() { Name = "other-resource", InstanceId = null, HasLogs = true, HasTraces = true, HasMetrics = true } + }; + var resourcesResponse = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + var emptyLogsResponse = new TelemetryApiResponse + { + Data = new TelemetryDataJson { ResourceLogs = [] }, + TotalCount = 0, + ReturnedCount = 0 + }; + var emptyLogsJson = JsonSerializer.Serialize(emptyLogsResponse, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + + using var mockHandler = new MockHttpMessageHandler(request => + { + // Check if this is the resources lookup request + if (request.RequestUri?.AbsolutePath.Contains("/resources") == true) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + + // For any other request, return empty logs response + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(emptyLogsJson, System.Text.Encoding.UTF8, "application/json") + }; + }); + var mockHttpClientFactory = new MockHttpClientFactory(mockHandler); + + var monitor = CreateMonitorWithDashboard(); + var tool = CreateTool(monitor, mockHttpClientFactory); + + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse("\"non-existent-resource\"").RootElement + }; + + // Act + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); + + // Assert + Assert.True(result.IsError); + var textContent = result.Content![0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("Resource 'non-existent-resource' not found", textContent.Text); + } + + [Fact] + public void ListStructuredLogsTool_HasCorrectName() + { + var tool = CreateTool(); + + Assert.Equal(KnownMcpTools.ListStructuredLogs, tool.Name); + } + + [Fact] + public void ListStructuredLogsTool_HasCorrectDescription() + { + var tool = CreateTool(); + + Assert.Equal("List structured logs for resources.", tool.Description); + } + + [Fact] + public void ListStructuredLogsTool_InputSchema_HasResourceNameProperty() + { + var tool = CreateTool(); + + var schema = tool.GetInputSchema(); + + Assert.Equal(JsonValueKind.Object, schema.ValueKind); + Assert.True(schema.TryGetProperty("properties", out var properties)); + Assert.True(properties.TryGetProperty("resourceName", out var resourceName)); + Assert.True(resourceName.TryGetProperty("type", out var type)); + Assert.Equal("string", type.GetString()); + } + + [Fact] + public void ListStructuredLogsTool_InputSchema_ResourceNameIsOptional() + { + var tool = CreateTool(); + + var schema = tool.GetInputSchema(); + + // Check that there's no "required" array or it doesn't include resourceName + if (schema.TryGetProperty("required", out var required)) + { + var requiredArray = required.EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.DoesNotContain("resourceName", requiredArray); + } + // If no required property, that's also fine - means nothing is required + } + + /// + /// Creates a ListStructuredLogsTool instance for testing with optional custom dependencies. + /// + private static ListStructuredLogsTool CreateTool( + TestAuxiliaryBackchannelMonitor? monitor = null, + IHttpClientFactory? httpClientFactory = null) + { + return new ListStructuredLogsTool( + monitor ?? new TestAuxiliaryBackchannelMonitor(), + httpClientFactory ?? s_httpClientFactory, + NullLogger.Instance); + } + + /// + /// Creates a TestAuxiliaryBackchannelMonitor with a connection configured with dashboard info. + /// + private static TestAuxiliaryBackchannelMonitor CreateMonitorWithDashboard( + string apiBaseUrl = "http://localhost:5000", + string apiToken = "test-token", + string[]? dashboardUrls = null) + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + DashboardInfoResponse = new GetDashboardInfoResponse + { + ApiBaseUrl = apiBaseUrl, + ApiToken = apiToken, + DashboardUrls = dashboardUrls ?? ["http://localhost:18888"] + } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + return monitor; + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs new file mode 100644 index 00000000000..5a6d104297c --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/ListTracesToolTests.cs @@ -0,0 +1,468 @@ +// 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.Text.Json; +using System.Text.Json.Nodes; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Mcp.Tools; +using Aspire.Cli.Otlp; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Aspire.Otlp.Serialization; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Protocol; + +namespace Aspire.Cli.Tests.Mcp; + +public class ListTracesToolTests +{ + private static readonly TestHttpClientFactory s_httpClientFactory = new(); + + [Fact] + public async Task ListTracesTool_ReturnsFormattedTraces_WhenApiReturnsData() + { + // Local function to create OtlpResourceSpansJson with service name and instance ID + static OtlpResourceSpansJson CreateResourceSpans(string serviceName, string? serviceInstanceId, params OtlpSpanJson[] spans) + { + var attributes = new List + { + new() { Key = "service.name", Value = new OtlpAnyValueJson { StringValue = serviceName } } + }; + if (serviceInstanceId is not null) + { + attributes.Add(new OtlpKeyValueJson { Key = "service.instance.id", Value = new OtlpAnyValueJson { StringValue = serviceInstanceId } }); + } + + return new OtlpResourceSpansJson + { + Resource = new OtlpResourceJson + { + Attributes = [.. attributes] + }, + ScopeSpans = + [ + new OtlpScopeSpansJson + { + Scope = new OtlpInstrumentationScopeJson { Name = "OpenTelemetry" }, + Spans = spans + } + ] + }; + } + + // Arrange - Create mock HTTP handler with sample traces response + var apiResponseObj = new TelemetryApiResponse + { + Data = new TelemetryDataJson + { + ResourceSpans = + [ + CreateResourceSpans("api-service", "instance-1", + new OtlpSpanJson + { + TraceId = "abc123def456789012345678901234567890", + SpanId = "span123456789012", + Name = "GET /api/products", + Kind = 2, // Server + StartTimeUnixNano = 1706540400000000000, + EndTimeUnixNano = 1706540400100000000, + Status = new OtlpSpanStatusJson { Code = 1 }, // Ok + Attributes = + [ + new OtlpKeyValueJson { Key = "http.method", Value = new OtlpAnyValueJson { StringValue = "GET" } }, + new OtlpKeyValueJson { Key = "http.url", Value = new OtlpAnyValueJson { StringValue = "/api/products" } } + ] + }), + CreateResourceSpans("api-service", "instance-2", + new OtlpSpanJson + { + TraceId = "abc123def456789012345678901234567890", + SpanId = "span234567890123", + ParentSpanId = "span123456789012", + Name = "GET /api/catalog", + Kind = 3, // Client + StartTimeUnixNano = 1706540400010000000, + EndTimeUnixNano = 1706540400090000000, + Status = new OtlpSpanStatusJson { Code = 1 }, + Attributes = + [ + new OtlpKeyValueJson { Key = "aspire.destination", Value = new OtlpAnyValueJson { StringValue = "catalog-service" } } + ] + }), + CreateResourceSpans("worker-service", "instance-1", + new OtlpSpanJson + { + TraceId = "xyz789abc123456789012345678901234567890", + SpanId = "span345678901234", + Name = "ProcessMessage", + Kind = 1, // Internal + StartTimeUnixNano = 1706540401000000000, + EndTimeUnixNano = 1706540401500000000, + Status = new OtlpSpanStatusJson { Code = 2 }, // Error + Attributes = + [ + new OtlpKeyValueJson { Key = "error", Value = new OtlpAnyValueJson { StringValue = "Processing failed" } } + ] + }) + ] + }, + TotalCount = 3, + ReturnedCount = 3 + }; + + var apiResponse = JsonSerializer.Serialize(apiResponseObj, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + + // Create resources that match the OtlpResourceSpansJson entries + var resources = new ResourceInfoJson[] + { + new() { Name = "api-service", InstanceId = "instance-1", HasLogs = true, HasTraces = true, HasMetrics = true }, + new() { Name = "api-service", InstanceId = "instance-2", HasLogs = true, HasTraces = true, HasMetrics = true }, + new() { Name = "worker-service", InstanceId = "instance-1", HasLogs = true, HasTraces = true, HasMetrics = true } + }; + var resourcesResponse = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + using var mockHandler = new MockHttpMessageHandler(request => + { + // Handle the resources endpoint + if (request.RequestUri?.AbsolutePath.Contains("/resources") == true) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + + // For traces endpoint, return the traces response + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(apiResponse, System.Text.Encoding.UTF8, "application/json") + }; + }); + var mockHttpClientFactory = new MockHttpClientFactory(mockHandler); + + // Use a dashboard URL with path and query string to test that only the base URL is used + var monitor = CreateMonitorWithDashboard(dashboardUrls: ["http://localhost:18888/login?t=authtoken123"]); + var tool = CreateTool(monitor, mockHttpClientFactory); + + // Act + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); + + // Assert + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + + // Parse the JSON array from the response + var jsonStartIndex = textContent.Text.IndexOf('['); + var jsonEndIndex = textContent.Text.LastIndexOf(']') + 1; + var jsonText = textContent.Text[jsonStartIndex..jsonEndIndex]; + var tracesArray = JsonNode.Parse(jsonText)?.AsArray(); + + Assert.NotNull(tracesArray); + // Should have 2 traces (grouped by trace_id) + Assert.Equal(2, tracesArray.Count); + + // Verify first trace (trace_id is shortened to 7 characters) + var firstTrace = tracesArray[0]?.AsObject(); + Assert.NotNull(firstTrace); + Assert.Equal("abc123d", firstTrace["trace_id"]?.GetValue()); + + // Verify spans in first trace have correct source and destination + var spans = firstTrace["spans"]?.AsArray(); + Assert.NotNull(spans); + Assert.Equal(2, spans.Count); + + // Verify dashboard_link is included for each trace with correct URLs (trace_id is shortened to 7 chars in the URL) + var firstDashboardLink = firstTrace["dashboard_link"]?.AsObject(); + Assert.NotNull(firstDashboardLink); + Assert.Equal("http://localhost:18888/traces/detail/abc123d", firstDashboardLink["url"]?.GetValue()); + Assert.Equal("abc123d", firstDashboardLink["text"]?.GetValue()); + + // First span (server) should have source from resource name, no destination + var serverSpan = spans.FirstOrDefault(s => s?["kind"]?.GetValue() == "Server")?.AsObject(); + Assert.NotNull(serverSpan); + Assert.Equal("api-service-instance-1", serverSpan["source"]?.GetValue()); + Assert.Null(serverSpan["destination"]); + + // Second span (client) should have source from resource name and destination from aspire.destination + var clientSpan = spans.FirstOrDefault(s => s?["kind"]?.GetValue() == "Client")?.AsObject(); + Assert.NotNull(clientSpan); + Assert.Equal("api-service-instance-2", clientSpan["source"]?.GetValue()); + Assert.Equal("catalog-service", clientSpan["destination"]?.GetValue()); + + // Verify second trace + var secondTrace = tracesArray[1]?.AsObject(); + Assert.NotNull(secondTrace); + Assert.Equal("xyz789a", secondTrace["trace_id"]?.GetValue()); + + var secondDashboardLink = secondTrace["dashboard_link"]?.AsObject(); + Assert.NotNull(secondDashboardLink); + Assert.Equal("http://localhost:18888/traces/detail/xyz789a", secondDashboardLink["url"]?.GetValue()); + Assert.Equal("xyz789a", secondDashboardLink["text"]?.GetValue()); + + // Verify spans in second trace have correct source and destination + var secondTraceSpans = secondTrace["spans"]?.AsArray(); + Assert.NotNull(secondTraceSpans); + Assert.Single(secondTraceSpans); + + // Internal span should have source from resource name (worker-service has no instance ID), no destination + var internalSpan = secondTraceSpans[0]?.AsObject(); + Assert.NotNull(internalSpan); + Assert.Equal("Internal", internalSpan["kind"]?.GetValue()); + Assert.Equal("worker-service", internalSpan["source"]?.GetValue()); + Assert.Null(internalSpan["destination"]); + } + + [Fact] + public async Task ListTracesTool_ReturnsEmptyTraces_WhenApiReturnsNoData() + { + // Arrange - Create mock HTTP handler with empty traces response + var apiResponseObj = new TelemetryApiResponse + { + Data = new TelemetryDataJson { ResourceSpans = [] }, + TotalCount = 0, + ReturnedCount = 0 + }; + var apiResponse = JsonSerializer.Serialize(apiResponseObj, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + + var resources = new ResourceInfoJson[] + { + new() { Name = "api-service", InstanceId = null, HasLogs = true, HasTraces = true, HasMetrics = true } + }; + var resourcesResponse = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + using var mockHandler = new MockHttpMessageHandler(request => + { + // Handle the resources endpoint + if (request.RequestUri?.AbsolutePath.Contains("/resources") == true) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + + // For traces endpoint, return empty traces response + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(apiResponse, System.Text.Encoding.UTF8, "application/json") + }; + }); + var mockHttpClientFactory = new MockHttpClientFactory(mockHandler); + + var monitor = CreateMonitorWithDashboard(); + var tool = CreateTool(monitor, mockHttpClientFactory); + + // Act + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(), CancellationToken.None).DefaultTimeout(); + + // Assert + Assert.True(result.IsError is null or false); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("TRACES DATA", textContent.Text); + // Empty array should be returned + Assert.Contains("[]", textContent.Text); + } + + [Fact] + public async Task ListTracesTool_ReturnsResourceNotFound_WhenResourceDoesNotExist() + { + // Arrange - Create mock HTTP handler that returns resources that don't match the requested name + var resources = new ResourceInfoJson[] + { + new() { Name = "other-resource", InstanceId = null, HasLogs = true, HasTraces = true, HasMetrics = true } + }; + var resourcesResponse = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + var emptyTracesResponse = new TelemetryApiResponse + { + Data = new TelemetryDataJson { ResourceSpans = [] }, + TotalCount = 0, + ReturnedCount = 0 + }; + var emptyTracesJson = JsonSerializer.Serialize(emptyTracesResponse, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + + using var mockHandler = new MockHttpMessageHandler(request => + { + // Check if this is the resources lookup request + if (request.RequestUri?.AbsolutePath.Contains("/resources") == true) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + + // For any other request, return empty traces response + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(emptyTracesJson, System.Text.Encoding.UTF8, "application/json") + }; + }); + var mockHttpClientFactory = new MockHttpClientFactory(mockHandler); + + var monitor = CreateMonitorWithDashboard(); + var tool = CreateTool(monitor, mockHttpClientFactory); + + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse("\"non-existent-resource\"").RootElement + }; + + // Act + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); + + // Assert + Assert.True(result.IsError); + var textContent = result.Content![0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("Resource 'non-existent-resource' not found", textContent.Text); + } + + [Fact] + public async Task ListTracesTool_FiltersTracesByResource_WhenResourceNameProvided() + { + // Arrange - Create mock HTTP handler with traces from multiple resources + static OtlpResourceSpansJson CreateResourceSpans(string serviceName, string? serviceInstanceId, params OtlpSpanJson[] spans) + { + var attributes = new List + { + new() { Key = "service.name", Value = new OtlpAnyValueJson { StringValue = serviceName } } + }; + if (serviceInstanceId is not null) + { + attributes.Add(new OtlpKeyValueJson { Key = "service.instance.id", Value = new OtlpAnyValueJson { StringValue = serviceInstanceId } }); + } + + return new OtlpResourceSpansJson + { + Resource = new OtlpResourceJson + { + Attributes = [.. attributes] + }, + ScopeSpans = + [ + new OtlpScopeSpansJson + { + Scope = new OtlpInstrumentationScopeJson { Name = "OpenTelemetry" }, + Spans = spans + } + ] + }; + } + + var apiResponseObj = new TelemetryApiResponse + { + Data = new TelemetryDataJson + { + ResourceSpans = + [ + CreateResourceSpans("api-service", null, + new OtlpSpanJson + { + TraceId = "trace123", + SpanId = "span123", + Name = "GET /api/products", + Kind = 2, + StartTimeUnixNano = 1706540400000000000, + EndTimeUnixNano = 1706540400100000000, + Status = new OtlpSpanStatusJson { Code = 1 } + }) + ] + }, + TotalCount = 1, + ReturnedCount = 1 + }; + + var apiResponse = JsonSerializer.Serialize(apiResponseObj, OtlpCliJsonSerializerContext.Default.TelemetryApiResponse); + + var resources = new ResourceInfoJson[] + { + new() { Name = "api-service", InstanceId = null, HasLogs = true, HasTraces = true, HasMetrics = true }, + new() { Name = "worker-service", InstanceId = null, HasLogs = true, HasTraces = true, HasMetrics = true } + }; + var resourcesResponse = JsonSerializer.Serialize(resources, OtlpCliJsonSerializerContext.Default.ResourceInfoJsonArray); + + string? capturedUrl = null; + using var mockHandler = new MockHttpMessageHandler(request => + { + // Capture the URL for assertions + capturedUrl = request.RequestUri?.ToString(); + + if (request.RequestUri?.AbsolutePath.Contains("/resources") == true) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resourcesResponse, System.Text.Encoding.UTF8, "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(apiResponse, System.Text.Encoding.UTF8, "application/json") + }; + }); + var mockHttpClientFactory = new MockHttpClientFactory(mockHandler); + + var monitor = CreateMonitorWithDashboard(); + var tool = CreateTool(monitor, mockHttpClientFactory); + + var arguments = new Dictionary + { + ["resourceName"] = JsonDocument.Parse("\"api-service\"").RootElement + }; + + // Act + var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(arguments), CancellationToken.None).DefaultTimeout(); + + // Assert + Assert.True(result.IsError is null or false); + Assert.NotNull(capturedUrl); + // Verify the URL contains the resource name filter + Assert.Contains("api-service", capturedUrl); + } + + /// + /// Creates a ListTracesTool instance for testing with optional custom dependencies. + /// + private static ListTracesTool CreateTool( + TestAuxiliaryBackchannelMonitor? monitor = null, + IHttpClientFactory? httpClientFactory = null) + { + return new ListTracesTool( + monitor ?? new TestAuxiliaryBackchannelMonitor(), + httpClientFactory ?? s_httpClientFactory, + NullLogger.Instance); + } + + /// + /// Creates a TestAuxiliaryBackchannelMonitor with a connection configured with dashboard info. + /// + private static TestAuxiliaryBackchannelMonitor CreateMonitorWithDashboard( + string apiBaseUrl = "http://localhost:5000", + string apiToken = "test-token", + string[]? dashboardUrls = null) + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + DashboardInfoResponse = new GetDashboardInfoResponse + { + ApiBaseUrl = apiBaseUrl, + ApiToken = apiToken, + DashboardUrls = dashboardUrls ?? ["http://localhost:18888"] + } + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + return monitor; + } +} diff --git a/tests/Aspire.Cli.Tests/Mcp/McpToolHelpersTests.cs b/tests/Aspire.Cli.Tests/Mcp/McpToolHelpersTests.cs new file mode 100644 index 00000000000..0480f5d1a21 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Mcp/McpToolHelpersTests.cs @@ -0,0 +1,24 @@ +// 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.Mcp.Tools; + +namespace Aspire.Cli.Tests.Mcp; + +public class McpToolHelpersTests +{ + [Theory] + [InlineData(null, null)] + [InlineData("http://localhost:18888", "http://localhost:18888")] + [InlineData("http://localhost:18888/", "http://localhost:18888")] + [InlineData("http://localhost:18888/login", "http://localhost:18888")] + [InlineData("http://localhost:18888/login?t=authtoken123", "http://localhost:18888")] + [InlineData("https://localhost:16319/login?t=d8d8255df4c79aebcb5b7325828ccb20", "https://localhost:16319")] + [InlineData("https://example.com:8080/path/to/resource?param=value", "https://example.com:8080")] + [InlineData("invalid-url", "invalid-url")] // Falls back to returning the original string + public void GetBaseUrl_ExtractsBaseUrl_RemovingPathAndQueryString(string? input, string? expected) + { + var result = McpToolHelpers.GetBaseUrl(input); + Assert.Equal(expected, result); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/MockHttpClientFactory.cs b/tests/Aspire.Cli.Tests/Utils/MockHttpClientFactory.cs new file mode 100644 index 00000000000..6e0274398f1 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/MockHttpClientFactory.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. + +namespace Aspire.Cli.Tests.Utils; + +/// +/// Mock HTTP client factory that creates clients using the specified handler. +/// Useful for testing code that depends on IHttpClientFactory. +/// +internal sealed class MockHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory +{ + /// + /// Creates an HTTP client using the configured handler. + /// + /// The name of the client (ignored). + /// An HTTP client configured with the mock handler. + public HttpClient CreateClient(string name) + { + return new HttpClient(handler, disposeHandler: false); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/MockHttpMessageHandler.cs b/tests/Aspire.Cli.Tests/Utils/MockHttpMessageHandler.cs new file mode 100644 index 00000000000..44cc4eae0a2 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/MockHttpMessageHandler.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.Cli.Tests.Utils; + +/// +/// Mock HTTP message handler for testing HTTP client interactions. +/// Supports returning fixed responses, dynamic responses via factory, throwing exceptions, and request validation. +/// +internal sealed class MockHttpMessageHandler : HttpMessageHandler +{ + private readonly Func? _responseFactory; + private readonly HttpResponseMessage? _response; + private readonly Exception? _exception; + private readonly Action? _requestValidator; + + /// + /// Gets a value indicating whether the request validator was invoked. + /// + public bool RequestValidated { get; private set; } + + /// + /// Creates a handler that returns the specified response. + /// + /// The HTTP response to return. + /// Optional action to validate the request. + public MockHttpMessageHandler(HttpResponseMessage response, Action? requestValidator = null) + { + _response = response; + _requestValidator = requestValidator; + } + + /// + /// Creates a handler that generates responses dynamically using the provided factory. + /// + /// A function that creates responses based on the request. + public MockHttpMessageHandler(Func responseFactory) + { + _responseFactory = responseFactory; + } + + /// + /// Creates a handler that throws the specified exception. + /// + /// The exception to throw. + public MockHttpMessageHandler(Exception exception) + { + _exception = exception; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (_exception is not null) + { + throw _exception; + } + + if (_requestValidator is not null) + { + _requestValidator(request); + RequestValidated = true; + } + + if (_responseFactory is not null) + { + return Task.FromResult(_responseFactory(request)); + } + + return Task.FromResult(_response!); + } +} diff --git a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs index 438e150d0ea..521b4b5d4bd 100644 --- a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AIHelpersTests.cs @@ -187,10 +187,10 @@ public void GetDashboardUrl_PublicUrl() Assert.True(options.Frontend.TryParseOptions(out _)); // Act - var url = AIHelpers.GetDashboardUrl(options, "/path"); + var url = AIHelpers.GetDashboardUrl(options); // Assert - Assert.Equal("https://localhost:1234/path", url); + Assert.Equal("https://localhost:1234", url); } [Fact] @@ -202,10 +202,10 @@ public void GetDashboardUrl_HttpsAndHttpEndpointUrls() Assert.True(options.Frontend.TryParseOptions(out _)); // Act - var url = AIHelpers.GetDashboardUrl(options, "/path"); + var url = AIHelpers.GetDashboardUrl(options); // Assert - Assert.Equal("https://localhost:1234/path", url); + Assert.Equal("https://localhost:1234", url); } [Fact] @@ -217,9 +217,9 @@ public void GetDashboardUrl_HttpEndpointUrl() Assert.True(options.Frontend.TryParseOptions(out _)); // Act - var url = AIHelpers.GetDashboardUrl(options, "/path"); + var url = AIHelpers.GetDashboardUrl(options); // Assert - Assert.Equal("http://localhost:5000/path", url); + Assert.Equal("http://localhost:5000", url); } } diff --git a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs index 2220e964a75..bf57c341dac 100644 --- a/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/AIAssistant/AssistantChatDataContextTests.cs @@ -128,12 +128,21 @@ public async Task GetConsoleLogs_ExceedTokenLimit_ReturnMostRecentItems() internal static AssistantChatDataContext CreateAssistantChatDataContext(TelemetryRepository? telemetryRepository = null, IDashboardClient? dashboardClient = null) { + var dashboardOptions = new DashboardOptions + { + Frontend = new FrontendOptions + { + EndpointUrls = "http://localhost:5000" + } + }; + Assert.True(dashboardOptions.Frontend.TryParseOptions(out _)); + var context = new AssistantChatDataContext( telemetryRepository ?? CreateRepository(), dashboardClient ?? new MockDashboardClient(), [], new TestStringLocalizer(), - new TestOptionsMonitor(new DashboardOptions())); + new TestOptionsMonitor(dashboardOptions)); return context; } diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs index e3f7c282a81..e9220633c4f 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.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.Globalization; using System.IO.Compression; using System.Text.Json; using Aspire.Dashboard.Model; @@ -9,6 +10,7 @@ using Aspire.Dashboard.Otlp.Model.Serialization; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Tests.Shared; +using Aspire.Dashboard.Tests.TelemetryRepositoryTests; using Aspire.Tests.Shared.DashboardModel; using Google.Protobuf.Collections; using Microsoft.AspNetCore.InternalTesting; @@ -90,6 +92,44 @@ public void ConvertLogsToOtlpJson_SingleLog_ReturnsCorrectStructure() Assert.Contains(logRecord.Attributes, a => a.Key == "custom.attr" && a.Value?.StringValue == "custom-value"); } + [Fact] + public void ConvertLogsToOtlpJson_AddsAspireLogIdAttribute() + { + // Arrange + var repository = CreateRepository(); + var addContext = new AddContext(); + repository.AddLogs(addContext, new RepeatedField() + { + new ResourceLogs + { + Resource = CreateResource(name: "TestService", instanceId: "instance-1"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = { CreateLogRecord(time: s_testTime, message: "Test log message") } + } + } + } + }); + + var resources = repository.GetResources(); + var resource = resources[0]; + var logs = repository.GetLogs(GetLogsContext.ForResourceKey(resource.ResourceKey)); + + // Act + var result = TelemetryExportService.ConvertLogsToOtlpJson(logs.Items); + + // Assert + var logRecord = result.ResourceLogs![0].ScopeLogs![0].LogRecords![0]; + Assert.NotNull(logRecord.Attributes); + + // Verify aspire.log_id attribute is added with the InternalId value + var logIdAttribute = Assert.Single(logRecord.Attributes, a => a.Key == OtlpHelpers.AspireLogIdAttribute); + Assert.Equal(logs.Items[0].InternalId.ToString(CultureInfo.InvariantCulture), logIdAttribute.Value?.StringValue); + } + [Fact] public void ConvertLogsToOtlpJson_MultipleLogs_GroupsByScope() { @@ -252,7 +292,7 @@ public void ConvertTracesToOtlpJson_SingleTrace_ReturnsCorrectStructure() }); // Act - var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items); + var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, []); // Assert Assert.NotNull(result.ResourceSpans); @@ -318,7 +358,7 @@ public void ConvertTracesToOtlpJson_SpanWithParent_IncludesParentSpanId() var traces = repository.GetTraces(GetTracesRequest.ForResourceKey(resource.ResourceKey)); // Act - var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items); + var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, []); // Assert var spans = result.ResourceSpans![0].ScopeSpans![0].Spans!; @@ -330,6 +370,98 @@ public void ConvertTracesToOtlpJson_SpanWithParent_IncludesParentSpanId() Assert.NotNull(childSpan.ParentSpanId); } + [Fact] + public void ConvertTracesToOtlpJson_WithPeerResolvers_AddsDestinationNameAttribute() + { + // Arrange + var repository = CreateRepository(); + var addContext = new AddContext(); + repository.AddTraces(addContext, new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "trace123456789012", + spanId: "span1234", + startTime: s_testTime, + endTime: s_testTime.AddSeconds(5), + attributes: [new KeyValuePair("peer.service", "target-service")]) + } + } + } + } + }); + + var resources = repository.GetResources(); + var resource = resources[0]; + var traces = repository.GetTraces(GetTracesRequest.ForResourceKey(resource.ResourceKey)); + + var outgoingPeerResolver = new TestOutgoingPeerResolver(onResolve: attributes => + { + var peerService = attributes.FirstOrDefault(a => a.Key == "peer.service"); + return (peerService.Value, null); + }); + + // Act + var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, [outgoingPeerResolver]); + + // Assert + var span = result.ResourceSpans![0].ScopeSpans![0].Spans![0]; + Assert.NotNull(span.Attributes); + Assert.Contains(span.Attributes, a => a.Key == OtlpHelpers.AspireDestinationNameAttribute && a.Value?.StringValue == "target-service"); + } + + [Fact] + public void ConvertTracesToOtlpJson_WithoutPeerResolvers_DoesNotAddDestinationNameAttribute() + { + // Arrange + var repository = CreateRepository(); + var addContext = new AddContext(); + repository.AddTraces(addContext, new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "trace123456789012", + spanId: "span1234", + startTime: s_testTime, + endTime: s_testTime.AddSeconds(5), + attributes: [new KeyValuePair("peer.service", "target-service")]) + } + } + } + } + }); + + var resources = repository.GetResources(); + var resource = resources[0]; + var traces = repository.GetTraces(GetTracesRequest.ForResourceKey(resource.ResourceKey)); + + // Act + var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, []); + + // Assert + var span = result.ResourceSpans![0].ScopeSpans![0].Spans![0]; + Assert.NotNull(span.Attributes); + Assert.DoesNotContain(span.Attributes, a => a.Key == OtlpHelpers.AspireDestinationNameAttribute); + } + [Fact] public void ConvertMetricsToOtlpJson_SingleInstrument_ReturnsCorrectStructure() { @@ -758,7 +890,7 @@ public void ConvertSpanToJson_ReturnsValidOtlpTelemetryDataJson() var span = repository.GetTraces(GetTracesRequest.ForResourceKey(repository.GetResources()[0].ResourceKey)).PagedResult.Items[0].Spans[0]; // Act - var json = TelemetryExportService.ConvertSpanToJson(span); + var json = TelemetryExportService.ConvertSpanToJson(span, []); // Assert - deserialize back to verify OtlpTelemetryDataJson structure var data = JsonSerializer.Deserialize(json, OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); @@ -811,7 +943,7 @@ public void ConvertSpanToJson_WithLogs_IncludesLogsInOutput() var logs = repository.GetLogs(GetLogsContext.ForResourceKey(repository.GetResources()[0].ResourceKey)).Items; // Act - var json = TelemetryExportService.ConvertSpanToJson(span, logs); + var json = TelemetryExportService.ConvertSpanToJson(span, [], logs); // Assert - verify both spans and logs are in the output var data = JsonSerializer.Deserialize(json, OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); @@ -871,7 +1003,7 @@ public void ConvertTraceToJson_WithLogs_IncludesLogsInOutput() var logs = repository.GetLogs(GetLogsContext.ForResourceKey(repository.GetResources()[0].ResourceKey)).Items; // Act - var json = TelemetryExportService.ConvertTraceToJson(trace, logs); + var json = TelemetryExportService.ConvertTraceToJson(trace, [], logs); // Assert - verify both spans and logs are in the output var data = JsonSerializer.Deserialize(json, OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); @@ -911,7 +1043,7 @@ public void ConvertTraceToJson_ReturnsValidOtlpTelemetryDataJson() var trace = repository.GetTraces(GetTracesRequest.ForResourceKey(repository.GetResources()[0].ResourceKey)).PagedResult.Items[0]; // Act - var json = TelemetryExportService.ConvertTraceToJson(trace); + var json = TelemetryExportService.ConvertTraceToJson(trace, []); // Assert - deserialize back to verify OtlpTelemetryDataJson structure var data = JsonSerializer.Deserialize(json, OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); @@ -965,7 +1097,7 @@ private static async Task CreateExportServiceAsync(Telem var consoleLogsManager = new ConsoleLogsManager(sessionStorage); await consoleLogsManager.EnsureInitializedAsync(); var consoleLogsFetcher = new ConsoleLogsFetcher(dashboardClient, consoleLogsManager); - return new TelemetryExportService(repository, consoleLogsFetcher, dashboardClient); + return new TelemetryExportService(repository, consoleLogsFetcher, dashboardClient, Array.Empty()); } private static Dictionary> BuildAllResourcesSelection(TelemetryRepository repository) diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs index eb26ae7ebdb..5059a958c36 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.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.Globalization; using System.IO.Compression; using System.Text; using System.Text.Json; @@ -313,6 +314,67 @@ public async Task ImportAsync_RoundTrip_LogsExportAndImport_PreservesData() Assert.Equal(Microsoft.Extensions.Logging.LogLevel.Warning, importedLogs.Items[0].Severity); } + [Fact] + public async Task ImportAsync_RoundTrip_LogsWithAspireLogId_FiltersOutAttribute() + { + // Arrange - Export logs (which adds aspire.log_id attribute) + var sourceRepository = CreateRepository(); + var addContext = new AddContext(); + + sourceRepository.AddLogs(addContext, new RepeatedField() + { + new ResourceLogs + { + Resource = CreateResource(name: "TestService", instanceId: "test-1"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = { CreateLogRecord(time: s_testTime, message: "Test message", attributes: [new KeyValuePair("custom.attr", "custom-value")]) } + } + } + } + }); + + var resources = sourceRepository.GetResources(); + var logs = sourceRepository.GetLogs(GetLogsContext.ForResourceKey(resources[0].ResourceKey)); + var originalInternalId = logs.Items[0].InternalId; + + // Export adds aspire.log_id attribute + var exportedJson = TelemetryExportService.ConvertLogsToOtlpJson(logs.Items); + + // Verify aspire.log_id was added during export + var exportedLogRecord = exportedJson.ResourceLogs![0].ScopeLogs![0].LogRecords![0]; + Assert.Contains(exportedLogRecord.Attributes!, a => a.Key == OtlpHelpers.AspireLogIdAttribute && a.Value?.StringValue == originalInternalId.ToString(CultureInfo.InvariantCulture)); + + var jsonString = JsonSerializer.Serialize(exportedJson, OtlpJsonSerializerContext.DefaultOptions); + + // Import + var targetRepository = CreateRepository(); + var importService = CreateImportService(targetRepository); + var stream = new MemoryStream(Encoding.UTF8.GetBytes(jsonString)); + + // Act + await importService.ImportAsync("logs.json", stream, CancellationToken.None); + + // Assert + var importedResources = targetRepository.GetResources(); + Assert.Single(importedResources); + + var importedLogs = targetRepository.GetLogs(GetLogsContext.ForResourceKey(importedResources[0].ResourceKey)); + Assert.Single(importedLogs.Items); + + // Verify aspire.log_id is NOT in the imported log's attributes (it should be filtered out) + Assert.DoesNotContain(importedLogs.Items[0].Attributes, a => a.Key == OtlpHelpers.AspireLogIdAttribute); + + // Verify the imported log gets a new InternalId (not the original one) + Assert.NotEqual(originalInternalId, importedLogs.Items[0].InternalId); + + // Verify other attributes are preserved + Assert.Contains(importedLogs.Items[0].Attributes, a => a.Key == "custom.attr" && a.Value == "custom-value"); + } + [Fact] public async Task ImportAsync_RoundTrip_TracesExportAndImport_PreservesData() { @@ -340,7 +402,7 @@ public async Task ImportAsync_RoundTrip_TracesExportAndImport_PreservesData() var traces = sourceRepository.GetTraces(GetTracesRequest.ForResourceKey(resources[0].ResourceKey)); // Export - var exportedJson = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items); + var exportedJson = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, []); var jsonString = JsonSerializer.Serialize(exportedJson, OtlpJsonSerializerContext.DefaultOptions); // Import diff --git a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs index 69e505bb5ce..08be66566a8 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Api; +using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; using Google.Protobuf.Collections; using OpenTelemetry.Proto.Logs.V1; using OpenTelemetry.Proto.Trace.V1; @@ -44,7 +46,7 @@ public async Task FollowSpansAsync_StreamsAllSpans() }); } - var service = new TelemetryApiService(repository); + var service = CreateService(repository); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); // Act - stream spans @@ -91,7 +93,7 @@ public async Task FollowLogsAsync_StreamsAllLogs() }); } - var service = new TelemetryApiService(repository); + var service = CreateService(repository); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); // Act - stream logs @@ -136,7 +138,7 @@ public void GetSpans_HasErrorFalse_ExcludesErrorSpans() } }); - var service = new TelemetryApiService(repository); + var service = CreateService(repository); // Act - get spans with hasError=false var result = service.GetSpans(resourceNames: null, traceId: null, hasError: false, limit: null); @@ -178,7 +180,7 @@ public void GetSpans_HasErrorTrue_OnlyReturnsErrorSpans() } }); - var service = new TelemetryApiService(repository); + var service = CreateService(repository); // Act - get spans with hasError=true var result = service.GetSpans(resourceNames: null, traceId: null, hasError: true, limit: null); @@ -237,7 +239,7 @@ public void GetTraces_HasErrorFalse_ExcludesTracesWithErrors() } }); - var service = new TelemetryApiService(repository); + var service = CreateService(repository); // Act - get traces with hasError=false (no error, should exclude the error trace) var result = service.GetTraces(resourceNames: null, hasError: false, limit: null); @@ -297,7 +299,7 @@ public void GetTraces_HasErrorTrue_OnlyReturnsTracesWithErrors() } }); - var service = new TelemetryApiService(repository); + var service = CreateService(repository); // Act - get traces with hasError=true (error only) var result = service.GetTraces(resourceNames: null, hasError: true, limit: null); @@ -338,7 +340,7 @@ public async Task FollowSpansAsync_WithInvalidResourceName_ReturnsNoSpans() } }); - var service = new TelemetryApiService(repository); + var service = CreateService(repository); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); // Act - stream spans for a non-existent resource @@ -385,7 +387,7 @@ public async Task FollowLogsAsync_WithInvalidResourceName_ReturnsNoLogs() } }); - var service = new TelemetryApiService(repository); + var service = CreateService(repository); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); // Act - stream logs for a non-existent resource @@ -405,4 +407,16 @@ public async Task FollowLogsAsync_WithInvalidResourceName_ReturnsNoLogs() // Assert - should receive NO items because the resource doesn't exist Assert.Empty(receivedItems); } + + /// + /// Creates a TelemetryApiService instance for testing with optional custom dependencies. + /// + private static TelemetryApiService CreateService( + TelemetryRepository? repository = null, + IOutgoingPeerResolver[]? peerResolvers = null) + { + return new TelemetryApiService( + repository ?? CreateRepository(), + peerResolvers ?? []); + } } diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs index ec80ac7b521..8d01fd21581 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs @@ -138,8 +138,8 @@ public void GetResourceName_GuidInstanceId_Shorten() // Act var resources = repository.GetResources(); - var instance1Name = OtlpResource.GetResourceName(resources[0], resources); - var instance2Name = OtlpResource.GetResourceName(resources[1], resources); + var instance1Name = OtlpHelpers.GetResourceName(resources[0], resources); + var instance2Name = OtlpHelpers.GetResourceName(resources[1], resources); // Assert Assert.Equal("app1-19572b19", instance1Name); diff --git a/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs b/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs index 65affd93b20..bc7a802c394 100644 --- a/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs +++ b/tests/Aspire.Hosting.Tests/ExecutionConfigurationGathererTests.cs @@ -538,8 +538,6 @@ public async Task HttpsCertificateExecutionConfigurationGatherer_WithCallback_Ex #endregion - #region Helper Methods - private static X509Certificate2 CreateTestCertificate() { using var rsa = RSA.Create(2048); @@ -589,6 +587,4 @@ public TestDeveloperCertificateService(X509Certificate2? certificate = null) public bool UseForHttps => true; } - - #endregion } From 5805c562b9b85fd603888e09132f5a74d5cab4bf Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 5 Feb 2026 10:08:15 -0600 Subject: [PATCH 046/256] Add initial Aspire.Hosting.Azure.Network integration (#13108) * Add initial Aspire.Hosting.Azure.Network integration This is the first round of supporting Azure virtual networks in Aspire. Currently it supports: Creating a virtual network Creating subnets Creating private endpoints to Storage blobs and queues Future PRs will enable: More private endpoint support. This is just the core / first resource. Being able to add NAT Gateways and public IPs. This pull request introduces a new Azure Virtual Network end-to-end sample to the repository, demonstrating how to configure a virtual network, subnets, private endpoints, and secure storage access for containerized applications. It also adds support for Azure Network and Private DNS provisioning packages, and makes minor improvements to existing storage modules. * Get some stuff working * Don't add VNet resources in run mode. Add a delegation annotation so things like ACA can delegate subnets. * Initial support for private endpoints. * Add Azure.Provisioning.PrivateDns and update to latest * Remove unncessary workaround * Finish implementing private endpoints. Gets AzureStorageEndToEnd working with a vnet and private endpoints. * Make new Aspire.Hosting.Azure APIs experimental * Add unit tests. Remove NatGateway (for now). This will come in a separate PR. Clean up some code. * More clean up * Move to a new playground app specific to VNets * Fix AddSubnet API * Rename AddAzurePrivateEndpoint and hang it off of AzureSubnetResource. * Don't add PrivateEndpointTargetAnnotation in run mode. Also walk the hierarchy until you reach the root resource. * Add WithSubnet API for ACA environments and improve delegation naming - Add IAzureDelegatedSubnetResource interface and DelegatedSubnetAnnotation to Aspire.Hosting.Azure - Implement IAzureDelegatedSubnetResource on AzureContainerAppEnvironmentResource - Add WithSubnet extension method in Network package for configuring subnet delegation - Update ACA Bicep generation to read DelegatedSubnetAnnotation and configure VnetConfiguration - Use serviceName for delegation name in Bicep output (e.g., 'Microsoft.App/environments') - Mark new public APIs with [Experimental("ASPIREAZURE003")] - Add unit test verifying ACA and VNet Bicep output with subnet delegation * Separate and reuse Private DNS Zones across private endpoints - Add AzurePrivateDnsZoneResource for shared DNS Zone management - Add AzurePrivateDnsZoneVNetLinkResource for VNet-to-zone linking - DNS Zones are cached at builder level and reused per zone name - VNet Links tracked on DNS Zone to avoid duplicates - PE Bicep now references existing DNS Zone instead of creating inline - Add tests for DNS Zone reuse scenarios - Add InternalsVisibleTo for test access to internal types * Address PR feedback --- Aspire.slnx | 5 + Directory.Packages.props | 2 + .../storage.module.bicep | 7 +- .../storage2.module.bicep | 7 +- ...reVirtualNetworkEndToEnd.ApiService.csproj | 15 ++ .../Program.cs | 43 ++++ .../Properties/launchSettings.json | 14 ++ .../appsettings.json | 9 + ...AzureVirtualNetworkEndToEnd.AppHost.csproj | 22 ++ .../Program.cs | 40 ++++ .../Properties/launchSettings.json | 46 ++++ .../api-containerapp.module.bicep | 113 ++++++++++ .../api-identity.module.bicep | 17 ++ .../api-roles-storage.module.bicep | 40 ++++ .../appsettings.json | 12 + .../aspire-manifest.json | 139 ++++++++++++ .../env-acr.module.bicep | 17 ++ .../env.module.bicep | 89 ++++++++ .../private-endpoints-blobs-pe.module.bicep | 55 +++++ .../private-endpoints-queues-pe.module.bicep | 55 +++++ ...atelink-blob-core-windows-net.module.bicep | 31 +++ ...telink-queue-core-windows-net.module.bicep | 31 +++ .../storage.module.bicep | 56 +++++ .../vnet.module.bicep | 52 +++++ .../AzureContainerAppEnvironmentResource.cs | 6 +- .../AzureContainerAppExtensions.cs | 11 + .../Aspire.Hosting.Azure.Network.csproj | 23 ++ .../AzurePrivateDnsZoneResource.cs | 117 ++++++++++ .../AzurePrivateDnsZoneVNetLinkResource.cs | 25 +++ .../AzurePrivateEndpointExtensions.cs | 194 ++++++++++++++++ .../AzurePrivateEndpointResource.cs | 76 +++++++ .../AzureSubnetResource.cs | 82 +++++++ .../AzureSubnetServiceDelegationAnnotation.cs | 24 ++ .../AzureVirtualNetworkExtensions.cs | 186 +++++++++++++++ .../AzureVirtualNetworkResource.cs | 57 +++++ src/Aspire.Hosting.Azure.Network/README.md | 105 +++++++++ .../AzureBlobStorageResource.cs | 11 +- .../AzureQueueStorageResource.cs | 11 +- .../AzureStorageExtensions.cs | 55 +++-- .../AzureStorageResource.cs | 5 + .../DelegatedSubnetAnnotation.cs | 20 ++ .../IAzureDelegatedSubnetResource.cs | 19 ++ .../IAzurePrivateEndpointTarget.cs | 31 +++ .../PrivateEndpointTargetAnnotation.cs | 16 ++ .../Aspire.Hosting.Azure.Tests.csproj | 1 + ...eContainerAppEnvironmentExtensionsTests.cs | 19 ++ .../AzurePrivateEndpointExtensionsTests.cs | 211 ++++++++++++++++++ ...zureStoragePrivateEndpointLockdownTests.cs | 54 +++++ .../AzureVirtualNetworkExtensionsTests.cs | 129 +++++++++++ ...guresVnetConfiguration#vnet.verified.bicep | 39 ++++ ...ConfiguresVnetConfiguration.verified.bicep | 89 ++++++++ ...tsCorrectly_WithSnapshot#01.verified.bicep | 4 +- ...nt_ForQueues_GeneratesBicep.verified.bicep | 55 +++++ ...vateEndpoint_GeneratesBicep.verified.bicep | 55 +++++ ...DnsZone_ForSameZoneName#pe1.verified.bicep | 55 +++++ ...DnsZone_ForSameZoneName#pe2.verified.bicep | 55 +++++ ...usesDnsZone_ForSameZoneName.verified.bicep | 31 +++ ...dAzureStorageViaPublishMode.verified.bicep | 2 + ...AccessOverridesDefaultFalse.verified.bicep | 2 + ...s.AddAzureStorageViaRunMode.verified.bicep | 2 + ...AccessOverridesDefaultFalse.verified.bicep | 2 + ...sts.ResourceNamesBicepValid.verified.bicep | 2 + ...point_GeneratesCorrectBicep.verified.bicep | 36 +++ ..._WithSubnets_GeneratesBicep.verified.bicep | 52 +++++ ...ageAccountWithResourceGroup.verified.bicep | 4 +- ...urceGroupAndStaticArguments.verified.bicep | 2 + 66 files changed, 2867 insertions(+), 25 deletions(-) create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/AzureVirtualNetworkEndToEnd.ApiService.csproj create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Program.cs create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Properties/launchSettings.json create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/appsettings.json create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/AzureVirtualNetworkEndToEnd.AppHost.csproj create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Properties/launchSettings.json create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-containerapp.module.bicep create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-identity.module.bicep create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-roles-storage.module.bicep create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/appsettings.json create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env-acr.module.bicep create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env.module.bicep create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-blob-core-windows-net.module.bicep create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-queue-core-windows-net.module.bicep create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/storage.module.bicep create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep create mode 100644 src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj create mode 100644 src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneResource.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneVNetLinkResource.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs create mode 100644 src/Aspire.Hosting.Azure.Network/README.md create mode 100644 src/Aspire.Hosting.Azure/DelegatedSubnetAnnotation.cs create mode 100644 src/Aspire.Hosting.Azure/IAzureDelegatedSubnetResource.cs create mode 100644 src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs create mode 100644 src/Aspire.Hosting.Azure/PrivateEndpointTargetAnnotation.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_GeneratesBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe1.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe2.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddAzureVirtualNetwork_WithSubnets_GeneratesBicep.verified.bicep diff --git a/Aspire.slnx b/Aspire.slnx index 316fc691811..c89c9907d9e 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -91,6 +91,7 @@ + @@ -159,6 +160,10 @@ + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 5c01540e93c..36db9b75c0e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -48,8 +48,10 @@ + + diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep index 354128b8f35..a6bbd75eee9 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage.module.bicep @@ -11,6 +11,7 @@ resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { properties: { accessTier: 'Hot' allowSharedKeyAccess: false + isHnsEnabled: false minimumTlsVersion: 'TLS1_2' networkAcls: { defaultAction: 'Allow' @@ -48,8 +49,12 @@ resource myqueue 'Microsoft.Storage/storageAccounts/queueServices/queues@2024-01 output blobEndpoint string = storage.properties.primaryEndpoints.blob +output dataLakeEndpoint string = storage.properties.primaryEndpoints.dfs + output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table -output name string = storage.name \ No newline at end of file +output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage2.module.bicep b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage2.module.bicep index 32838ffeb1c..42f3049b006 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage2.module.bicep +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/storage2.module.bicep @@ -11,6 +11,7 @@ resource storage2 'Microsoft.Storage/storageAccounts@2024-01-01' = { properties: { accessTier: 'Hot' allowSharedKeyAccess: false + isHnsEnabled: false minimumTlsVersion: 'TLS1_2' networkAcls: { defaultAction: 'Allow' @@ -33,8 +34,12 @@ resource foocontainer 'Microsoft.Storage/storageAccounts/blobServices/containers output blobEndpoint string = storage2.properties.primaryEndpoints.blob +output dataLakeEndpoint string = storage2.properties.primaryEndpoints.dfs + output queueEndpoint string = storage2.properties.primaryEndpoints.queue output tableEndpoint string = storage2.properties.primaryEndpoints.table -output name string = storage2.name \ No newline at end of file +output name string = storage2.name + +output id string = storage2.id \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/AzureVirtualNetworkEndToEnd.ApiService.csproj b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/AzureVirtualNetworkEndToEnd.ApiService.csproj new file mode 100644 index 00000000000..66e0c13e3e3 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/AzureVirtualNetworkEndToEnd.ApiService.csproj @@ -0,0 +1,15 @@ + + + + $(DefaultTargetFramework) + enable + enable + + + + + + + + + diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Program.cs b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Program.cs new file mode 100644 index 00000000000..9c42c6603c2 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Program.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 Azure.Storage.Blobs; +using Azure.Storage.Queues; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddAzureBlobContainerClient("mycontainer"); + +builder.AddKeyedAzureQueue("myqueue"); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +app.MapGet("/", async (BlobContainerClient containerClient, [FromKeyedServices("myqueue")] QueueClient queue) => +{ + var blobNames = new List(); + var blobNameAndContent = Guid.NewGuid().ToString(); + + await containerClient.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); + + await ReadBlobsAsync(containerClient, blobNames); + + await queue.SendMessageAsync("Hello, world!"); + + return blobNames; +}); + +app.Run(); + +static async Task ReadBlobsAsync(BlobContainerClient containerClient, List output) +{ + output.Add(containerClient.Uri.ToString()); + var blobs = containerClient.GetBlobsAsync(); + await foreach (var blob in blobs) + { + output.Add(blob.Name); + } +} diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Properties/launchSettings.json b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Properties/launchSettings.json new file mode 100644 index 00000000000..de23e4696cf --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5193", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/appsettings.json b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/appsettings.json new file mode 100644 index 00000000000..10f68b8c8b4 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/AzureVirtualNetworkEndToEnd.AppHost.csproj b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/AzureVirtualNetworkEndToEnd.AppHost.csproj new file mode 100644 index 00000000000..5b4082c956a --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/AzureVirtualNetworkEndToEnd.AppHost.csproj @@ -0,0 +1,22 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + d3e0c7a8-1f5b-4c2d-8e9a-6b7c8d9e0f1a + + + + + + + + + + + + + diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs new file mode 100644 index 00000000000..1eda44bb945 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +var builder = DistributedApplication.CreateBuilder(args); + +// Create a virtual network with two subnets: +// - One for the Container App Environment (with service delegation) +// - One for private endpoints +var vnet = builder.AddAzureVirtualNetwork("vnet"); + +var containerAppsSubnet = vnet.AddSubnet("container-apps", "10.0.0.0/23"); +var privateEndpointsSubnet = vnet.AddSubnet("private-endpoints", "10.0.2.0/27"); + +// Configure the Container App Environment to use the VNet +builder.AddAzureContainerAppEnvironment("env") + .WithDelegatedSubnet(containerAppsSubnet); + +var storage = builder.AddAzureStorage("storage").RunAsEmulator(); + +var blobs = storage.AddBlobs("blobs"); +var mycontainer = storage.AddBlobContainer("mycontainer"); + +var queues = storage.AddQueues("queues"); +var myqueue = storage.AddQueue("myqueue"); + +// Add private endpoints for blob and queue storage +// This automatically: +// - Creates Private DNS Zones for each service +// - Links the DNS zones to the VNet +// - Creates the Private Endpoints +// - Locks down public access to the storage account +privateEndpointsSubnet.AddPrivateEndpoint(blobs); +privateEndpointsSubnet.AddPrivateEndpoint(queues); + +builder.AddProject("api") + .WithExternalHttpEndpoints() + .WithReference(mycontainer).WaitFor(mycontainer) + .WithReference(myqueue).WaitFor(myqueue); + +builder.Build().Run(); diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Properties/launchSettings.json b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..457e26b8af3 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Properties/launchSettings.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:16129;http://localhost:16130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17049", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:18026", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17049", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:16130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17050", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18027", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17050", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + }, + "generate-manifest": { + "commandName": "Project", + "launchBrowser": true, + "dotnetRunMessages": true, + "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json", + "applicationUrl": "http://localhost:16130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17050" + } + } + } +} diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-containerapp.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-containerapp.module.bicep new file mode 100644 index 00000000000..775868cc85d --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-containerapp.module.bicep @@ -0,0 +1,113 @@ +@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 api_containerimage string + +param api_identity_outputs_id string + +param api_containerport string + +param storage_outputs_blobendpoint string + +param storage_outputs_queueendpoint string + +param api_identity_outputs_clientid string + +param env_outputs_azure_container_registry_endpoint string + +param env_outputs_azure_container_registry_managed_identity_id string + +resource api 'Microsoft.App/containerApps@2025-02-02-preview' = { + name: 'api' + location: location + properties: { + configuration: { + activeRevisionsMode: 'Single' + ingress: { + external: true + targetPort: int(api_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: api_containerimage + name: 'api' + env: [ + { + name: 'OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY' + value: 'in_memory' + } + { + name: 'ASPNETCORE_FORWARDEDHEADERS_ENABLED' + value: 'true' + } + { + name: 'HTTP_PORTS' + value: api_containerport + } + { + name: 'ConnectionStrings__mycontainer' + value: 'Endpoint=${storage_outputs_blobendpoint};ContainerName=mycontainer' + } + { + name: 'MYCONTAINER_URI' + value: storage_outputs_blobendpoint + } + { + name: 'MYCONTAINER_BLOBCONTAINERNAME' + value: 'mycontainer' + } + { + name: 'ConnectionStrings__myqueue' + value: 'Endpoint=${storage_outputs_queueendpoint};QueueName=myqueue' + } + { + name: 'MYQUEUE_URI' + value: storage_outputs_queueendpoint + } + { + name: 'MYQUEUE_QUEUENAME' + value: 'myqueue' + } + { + name: 'AZURE_CLIENT_ID' + value: api_identity_outputs_clientid + } + { + name: 'AZURE_TOKEN_CREDENTIALS' + value: 'ManagedIdentityCredential' + } + ] + } + ] + scale: { + minReplicas: 1 + } + } + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${api_identity_outputs_id}': { } + '${env_outputs_azure_container_registry_managed_identity_id}': { } + } + } +} \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-identity.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-identity.module.bicep new file mode 100644 index 00000000000..e2d7908d230 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-identity.module.bicep @@ -0,0 +1,17 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource api_identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('api_identity-${uniqueString(resourceGroup().id)}', 128) + location: location +} + +output id string = api_identity.id + +output clientId string = api_identity.properties.clientId + +output principalId string = api_identity.properties.principalId + +output principalName string = api_identity.name + +output name string = api_identity.name \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-roles-storage.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-roles-storage.module.bicep new file mode 100644 index 00000000000..b3f1171c933 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/api-roles-storage.module.bicep @@ -0,0 +1,40 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param storage_outputs_name string + +param principalId string + +resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { + name: storage_outputs_name +} + +resource storage_StorageBlobDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') + principalType: 'ServicePrincipal' + } + scope: storage +} + +resource storage_StorageTableDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3') + principalType: 'ServicePrincipal' + } + scope: storage +} + +resource storage_StorageQueueDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storage.id, principalId, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88')) + properties: { + principalId: principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88') + principalType: 'ServicePrincipal' + } + scope: storage +} \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/appsettings.json b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/appsettings.json new file mode 100644 index 00000000000..39ba62a688d --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + }, + "Parameters": { + "insertionrows": "1" + } +} diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json new file mode 100644 index 00000000000..2c8a2db9647 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json @@ -0,0 +1,139 @@ +{ + "$schema": "https://json.schemastore.org/aspire-8.0.json", + "resources": { + "vnet": { + "type": "azure.bicep.v0", + "path": "vnet.module.bicep" + }, + "env-acr": { + "type": "azure.bicep.v0", + "path": "env-acr.module.bicep" + }, + "env": { + "type": "azure.bicep.v0", + "path": "env.module.bicep", + "params": { + "env_acr_outputs_name": "{env-acr.outputs.name}", + "vnet_outputs_container_apps_id": "{vnet.outputs.container_apps_Id}", + "userPrincipalId": "" + } + }, + "storage": { + "type": "azure.bicep.v0", + "path": "storage.module.bicep" + }, + "blobs": { + "type": "value.v0", + "connectionString": "{storage.outputs.blobEndpoint}" + }, + "storage-blobs": { + "type": "value.v0", + "connectionString": "{storage.outputs.blobEndpoint}" + }, + "mycontainer": { + "type": "value.v0", + "connectionString": "Endpoint={storage.outputs.blobEndpoint};ContainerName=mycontainer" + }, + "queues": { + "type": "value.v0", + "connectionString": "{storage.outputs.queueEndpoint}" + }, + "storage-queues": { + "type": "value.v0", + "connectionString": "{storage.outputs.queueEndpoint}" + }, + "myqueue": { + "type": "value.v0", + "connectionString": "Endpoint={storage.outputs.queueEndpoint};QueueName=myqueue" + }, + "privatelink-blob-core-windows-net": { + "type": "azure.bicep.v0", + "path": "privatelink-blob-core-windows-net.module.bicep", + "params": { + "vnet_outputs_id": "{vnet.outputs.id}" + } + }, + "private-endpoints-blobs-pe": { + "type": "azure.bicep.v0", + "path": "private-endpoints-blobs-pe.module.bicep", + "params": { + "privatelink_blob_core_windows_net_outputs_name": "{privatelink-blob-core-windows-net.outputs.name}", + "vnet_outputs_private_endpoints_id": "{vnet.outputs.private_endpoints_Id}", + "storage_outputs_id": "{storage.outputs.id}" + } + }, + "privatelink-queue-core-windows-net": { + "type": "azure.bicep.v0", + "path": "privatelink-queue-core-windows-net.module.bicep", + "params": { + "vnet_outputs_id": "{vnet.outputs.id}" + } + }, + "private-endpoints-queues-pe": { + "type": "azure.bicep.v0", + "path": "private-endpoints-queues-pe.module.bicep", + "params": { + "privatelink_queue_core_windows_net_outputs_name": "{privatelink-queue-core-windows-net.outputs.name}", + "vnet_outputs_private_endpoints_id": "{vnet.outputs.private_endpoints_Id}", + "storage_outputs_id": "{storage.outputs.id}" + } + }, + "api": { + "type": "project.v1", + "path": "../AzureVirtualNetworkEndToEnd.ApiService/AzureVirtualNetworkEndToEnd.ApiService.csproj", + "deployment": { + "type": "azure.bicep.v0", + "path": "api-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}", + "api_containerimage": "{api.containerImage}", + "api_identity_outputs_id": "{api-identity.outputs.id}", + "api_containerport": "{api.containerPort}", + "storage_outputs_blobendpoint": "{storage.outputs.blobEndpoint}", + "storage_outputs_queueendpoint": "{storage.outputs.queueEndpoint}", + "api_identity_outputs_clientid": "{api-identity.outputs.clientId}" + } + }, + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory", + "ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true", + "HTTP_PORTS": "{api.bindings.http.targetPort}", + "ConnectionStrings__mycontainer": "{mycontainer.connectionString}", + "MYCONTAINER_URI": "{storage.outputs.blobEndpoint}", + "MYCONTAINER_BLOBCONTAINERNAME": "mycontainer", + "ConnectionStrings__myqueue": "{myqueue.connectionString}", + "MYQUEUE_URI": "{storage.outputs.queueEndpoint}", + "MYQUEUE_QUEUENAME": "myqueue" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http", + "external": true + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http", + "external": true + } + } + }, + "api-identity": { + "type": "azure.bicep.v0", + "path": "api-identity.module.bicep" + }, + "api-roles-storage": { + "type": "azure.bicep.v0", + "path": "api-roles-storage.module.bicep", + "params": { + "storage_outputs_name": "{storage.outputs.name}", + "principalId": "{api-identity.outputs.principalId}" + } + } + } +} \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env-acr.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env-acr.module.bicep new file mode 100644 index 00000000000..e2bf0b77f72 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env-acr.module.bicep @@ -0,0 +1,17 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' = { + name: take('envacr${uniqueString(resourceGroup().id)}', 50) + location: location + sku: { + name: 'Basic' + } + tags: { + 'aspire-resource-name': 'env-acr' + } +} + +output name string = env_acr.name + +output loginServer string = env_acr.properties.loginServer \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env.module.bicep new file mode 100644 index 00000000000..cc7b2008ed2 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/env.module.bicep @@ -0,0 +1,89 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param env_acr_outputs_name string + +param vnet_outputs_container_apps_id string + +resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: env_acr_outputs_name +} + +resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: env_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: env_acr +} + +resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('env${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: env_law.properties.customerId + sharedKey: env_law.listKeys().primarySharedKey + } + } + vnetConfiguration: { + infrastructureSubnetId: vnet_outputs_container_apps_id + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: env +} + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = env_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = env_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep new file mode 100644 index 00000000000..18c7dfa25f5 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-blobs-pe.module.bicep @@ -0,0 +1,55 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_blob_core_windows_net_outputs_name string + +param vnet_outputs_private_endpoints_id string + +param storage_outputs_id string + +resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_blob_core_windows_net_outputs_name +} + +resource private_endpoints_blobs_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('private_endpoints_blobs_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: storage_outputs_id + groupIds: [ + 'blob' + ] + } + name: 'private-endpoints-blobs-pe-connection' + } + ] + subnet: { + id: vnet_outputs_private_endpoints_id + } + } + tags: { + 'aspire-resource-name': 'private-endpoints-blobs-pe' + } +} + +resource private_endpoints_blobs_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_blob_core_windows_net' + properties: { + privateDnsZoneId: privatelink_blob_core_windows_net.id + } + } + ] + } + parent: private_endpoints_blobs_pe +} + +output id string = private_endpoints_blobs_pe.id + +output name string = private_endpoints_blobs_pe.name \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep new file mode 100644 index 00000000000..a05692409d5 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-queues-pe.module.bicep @@ -0,0 +1,55 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_queue_core_windows_net_outputs_name string + +param vnet_outputs_private_endpoints_id string + +param storage_outputs_id string + +resource privatelink_queue_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_queue_core_windows_net_outputs_name +} + +resource private_endpoints_queues_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('private_endpoints_queues_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: storage_outputs_id + groupIds: [ + 'queue' + ] + } + name: 'private-endpoints-queues-pe-connection' + } + ] + subnet: { + id: vnet_outputs_private_endpoints_id + } + } + tags: { + 'aspire-resource-name': 'private-endpoints-queues-pe' + } +} + +resource private_endpoints_queues_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_queue_core_windows_net' + properties: { + privateDnsZoneId: privatelink_queue_core_windows_net.id + } + } + ] + } + parent: private_endpoints_queues_pe +} + +output id string = private_endpoints_queues_pe.id + +output name string = private_endpoints_queues_pe.name \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-blob-core-windows-net.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-blob-core-windows-net.module.bicep new file mode 100644 index 00000000000..fc0adaad306 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-blob-core-windows-net.module.bicep @@ -0,0 +1,31 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param vnet_outputs_id string + +resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.blob.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-blob-core-windows-net' + } +} + +resource vnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'vnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: vnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-blob-core-windows-net-vnet-link' + } + parent: privatelink_blob_core_windows_net +} + +output id string = privatelink_blob_core_windows_net.id + +output name string = 'privatelink.blob.core.windows.net' \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-queue-core-windows-net.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-queue-core-windows-net.module.bicep new file mode 100644 index 00000000000..60e104c75e1 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/privatelink-queue-core-windows-net.module.bicep @@ -0,0 +1,31 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param vnet_outputs_id string + +resource privatelink_queue_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.queue.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-queue-core-windows-net' + } +} + +resource vnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'vnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: vnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-queue-core-windows-net-vnet-link' + } + parent: privatelink_queue_core_windows_net +} + +output id string = privatelink_queue_core_windows_net.id + +output name string = 'privatelink.queue.core.windows.net' \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/storage.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/storage.module.bicep new file mode 100644 index 00000000000..49e8b890132 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/storage.module.bicep @@ -0,0 +1,56 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('storage${uniqueString(resourceGroup().id)}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_GRS' + } + properties: { + accessTier: 'Hot' + allowSharedKeyAccess: false + isHnsEnabled: false + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Deny' + } + publicNetworkAccess: 'Disabled' + } + tags: { + 'aspire-resource-name': 'storage' + } +} + +resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { + name: 'default' + parent: storage +} + +resource mycontainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = { + name: 'mycontainer' + parent: blobs +} + +resource queues 'Microsoft.Storage/storageAccounts/queueServices@2024-01-01' = { + name: 'default' + parent: storage +} + +resource myqueue 'Microsoft.Storage/storageAccounts/queueServices/queues@2024-01-01' = { + name: 'myqueue' + parent: queues +} + +output blobEndpoint string = storage.properties.primaryEndpoints.blob + +output dataLakeEndpoint string = storage.properties.primaryEndpoints.dfs + +output queueEndpoint string = storage.properties.primaryEndpoints.queue + +output tableEndpoint string = storage.properties.primaryEndpoints.table + +output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep new file mode 100644 index 00000000000..10c4439e46a --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep @@ -0,0 +1,52 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource vnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('vnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'vnet' + } +} + +resource container_apps 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'container-apps' + properties: { + addressPrefix: '10.0.0.0/23' + delegations: [ + { + properties: { + serviceName: 'Microsoft.App/environments' + } + name: 'Microsoft.App/environments' + } + ] + } + parent: vnet +} + +resource private_endpoints 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'private-endpoints' + properties: { + addressPrefix: '10.0.2.0/27' + } + parent: vnet + dependsOn: [ + container_apps + ] +} + +output container_apps_Id string = container_apps.id + +output private_endpoints_Id string = private_endpoints.id + +output id string = vnet.id + +output name string = vnet.name \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs index e3c48d0e18b..1ed6696850f 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIREPIPELINES001 #pragma warning disable ASPIREAZURE001 +#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.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; @@ -18,9 +19,12 @@ namespace Aspire.Hosting.Azure.AppContainers; /// #pragma warning disable CS0618 // Type or member is obsolete public class AzureContainerAppEnvironmentResource : - AzureProvisioningResource, IAzureComputeEnvironmentResource, IAzureContainerRegistry + AzureProvisioningResource, IAzureComputeEnvironmentResource, IAzureContainerRegistry, IAzureDelegatedSubnetResource #pragma warning restore CS0618 // Type or member is obsolete { + /// + string IAzureDelegatedSubnetResource.DelegatedSubnetServiceName => "Microsoft.App/environments"; + /// /// Initializes a new instance of the class. /// diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs index b000795d993..b86febc57ec 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.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.Diagnostics; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; @@ -151,6 +153,15 @@ public static IResourceBuilder AddAzureCon Tags = tags }; + // Configure VNet integration if a subnet is specified + if (appEnvResource.TryGetLastAnnotation(out var subnetAnnotation)) + { + containerAppEnvironment.VnetConfiguration = new ContainerAppVnetConfiguration + { + InfrastructureSubnetId = subnetAnnotation.SubnetId.AsProvisioningParameter(infra) + }; + } + infra.Add(containerAppEnvironment); if (appEnvResource.EnableDashboard) diff --git a/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj b/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj new file mode 100644 index 00000000000..485c5baba74 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/Aspire.Hosting.Azure.Network.csproj @@ -0,0 +1,23 @@ + + + + $(DefaultTargetFramework) + true + aspire integration hosting azure network vnet virtual-network subnet nat-gateway public-ip cloud + Azure Virtual Network resource types for Aspire. + $(SharedDir)Azure_256x.png + true + $(NoWarn);AZPROVISION001;ASPIREAZURE003 + + + + + + + + + + + + + diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneResource.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneResource.cs new file mode 100644 index 00000000000..c0eae36a973 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneResource.cs @@ -0,0 +1,117 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.Core; +using Azure.Provisioning; +using Azure.Provisioning.Primitives; +using Azure.Provisioning.PrivateDns; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure Private DNS Zone resource. +/// +internal sealed class AzurePrivateDnsZoneResource : AzureProvisioningResource +{ + /// + /// Initializes a new instance of . + /// + /// The Aspire resource name. + /// The DNS zone name (e.g., "privatelink.blob.core.windows.net"). + public AzurePrivateDnsZoneResource(string name, string zoneName) + : base(name, ConfigureDnsZone) + { + ZoneName = zoneName; + } + + /// + /// Gets the DNS zone name (e.g., "privatelink.blob.core.windows.net"). + /// + public string ZoneName { get; } + + /// + /// Gets the "id" output reference from the Private DNS Zone resource. + /// + public BicepOutputReference Id => new("id", this); + + /// + /// Gets the "name" output reference from the Private DNS Zone resource. + /// + public BicepOutputReference NameOutput => new("name", this); + + /// + /// Tracks VNet Links for this DNS Zone, keyed by VNet resource. + /// + internal Dictionary VNetLinks { get; } = []; + + /// + public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) + { + var bicepIdentifier = this.GetBicepIdentifier(); + var resources = infra.GetProvisionableResources(); + + // Check if a PrivateDnsZone with the same identifier already exists + var existingZone = resources.OfType().SingleOrDefault(z => z.BicepIdentifier == bicepIdentifier); + + if (existingZone is not null) + { + return existingZone; + } + + // Create and add new resource if it doesn't exist + var dnsZone = PrivateDnsZone.FromExisting(bicepIdentifier); + + if (!TryApplyExistingResourceAnnotation( + this, + infra, + dnsZone)) + { + dnsZone.Name = NameOutput.AsProvisioningParameter(infra); + } + + infra.Add(dnsZone); + return dnsZone; + } + + private static void ConfigureDnsZone(AzureResourceInfrastructure infra) + { + var resource = (AzurePrivateDnsZoneResource)infra.AspireResource; + + var dnsZone = new PrivateDnsZone(infra.AspireResource.GetBicepIdentifier()) + { + Name = resource.ZoneName, + Location = new AzureLocation("global"), + Tags = { { "aspire-resource-name", resource.Name } } + }; + infra.Add(dnsZone); + + // Create VNet Links for all linked VNets + foreach (var vnetLinkEntry in resource.VNetLinks) + { + var vnetLink = vnetLinkEntry.Value; + var linkIdentifier = Infrastructure.NormalizeBicepIdentifier($"{vnetLink.VNet.Name}_link"); + + var link = new VirtualNetworkLink(linkIdentifier) + { + Name = $"{vnetLink.VNet.Name}-link", + Parent = dnsZone, + Location = new AzureLocation("global"), + RegistrationEnabled = false, + VirtualNetworkId = vnetLink.VNet.Id.AsProvisioningParameter(infra), + Tags = { { "aspire-resource-name", vnetLink.Name } } + }; + infra.Add(link); + } + + // Output the DNS Zone ID for references + infra.Add(new ProvisioningOutput("id", typeof(string)) + { + Value = dnsZone.Id + }); + + infra.Add(new ProvisioningOutput("name", typeof(string)) + { + Value = dnsZone.Name + }); + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneVNetLinkResource.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneVNetLinkResource.cs new file mode 100644 index 00000000000..48bcfd391f6 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateDnsZoneVNetLinkResource.cs @@ -0,0 +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 Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure Private DNS Zone VNet Link resource. +/// +internal sealed class AzurePrivateDnsZoneVNetLinkResource( + string name, + AzurePrivateDnsZoneResource dnsZone, + AzureVirtualNetworkResource vnet) : Resource(name), IResourceWithParent +{ + /// + /// Gets the parent DNS Zone resource. + /// + public AzurePrivateDnsZoneResource Parent { get; } = dnsZone; + + /// + /// Gets the VNet resource linked to the DNS Zone. + /// + public AzureVirtualNetworkResource VNet { get; } = vnet; +} diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs new file mode 100644 index 00000000000..8ffe469db69 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointExtensions.cs @@ -0,0 +1,194 @@ +// 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; +using Aspire.Hosting.Azure; +using Azure.Provisioning; +using Azure.Provisioning.Network; +using Azure.Provisioning.PrivateDns; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Azure Private Endpoint resources to the application model. +/// +public static class AzurePrivateEndpointExtensions +{ + /// + /// Adds an Azure Private Endpoint resource to the subnet. + /// + /// The subnet to add the private endpoint to. + /// The target Azure resource to connect via private link. + /// A reference to the . + /// + /// + /// This method automatically creates the Private DNS Zone, VNet Link, and DNS Zone Group + /// required for private endpoint DNS resolution. Private DNS Zones are shared across + /// multiple private endpoints that use the same zone name. + /// + /// + /// When a private endpoint is added, the target resource (or its parent) is automatically + /// configured to deny public network access. To override this behavior, use + /// to customize + /// the network settings. + /// + /// + /// + /// This example creates a virtual network with a subnet and adds a private endpoint for Azure Storage blobs: + /// + /// var vnet = builder.AddAzureVirtualNetwork("vnet"); + /// var peSubnet = vnet.AddSubnet("pe-subnet", "10.0.1.0/24"); + /// + /// var storage = builder.AddAzureStorage("storage"); + /// var blobs = storage.AddBlobs("blobs"); + /// + /// peSubnet.AddPrivateEndpoint(blobs); + /// + /// + public static IResourceBuilder AddPrivateEndpoint( + this IResourceBuilder subnet, + IResourceBuilder target) + { + ArgumentNullException.ThrowIfNull(subnet); + ArgumentNullException.ThrowIfNull(target); + + var builder = subnet.ApplicationBuilder; + var name = $"{subnet.Resource.Name}-{target.Resource.Name}-pe"; + var vnet = subnet.Resource.Parent; + + var resource = new AzurePrivateEndpointResource(name, subnet.Resource, target.Resource, ConfigurePrivateEndpoint); + + if (builder.ExecutionContext.IsRunMode) + { + // In run mode, we don't want to add the resource to the builder. + return builder.CreateResourceBuilder(resource); + } + + // Get or create the shared Private DNS Zone for this zone name + var zoneName = target.Resource.GetPrivateDnsZoneName(); + var dnsZone = GetOrCreatePrivateDnsZone(builder, zoneName, vnet); + resource.DnsZone = dnsZone; + + // Add annotation to the target's root parent (e.g., storage account) to signal + // that it should deny public network access. + // This should only be done in publish mode. In run mode, the target resource + // needs to be accessible over the public internet so the local app can reach it. + IResource rootResource = target.Resource; + while (rootResource is IResourceWithParent parentedResource) + { + rootResource = parentedResource.Parent; + } + rootResource.Annotations.Add(new PrivateEndpointTargetAnnotation()); + + return builder.AddResource(resource); + + void ConfigurePrivateEndpoint(AzureResourceInfrastructure infra) + { + var azureResource = (AzurePrivateEndpointResource)infra.AspireResource; + + // Get the shared DNS Zone as an existing resource + var dnsZone = azureResource.DnsZone!; + var dnsZoneIdentifier = dnsZone.GetBicepIdentifier(); + var privateDnsZone = PrivateDnsZone.FromExisting(dnsZoneIdentifier); + privateDnsZone.Name = dnsZone.NameOutput.AsProvisioningParameter(infra); + infra.Add(privateDnsZone); + + // Create the Private Endpoint + var endpoint = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, + (identifier, name) => + { + var resource = PrivateEndpoint.FromExisting(identifier); + resource.Name = name; + return resource; + }, + (infrastructure) => + { + var pe = new PrivateEndpoint(infrastructure.AspireResource.GetBicepIdentifier()) + { + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + + // Configure subnet + pe.Subnet.Id = azureResource.Subnet.Id.AsProvisioningParameter(infrastructure); + + // Configure private link service connection + pe.PrivateLinkServiceConnections.Add( + new NetworkPrivateLinkServiceConnection + { + Name = $"{azureResource.Name}-connection", + PrivateLinkServiceId = azureResource.Target.Id.AsProvisioningParameter(infrastructure), + GroupIds = [.. azureResource.Target.GetPrivateLinkGroupIds()] + }); + + return pe; + }); + + // Create DNS Zone Group on the Private Endpoint + var dnsZoneGroupIdentifier = $"{endpoint.BicepIdentifier}_dnsgroup"; + var dnsZoneGroup = new PrivateDnsZoneGroup(dnsZoneGroupIdentifier) + { + Name = "default", + Parent = endpoint, + PrivateDnsZoneConfigs = + { + new PrivateDnsZoneConfig + { + Name = dnsZoneIdentifier, + PrivateDnsZoneId = privateDnsZone.Id + } + } + }; + infra.Add(dnsZoneGroup); + + // Output the Private Endpoint ID for references + infra.Add(new ProvisioningOutput("id", typeof(string)) + { + Value = endpoint.Id + }); + + // We need to output name so it can be referenced by others. + infra.Add(new ProvisioningOutput("name", typeof(string)) { Value = endpoint.Name }); + } + } + + /// + /// Gets or creates a shared Private DNS Zone for the given zone name and VNet. + /// + private static AzurePrivateDnsZoneResource GetOrCreatePrivateDnsZone( + IDistributedApplicationBuilder builder, + string zoneName, + AzureVirtualNetworkResource vnet) + { + // Search for existing DNS Zone with matching zone name + var existingZone = builder.Resources + .OfType() + .FirstOrDefault(z => z.ZoneName == zoneName); + + AzurePrivateDnsZoneResource dnsZone; + + if (existingZone is not null) + { + dnsZone = existingZone; + } + else + { + // Create new DNS Zone resource - use hyphens for resource name + var zoneResourceName = zoneName.Replace(".", "-"); + dnsZone = new AzurePrivateDnsZoneResource(zoneResourceName, zoneName); + builder.AddResource(dnsZone); + } + + // Check if VNet Link already exists for this VNet + if (!dnsZone.VNetLinks.ContainsKey(vnet)) + { + // Create VNet Link resource + var linkName = $"{dnsZone.Name}-{vnet.Name}-link"; + var vnetLink = new AzurePrivateDnsZoneVNetLinkResource(linkName, dnsZone, vnet); + dnsZone.VNetLinks[vnet] = vnetLink; + + builder.AddResource(vnetLink).ExcludeFromManifest(); + } + + return dnsZone; + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs new file mode 100644 index 00000000000..914ab0409f8 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzurePrivateEndpointResource.cs @@ -0,0 +1,76 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.Provisioning.Network; +using Azure.Provisioning.Primitives; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure Private Endpoint resource. +/// +/// The name of the resource. +/// The subnet where the private endpoint will be created. +/// The target Azure resource to connect via private link. +/// Callback to configure the Azure Private Endpoint resource. +public class AzurePrivateEndpointResource( + string name, + AzureSubnetResource subnet, + IAzurePrivateEndpointTarget target, + Action configureInfrastructure) + : AzureProvisioningResource(name, configureInfrastructure) +{ + /// + /// Gets the "id" output reference from the Azure Private Endpoint resource. + /// + public BicepOutputReference Id => new("id", this); + + /// + /// Gets the "name" output reference for the resource. + /// + public BicepOutputReference NameOutput => new("name", this); + + /// + /// Gets the subnet where the private endpoint will be created. + /// + public AzureSubnetResource Subnet { get; } = subnet; + + /// + /// Gets the target Azure resource to connect via private link. + /// + public IAzurePrivateEndpointTarget Target { get; } = target; + + /// + /// Gets or sets the Private DNS Zone for this endpoint. + /// + internal AzurePrivateDnsZoneResource? DnsZone { get; set; } + + /// + public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) + { + var bicepIdentifier = this.GetBicepIdentifier(); + var resources = infra.GetProvisionableResources(); + + // Check if a PrivateEndpoint with the same identifier already exists + var existingEndpoint = resources.OfType().SingleOrDefault(endpoint => endpoint.BicepIdentifier == bicepIdentifier); + + if (existingEndpoint is not null) + { + return existingEndpoint; + } + + // Create and add new resource if it doesn't exist + var endpoint = PrivateEndpoint.FromExisting(bicepIdentifier); + + if (!TryApplyExistingResourceAnnotation( + this, + infra, + endpoint)) + { + endpoint.Name = NameOutput.AsProvisioningParameter(infra); + } + + infra.Add(endpoint); + return endpoint; + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs new file mode 100644 index 00000000000..ae962a7d3cf --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs @@ -0,0 +1,82 @@ +// 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 System.Runtime.CompilerServices; +using Aspire.Hosting.ApplicationModel; +using Azure.Provisioning; +using Azure.Provisioning.Network; +using Azure.Provisioning.Primitives; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure Subnet resource. +/// +/// The name of the resource. +/// The subnet name. +/// The address prefix for the subnet. +/// The parent Virtual Network resource. +/// +/// Use to configure specific properties. +/// +public class AzureSubnetResource(string name, string subnetName, string addressPrefix, AzureVirtualNetworkResource parent) + : Resource(name), IResourceWithParent +{ + /// + /// Gets the subnet name. + /// + public string SubnetName { get; } = ThrowIfNullOrEmpty(subnetName); + + /// + /// Gets the address prefix for the subnet (e.g., "10.0.1.0/24"). + /// + public string AddressPrefix { get; } = ThrowIfNullOrEmpty(addressPrefix); + + /// + /// Gets the subnet Id output reference. + /// + public BicepOutputReference Id => new($"{Infrastructure.NormalizeBicepIdentifier(Name)}_Id", parent); + + /// + /// Gets the parent Azure Virtual Network resource. + /// + public AzureVirtualNetworkResource Parent { get; } = parent ?? throw new ArgumentNullException(nameof(parent)); + + private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) + => !string.IsNullOrEmpty(argument) ? argument : throw new ArgumentNullException(paramName); + + /// + /// Converts the current instance to a provisioning entity. + /// + internal SubnetResource ToProvisioningEntity(AzureResourceInfrastructure infra, ProvisionableResource? dependsOn) + { + var subnet = new SubnetResource(Infrastructure.NormalizeBicepIdentifier(Name)) + { + Name = SubnetName, + AddressPrefix = AddressPrefix, + }; + + if (dependsOn is not null) + { + subnet.DependsOn.Add(dependsOn); + } + + if (this.TryGetLastAnnotation(out var serviceDelegationAnnotation)) + { + subnet.Delegations.Add(new ServiceDelegation() + { + Name = serviceDelegationAnnotation.Name, + ServiceName = serviceDelegationAnnotation.ServiceName + }); + } + + // add a provisioning output for the subnet ID so it can be referenced by other resources + infra.Add(new ProvisioningOutput(Id.Name, typeof(string)) + { + Value = subnet.Id + }); + + return subnet; + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs new file mode 100644 index 00000000000..fe10e5f6d3a --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetServiceDelegationAnnotation.cs @@ -0,0 +1,24 @@ +// 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.Azure; + +/// +/// Annotation to specify a service delegation for an Azure Subnet. +/// +/// The name of the service delegation. +/// The service name for the delegation (e.g., "Microsoft.App/environments"). +internal sealed class AzureSubnetServiceDelegationAnnotation(string name, string serviceName) : IResourceAnnotation +{ + /// + /// Gets or sets the name associated with the service delegation. + /// + public string Name { get; set; } = name; + + /// + /// Gets or sets the name of the service associated with the service delegation. + /// + public string ServiceName { get; set; } = serviceName; +} diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs new file mode 100644 index 00000000000..3aad3dd2485 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs @@ -0,0 +1,186 @@ +// 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; +using Aspire.Hosting.Azure; +using Azure.Provisioning; +using Azure.Provisioning.Network; +using Azure.Provisioning.Primitives; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Azure Virtual Network resources to the application model. +/// +public static class AzureVirtualNetworkExtensions +{ + /// + /// Adds an Azure Virtual Network resource to the application model. + /// + /// The builder for the distributed application. + /// The name of the Azure Virtual Network resource. + /// The address prefix for the virtual network (e.g., "10.0.0.0/16"). If null, defaults to "10.0.0.0/16". + /// A reference to the . + /// + /// This example creates a virtual network with a subnet for private endpoints: + /// + /// var vnet = builder.AddAzureVirtualNetwork("vnet"); + /// var subnet = vnet.AddSubnet("pe-subnet", "10.0.1.0/24"); + /// + /// + public static IResourceBuilder AddAzureVirtualNetwork( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + string? addressPrefix = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + builder.AddAzureProvisioning(); + + AzureVirtualNetworkResource resource = new(name, ConfigureVirtualNetwork); + + if (builder.ExecutionContext.IsRunMode) + { + // In run mode, we don't want to add the resource to the builder. + return builder.CreateResourceBuilder(resource); + } + + return builder.AddResource(resource); + + void ConfigureVirtualNetwork(AzureResourceInfrastructure infra) + { + var vnet = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, + (identifier, name) => + { + var resource = VirtualNetwork.FromExisting(identifier); + resource.Name = name; + return resource; + }, + (infrastructure) => + { + var vnet = new VirtualNetwork(infrastructure.AspireResource.GetBicepIdentifier()) + { + AddressSpace = new VirtualNetworkAddressSpace() + { + AddressPrefixes = { addressPrefix ?? "10.0.0.0/16" } + }, + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + + return vnet; + }); + + var azureResource = (AzureVirtualNetworkResource)infra.AspireResource; + + // Add subnets + if (azureResource.Subnets.Count > 0) + { + // Chain subnet provisioning to ensure deployment doesn't fail + // due to parallel creation of subnets within the VNet. + ProvisionableResource? dependsOn = null; + foreach (var subnet in azureResource.Subnets) + { + var cdkSubnet = subnet.ToProvisioningEntity(infra, dependsOn); + cdkSubnet.Parent = vnet; + infra.Add(cdkSubnet); + + dependsOn = cdkSubnet; + } + } + + // Output the VNet ID for references + infra.Add(new ProvisioningOutput("id", typeof(string)) + { + Value = vnet.Id + }); + + // We need to output name so it can be referenced by others. + infra.Add(new ProvisioningOutput("name", typeof(string)) { Value = vnet.Name }); + } + } + + /// + /// Adds an Azure Subnet to the Virtual Network. + /// + /// The Virtual Network resource builder. + /// The name of the subnet resource. + /// The address prefix for the subnet (e.g., "10.0.1.0/24"). + /// The subnet name in Azure. If null, the resource name is used. + /// A reference to the . + /// + /// This example adds a subnet to a virtual network: + /// + /// var vnet = builder.AddAzureVirtualNetwork("vnet"); + /// var subnet = vnet.AddSubnet("my-subnet", "10.0.1.0/24"); + /// + /// + public static IResourceBuilder AddSubnet( + this IResourceBuilder builder, + [ResourceName] string name, + string addressPrefix, + string? subnetName = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(addressPrefix); + + subnetName ??= name; + + var subnet = new AzureSubnetResource(name, subnetName, addressPrefix, builder.Resource); + + builder.Resource.Subnets.Add(subnet); + + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + // In run mode, we don't want to add the resource to the builder. + return builder.ApplicationBuilder.CreateResourceBuilder(subnet); + } + + return builder.ApplicationBuilder.AddResource(subnet) + .ExcludeFromManifest(); + } + + /// + /// Configures the resource to use the specified subnet with appropriate service delegation. + /// + /// The type of resource that supports subnet delegation. + /// The resource builder. + /// The subnet to associate with the resource. + /// A reference to the . + /// + /// This method automatically configures the subnet with the appropriate service delegation + /// for the target resource type (e.g., "Microsoft.App/environments" for Azure Container Apps). + /// + /// + /// This example configures an Azure Container App Environment to use a subnet: + /// + /// var vnet = builder.AddAzureVirtualNetwork("vnet"); + /// var subnet = vnet.AddSubnet("aca-subnet", "10.0.0.0/23"); + /// + /// var env = builder.AddAzureContainerAppEnvironment("env") + /// .WithDelegatedSubnet(subnet); + /// + /// + public static IResourceBuilder WithDelegatedSubnet( + this IResourceBuilder builder, + IResourceBuilder subnet) + where T : IAzureDelegatedSubnetResource + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(subnet); + + var target = builder.Resource; + + // Store the subnet ID reference on the target resource via annotation + builder.WithAnnotation( + new DelegatedSubnetAnnotation(ReferenceExpression.Create($"{subnet.Resource.Id}"))); + + // Add service delegation annotation to the subnet + subnet.WithAnnotation(new AzureSubnetServiceDelegationAnnotation( + target.DelegatedSubnetServiceName, + target.DelegatedSubnetServiceName)); + + return builder; + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs new file mode 100644 index 00000000000..203393fc420 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.Provisioning.Network; +using Azure.Provisioning.Primitives; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure Virtual Network resource. +/// +/// The name of the resource. +/// Callback to configure the Azure Virtual Network resource. +public class AzureVirtualNetworkResource(string name, Action configureInfrastructure) + : AzureProvisioningResource(name, configureInfrastructure) +{ + internal List Subnets { get; } = []; + + /// + /// Gets the "id" output reference from the Azure Virtual Network resource. + /// + public BicepOutputReference Id => new("id", this); + + /// + /// Gets the "name" output reference for the resource. + /// + public BicepOutputReference NameOutput => new("name", this); + + /// + public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) + { + var bicepIdentifier = this.GetBicepIdentifier(); + var resources = infra.GetProvisionableResources(); + + // Check if a VirtualNetwork with the same identifier already exists + var existingVNet = resources.OfType().SingleOrDefault(vnet => vnet.BicepIdentifier == bicepIdentifier); + + if (existingVNet is not null) + { + return existingVNet; + } + + // Create and add new resource if it doesn't exist + var vnet = VirtualNetwork.FromExisting(bicepIdentifier); + + if (!TryApplyExistingResourceAnnotation( + this, + infra, + vnet)) + { + vnet.Name = NameOutput.AsProvisioningParameter(infra); + } + + infra.Add(vnet); + return vnet; + } +} diff --git a/src/Aspire.Hosting.Azure.Network/README.md b/src/Aspire.Hosting.Azure.Network/README.md new file mode 100644 index 00000000000..09dbf9abf2c --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/README.md @@ -0,0 +1,105 @@ +# Aspire.Hosting.Azure.Network library + +Provides extension methods and resource definitions for an Aspire AppHost to configure Azure Virtual Networks, Subnets, and Private Endpoints. + +## Getting started + +### Prerequisites + +- Azure subscription - [create one for free](https://azure.microsoft.com/free/) + +### Install the package + +Install the Aspire Azure Virtual Network Hosting library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Hosting.Azure.Network +``` + +## Configure Azure Provisioning for local development + +Adding Azure resources to the Aspire application model will automatically enable development-time provisioning +for Azure resources so that you don't need to configure them manually. Provisioning requires a number of settings +to be available via .NET configuration. Set these values in user secrets in order to allow resources to be configured +automatically. + +```json +{ + "Azure": { + "SubscriptionId": "", + "ResourceGroupPrefix": "", + "Location": "" + } +} +``` + +> NOTE: Developers must have Owner access to the target subscription so that role assignments +> can be configured for the provisioned resources. + +## Usage examples + +### Adding a Virtual Network + +In the _AppHost.cs_ file of `AppHost`, add a Virtual Network using the following method: + +```csharp +var vnet = builder.AddAzureVirtualNetwork("vnet"); +``` + +By default, the virtual network will use the address prefix `10.0.0.0/16`. You can specify a custom address prefix: + +```csharp +var vnet = builder.AddAzureVirtualNetwork("vnet", "10.1.0.0/16"); +``` + +### Adding Subnets + +You can add subnets to your virtual network: + +```csharp +var vnet = builder.AddAzureVirtualNetwork("vnet"); +var subnet = vnet.AddSubnet("subnet", "10.0.1.0/24"); +``` + +### Adding Private Endpoints + +Create a private endpoint to securely connect to Azure resources over a private network: + +```csharp +var vnet = builder.AddAzureVirtualNetwork("vnet"); +var peSubnet = vnet.AddSubnet("private-endpoints", "10.0.2.0/24"); + +var storage = builder.AddAzureStorage("storage"); +var blobs = storage.AddBlobs("blobs"); + +// Add a private endpoint for the blob storage +peSubnet.AddPrivateEndpoint(blobs); +``` + +When you add a private endpoint to an Azure resource: + +1. A Private DNS Zone is automatically created for the service (e.g., `privatelink.blob.core.windows.net`) +2. A Virtual Network Link connects the DNS zone to your VNet +3. A DNS Zone Group is created on the private endpoint for automatic DNS registration +4. The target resource is automatically configured to deny public network access + +To override the automatic network lockdown, use `ConfigureInfrastructure`: + +```csharp +storage.ConfigureInfrastructure(infra => +{ + var storageAccount = infra.GetProvisionableResources() + .OfType() + .Single(); + storageAccount.PublicNetworkAccess = StoragePublicNetworkAccess.Enabled; +}); +``` + +## Additional documentation + +* https://learn.microsoft.com/azure/virtual-network/ +* https://learn.microsoft.com/azure/private-link/ + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs index fb6baaa9398..d65f5cc6f73 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.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 Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting.Azure; @@ -13,7 +15,8 @@ namespace Aspire.Hosting.Azure; public class AzureBlobStorageResource(string name, AzureStorageResource storage) : Resource(name), IResourceWithConnectionString, IResourceWithParent, - IResourceWithAzureFunctionsConfig + IResourceWithAzureFunctionsConfig, + IAzurePrivateEndpointTarget { /// /// Gets the parent AzureStorageResource of this AzureBlobStorageResource. @@ -81,6 +84,12 @@ void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDiction } } + BicepOutputReference IAzurePrivateEndpointTarget.Id => Parent.Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["blob"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.blob.core.windows.net"; + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() { yield return new("Uri", UriExpression); diff --git a/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs index b53e5df530e..877c8acabd3 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.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 Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting.Azure; @@ -13,7 +15,8 @@ namespace Aspire.Hosting.Azure; public class AzureQueueStorageResource(string name, AzureStorageResource storage) : Resource(name), IResourceWithConnectionString, IResourceWithParent, - IResourceWithAzureFunctionsConfig + IResourceWithAzureFunctionsConfig, + IAzurePrivateEndpointTarget { /// /// Gets the parent AzureStorageResource of this AzureQueueStorageResource. @@ -74,6 +77,12 @@ void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDiction } } + BicepOutputReference IAzurePrivateEndpointTarget.Id => Parent.Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["queue"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.queue.core.windows.net"; + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() { yield return new("Uri", UriExpression); diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs index 139b06caa03..f2513ccd5af 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.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 Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Aspire.Hosting.Azure.Storage; @@ -53,26 +55,42 @@ public static IResourceBuilder AddAzureStorage(this IDistr resource.Name = name; return resource; }, - (infrastructure) => new StorageAccount(infrastructure.AspireResource.GetBicepIdentifier()) + (infrastructure) => { - Kind = StorageKind.StorageV2, - AccessTier = StorageAccountAccessTier.Hot, - Sku = new StorageSku() { Name = StorageSkuName.StandardGrs }, - IsHnsEnabled = azureResource.IsHnsEnabled, - NetworkRuleSet = new StorageAccountNetworkRuleSet() + // Check if this storage has a private endpoint (via annotation) + var hasPrivateEndpoint = azureResource.HasAnnotationOfType(); + + var storageAccount = new StorageAccount(infrastructure.AspireResource.GetBicepIdentifier()) { - // Unfortunately Azure Storage does not list ACA as one of the resource types in which - // the AzureServices firewall policy works. This means that we need this Azure Storage - // account to have its default action set to Allow. - DefaultAction = StorageNetworkDefaultAction.Allow - }, - // Set the minimum TLS version to 1.2 to ensure resources provisioned are compliant - // with the pending deprecation of TLS 1.0 and 1.1. - MinimumTlsVersion = StorageMinimumTlsVersion.Tls1_2, - // Disable shared key access to the storage account as managed identity is configured - // to access the storage account by default. - AllowSharedKeyAccess = false, - Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + Kind = StorageKind.StorageV2, + AccessTier = StorageAccountAccessTier.Hot, + Sku = new StorageSku() { Name = StorageSkuName.StandardGrs }, + IsHnsEnabled = azureResource.IsHnsEnabled, + NetworkRuleSet = new StorageAccountNetworkRuleSet() + { + // When using private endpoints, deny public access. + // Otherwise, we need to allow it since Azure Storage does not list ACA + // as one of the resource types in which the AzureServices firewall policy works. + DefaultAction = hasPrivateEndpoint + ? StorageNetworkDefaultAction.Deny + : StorageNetworkDefaultAction.Allow + }, + // Set the minimum TLS version to 1.2 to ensure resources provisioned are compliant + // with the pending deprecation of TLS 1.0 and 1.1. + MinimumTlsVersion = StorageMinimumTlsVersion.Tls1_2, + // Disable shared key access to the storage account as managed identity is configured + // to access the storage account by default. + AllowSharedKeyAccess = false, + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + + // When using private endpoints, completely disable public network access. + if (hasPrivateEndpoint) + { + storageAccount.PublicNetworkAccess = StoragePublicNetworkAccess.Disabled; + } + + return storageAccount; }); if (azureResource.BlobContainers.Count > 0 || azureResource.DataLakeFileSystems.Count > 0) @@ -135,6 +153,7 @@ public static IResourceBuilder AddAzureStorage(this IDistr // We need to output name to externalize role assignments. infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = storageAccount.Name.ToBicepExpression() }); + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = storageAccount.Id }); }; var resource = new AzureStorageResource(name, configureInfrastructure); diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs index d300c713654..f8b7c3901c1 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs @@ -57,6 +57,11 @@ public class AzureStorageResource(string name, Action public BicepOutputReference DataLakeEndpoint => new("dataLakeEndpoint", this); + /// + /// Gets the "id" output reference for the resource. + /// + public BicepOutputReference Id => new("id", this); + /// /// Gets the "name" output reference for the resource. /// diff --git a/src/Aspire.Hosting.Azure/DelegatedSubnetAnnotation.cs b/src/Aspire.Hosting.Azure/DelegatedSubnetAnnotation.cs new file mode 100644 index 00000000000..8326cd75564 --- /dev/null +++ b/src/Aspire.Hosting.Azure/DelegatedSubnetAnnotation.cs @@ -0,0 +1,20 @@ +// 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; + +/// +/// Annotation that stores a reference to a subnet for an Azure resource that implements . +/// +/// The subnet ID reference expression. +[Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")] +public sealed class DelegatedSubnetAnnotation(ReferenceExpression subnetId) : IResourceAnnotation +{ + /// + /// Gets the subnet ID reference expression. + /// + public ReferenceExpression SubnetId { get; } = subnetId; +} diff --git a/src/Aspire.Hosting.Azure/IAzureDelegatedSubnetResource.cs b/src/Aspire.Hosting.Azure/IAzureDelegatedSubnetResource.cs new file mode 100644 index 00000000000..64e2b97bc54 --- /dev/null +++ b/src/Aspire.Hosting.Azure/IAzureDelegatedSubnetResource.cs @@ -0,0 +1,19 @@ +// 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; + +/// +/// Represents an Azure resource that supports subnet delegation. +/// +[Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")] +public interface IAzureDelegatedSubnetResource : IResource +{ + /// + /// Gets the service delegation service name (e.g., "Microsoft.App/environments"). + /// + string DelegatedSubnetServiceName { get; } +} diff --git a/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs b/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs new file mode 100644 index 00000000000..883989c79e0 --- /dev/null +++ b/src/Aspire.Hosting.Azure/IAzurePrivateEndpointTarget.cs @@ -0,0 +1,31 @@ +// 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; + +/// +/// Represents an Azure resource that can be connected to via a private endpoint. +/// +[Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")] +public interface IAzurePrivateEndpointTarget : IResource +{ + /// + /// Gets the "id" output reference from the Azure resource. + /// + BicepOutputReference Id { get; } + + /// + /// Gets the group IDs for the private link service connection (e.g., "blob", "file" for storage). + /// + /// A collection of group IDs for the private link service connection. + IEnumerable GetPrivateLinkGroupIds(); + + /// + /// Gets the private DNS zone name for this resource type (e.g., "privatelink.blob.core.windows.net" for blob storage). + /// + /// The private DNS zone name for the private endpoint. + string GetPrivateDnsZoneName(); +} diff --git a/src/Aspire.Hosting.Azure/PrivateEndpointTargetAnnotation.cs b/src/Aspire.Hosting.Azure/PrivateEndpointTargetAnnotation.cs new file mode 100644 index 00000000000..2a9e630fe81 --- /dev/null +++ b/src/Aspire.Hosting.Azure/PrivateEndpointTargetAnnotation.cs @@ -0,0 +1,16 @@ +// 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; + +/// +/// An annotation that indicates a resource is the target of a private endpoint. +/// When this annotation is present, the resource should be configured to deny public network access. +/// +[Experimental("ASPIREAZURE003", UrlFormat = "https://aka.ms/dotnet/aspire/diagnostics#{0}")] +public sealed class PrivateEndpointTargetAnnotation : IResourceAnnotation +{ +} diff --git a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj index a8a46d0e2b5..8d5c2093659 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj +++ b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs index a6218a43f6e..a28f4903744 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppEnvironmentExtensionsTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIRECOMPUTE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. +#pragma warning disable ASPIREAZURE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure.AppContainers; @@ -133,4 +134,22 @@ public void ContainerRegistry_ThrowsWhenNonAzureRegistryConfigured() Assert.Contains("not an Azure Container Registry", exception.Message); Assert.Contains("env", exception.Message); } + + [Fact] + public async Task WithDelegatedSubnet_ConfiguresVnetConfiguration() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("container-apps-subnet", "10.0.0.0/23"); + + var containerAppEnvironment = builder.AddAzureContainerAppEnvironment("env") + .WithDelegatedSubnet(subnet); + + var (_, envBicep) = await AzureManifestUtils.GetManifestWithBicep(containerAppEnvironment.Resource); + var (_, vnetBicep) = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); + + await Verify(envBicep, extension: "bicep") + .AppendContentAsFile(vnetBicep, "bicep", "vnet"); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs new file mode 100644 index 00000000000..eb1be832c40 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointExtensionsTests.cs @@ -0,0 +1,211 @@ +// 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 Aspire.Hosting.Utils; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzurePrivateEndpointExtensionsTests +{ + [Fact] + public void AddPrivateEndpoint_CreatesResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + + var pe = subnet.AddPrivateEndpoint(blobs); + + Assert.NotNull(pe); + Assert.Equal("pesubnet-blobs-pe", pe.Resource.Name); + Assert.IsType(pe.Resource); + Assert.Same(subnet.Resource, pe.Resource.Subnet); + Assert.Same(blobs.Resource, pe.Resource.Target); + } + + [Fact] + public void AddPrivateEndpoint_AddsAnnotationToParentStorage() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + + // Before adding PE, no annotation + Assert.Empty(storage.Resource.Annotations.OfType()); + + subnet.AddPrivateEndpoint(blobs); + + // After adding PE, annotation should be on parent storage + var annotation = storage.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + } + + [Fact] + public void AddPrivateEndpoint_ForQueues_AddsAnnotationToParentStorage() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage"); + var queues = storage.AddQueues("queues"); + + subnet.AddPrivateEndpoint(queues); + + var annotation = storage.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(annotation); + } + + [Fact] + public async Task AddPrivateEndpoint_GeneratesBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + + var pe = subnet.AddPrivateEndpoint(blobs); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(pe.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddPrivateEndpoint_ForQueues_GeneratesBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage"); + var queues = storage.AddQueues("queues"); + + var pe = subnet.AddPrivateEndpoint(queues); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(pe.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public void AddPrivateEndpoint_InRunMode_DoesNotAddToBuilder() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + + var pe = subnet.AddPrivateEndpoint(blobs); + + // In run mode, the PE resource should not be added to the builder's resources + Assert.DoesNotContain(pe.Resource, builder.Resources); + } + + [Fact] + public void AzureBlobStorageResource_ImplementsIAzurePrivateEndpointTarget() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + + Assert.IsAssignableFrom(blobs.Resource); + + var target = (IAzurePrivateEndpointTarget)blobs.Resource; + Assert.Equal(["blob"], target.GetPrivateLinkGroupIds()); + Assert.Equal("privatelink.blob.core.windows.net", target.GetPrivateDnsZoneName()); + } + + [Fact] + public void AzureQueueStorageResource_ImplementsIAzurePrivateEndpointTarget() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var storage = builder.AddAzureStorage("storage"); + var queues = storage.AddQueues("queues"); + + Assert.IsAssignableFrom(queues.Resource); + + var target = (IAzurePrivateEndpointTarget)queues.Resource; + Assert.Equal(["queue"], target.GetPrivateLinkGroupIds()); + Assert.Equal("privatelink.queue.core.windows.net", target.GetPrivateDnsZoneName()); + } + + [Fact] + public async Task AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + + // Two storage accounts with blob endpoints (same DNS zone name) + var storage1 = builder.AddAzureStorage("storage1"); + var blobs1 = storage1.AddBlobs("blobs1"); + + var storage2 = builder.AddAzureStorage("storage2"); + var blobs2 = storage2.AddBlobs("blobs2"); + + // Create two private endpoints for the same DNS zone type + var pe1 = subnet.AddPrivateEndpoint(blobs1); + var pe2 = subnet.AddPrivateEndpoint(blobs2); + + // Should only have one DNS Zone resource + var dnsZones = builder.Resources.OfType().ToList(); + Assert.Single(dnsZones); + Assert.Equal("privatelink.blob.core.windows.net", dnsZones[0].ZoneName); + + // Should only have one VNet Link + Assert.Single(dnsZones[0].VNetLinks); + var vnetLinks = builder.Resources.OfType().ToList(); + Assert.Single(vnetLinks); + + // Verify the bicep for DNS Zone, VNet Link, and both PEs + var (_, dnsZoneBicep) = await AzureManifestUtils.GetManifestWithBicep(dnsZones[0]); + var (_, pe1Bicep) = await AzureManifestUtils.GetManifestWithBicep(pe1.Resource); + var (_, pe2Bicep) = await AzureManifestUtils.GetManifestWithBicep(pe2.Resource); + + await Verify(dnsZoneBicep, extension: "bicep") + .AppendContentAsFile(pe1Bicep, "bicep", "pe1") + .AppendContentAsFile(pe2Bicep, "bicep", "pe2"); + } + + [Fact] + public void AddPrivateEndpoint_CreatesSeparateDnsZones_ForDifferentZoneNames() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + var queues = storage.AddQueues("queues"); + + // Create two private endpoints for different DNS zone types + subnet.AddPrivateEndpoint(blobs); + subnet.AddPrivateEndpoint(queues); + + // Should have two DNS Zone resources + var dnsZones = builder.Resources.OfType().ToList(); + Assert.Equal(2, dnsZones.Count); + Assert.Contains(dnsZones, z => z.ZoneName == "privatelink.blob.core.windows.net"); + Assert.Contains(dnsZones, z => z.ZoneName == "privatelink.queue.core.windows.net"); + + // Each DNS Zone should have one VNet Link + Assert.All(dnsZones, z => Assert.Single(z.VNetLinks)); + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs new file mode 100644 index 00000000000..e5b8bfcba07 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs @@ -0,0 +1,54 @@ +// 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; +using Azure.Provisioning.Storage; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzureStoragePrivateEndpointLockdownTests +{ + [Fact] + public async Task AddAzureStorage_WithPrivateEndpoint_CanOverrideWithConfigureInfrastructure() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage") + .ConfigureInfrastructure(infra => + { + var storageAccount = infra.GetProvisionableResources().OfType().Single(); + storageAccount.PublicNetworkAccess = StoragePublicNetworkAccess.Enabled; + storageAccount.NetworkRuleSet!.DefaultAction = StorageNetworkDefaultAction.Allow; + }); + var blobs = storage.AddBlobs("blobs"); + + subnet.AddPrivateEndpoint(blobs); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(storage.Resource); + + // Override should result in Allow/Enabled + Assert.Contains("defaultAction: 'Allow'", manifest.BicepText); + Assert.Contains("publicNetworkAccess: 'Enabled'", manifest.BicepText); + } + + [Fact] + public async Task AddAzureStorage_WithPrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + var queues = storage.AddQueues("queues"); + + subnet.AddPrivateEndpoint(blobs); + subnet.AddPrivateEndpoint(queues); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(storage.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs new file mode 100644 index 00000000000..e0b9a5db7c9 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs @@ -0,0 +1,129 @@ +// 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 Aspire.Hosting.Utils; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzureVirtualNetworkExtensionsTests +{ + [Fact] + public void AddAzureVirtualNetwork_CreatesResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + + Assert.NotNull(vnet); + Assert.Equal("myvnet", vnet.Resource.Name); + Assert.IsType(vnet.Resource); + } + + [Fact] + public void AddAzureVirtualNetwork_WithCustomAddressPrefix() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet", "10.1.0.0/16"); + + Assert.NotNull(vnet); + Assert.Equal("myvnet", vnet.Resource.Name); + } + + [Fact] + public void AddSubnet_CreatesSubnetResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("mysubnet", "10.0.1.0/24"); + + Assert.NotNull(subnet); + Assert.Equal("mysubnet", subnet.Resource.Name); + Assert.Equal("mysubnet", subnet.Resource.SubnetName); + Assert.Equal("10.0.1.0/24", subnet.Resource.AddressPrefix); + Assert.Same(vnet.Resource, subnet.Resource.Parent); + } + + [Fact] + public void AddSubnet_WithCustomSubnetName() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("mysubnet", "10.0.1.0/24", subnetName: "custom-subnet-name"); + + Assert.Equal("mysubnet", subnet.Resource.Name); + Assert.Equal("custom-subnet-name", subnet.Resource.SubnetName); + Assert.Equal("10.0.1.0/24", subnet.Resource.AddressPrefix); + } + + [Fact] + public void AddSubnet_MultipleSubnets_HaveDifferentParentReferences() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet1 = vnet.AddSubnet("subnet1", "10.0.1.0/24"); + var subnet2 = vnet.AddSubnet("subnet2", "10.0.2.0/24"); + + // Both subnets should have the same parent VNet + Assert.Same(vnet.Resource, subnet1.Resource.Parent); + Assert.Same(vnet.Resource, subnet2.Resource.Parent); + // But they should be different resources + Assert.NotSame(subnet1.Resource, subnet2.Resource); + } + + [Fact] + public async Task AddAzureVirtualNetwork_WithSubnets_GeneratesBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + vnet.AddSubnet("subnet1", "10.0.1.0/24") + .WithAnnotation(new AzureSubnetServiceDelegationAnnotation("ContainerAppsDelegation", "Microsoft.App/environments")); + vnet.AddSubnet("subnet2", "10.0.2.0/24", subnetName: "custom-subnet-name"); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public void AddAzureVirtualNetwork_InRunMode_DoesNotAddToBuilder() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("mysubnet", "10.0.1.0/24"); + + // In run mode, the resource should not be added to the builder's resources + Assert.DoesNotContain(vnet.Resource, builder.Resources); + // In run mode, the subnet should not be added to the builder's resources + Assert.DoesNotContain(subnet.Resource, builder.Resources); + } + + [Fact] + public void WithDelegatedSubnet_AddsAnnotationsToSubnetAndTarget() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("mysubnet", "10.0.0.0/23"); + + var env = builder.AddAzureContainerAppEnvironment("env") + .WithDelegatedSubnet(subnet); + + // Verify the target has DelegatedSubnetAnnotation + var subnetAnnotation = env.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(subnetAnnotation); + Assert.Equal("{myvnet.outputs.mysubnet_Id}", subnetAnnotation.SubnetId.ValueExpression); + + // Verify the subnet has AzureSubnetServiceDelegationAnnotation + var delegationAnnotation = subnet.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(delegationAnnotation); + Assert.Equal("Microsoft.App/environments", delegationAnnotation.ServiceName); + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep new file mode 100644 index 00000000000..e9c6c13ec83 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration#vnet.verified.bicep @@ -0,0 +1,39 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource container_apps_subnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'container-apps-subnet' + properties: { + addressPrefix: '10.0.0.0/23' + delegations: [ + { + properties: { + serviceName: 'Microsoft.App/environments' + } + name: 'Microsoft.App/environments' + } + ] + } + parent: myvnet +} + +output container_apps_subnet_Id string = container_apps_subnet.id + +output id string = myvnet.id + +output name string = myvnet.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration.verified.bicep new file mode 100644 index 00000000000..89b970e4830 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppEnvironmentExtensionsTests.WithDelegatedSubnet_ConfiguresVnetConfiguration.verified.bicep @@ -0,0 +1,89 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param env_acr_outputs_name string + +param myvnet_outputs_container_apps_subnet_id string + +resource env_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('env_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource env_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: env_acr_outputs_name +} + +resource env_acr_env_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(env_acr.id, env_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: env_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: env_acr +} + +resource env_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('envlaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource env 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('env${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: env_law.properties.customerId + sharedKey: env_law.listKeys().primarySharedKey + } + } + vnetConfiguration: { + infrastructureSubnetId: myvnet_outputs_container_apps_subnet_id + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: env +} + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = env_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = env_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = env_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = env_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = env_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = env.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = env.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = env.properties.defaultDomain \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_CapturesParametersAndOutputsCorrectly_WithSnapshot#01.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_CapturesParametersAndOutputsCorrectly_WithSnapshot#01.verified.bicep index 75055d8eff0..8cfebedff1e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_CapturesParametersAndOutputsCorrectly_WithSnapshot#01.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_CapturesParametersAndOutputsCorrectly_WithSnapshot#01.verified.bicep @@ -36,4 +36,6 @@ output tableEndpoint string = storage.properties.primaryEndpoints.table output name string = storage.name -output description string = sku_description +output id string = storage.id + +output description string = sku_description \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep new file mode 100644 index 00000000000..71713b626d3 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ForQueues_GeneratesBicep.verified.bicep @@ -0,0 +1,55 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_queue_core_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param storage_outputs_id string + +resource privatelink_queue_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_queue_core_windows_net_outputs_name +} + +resource pesubnet_queues_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_queues_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: storage_outputs_id + groupIds: [ + 'queue' + ] + } + name: 'pesubnet-queues-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-queues-pe' + } +} + +resource pesubnet_queues_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_queue_core_windows_net' + properties: { + privateDnsZoneId: privatelink_queue_core_windows_net.id + } + } + ] + } + parent: pesubnet_queues_pe +} + +output id string = pesubnet_queues_pe.id + +output name string = pesubnet_queues_pe.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_GeneratesBicep.verified.bicep new file mode 100644 index 00000000000..9dc6735e109 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_GeneratesBicep.verified.bicep @@ -0,0 +1,55 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_blob_core_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param storage_outputs_id string + +resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_blob_core_windows_net_outputs_name +} + +resource pesubnet_blobs_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_blobs_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: storage_outputs_id + groupIds: [ + 'blob' + ] + } + name: 'pesubnet-blobs-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-blobs-pe' + } +} + +resource pesubnet_blobs_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_blob_core_windows_net' + properties: { + privateDnsZoneId: privatelink_blob_core_windows_net.id + } + } + ] + } + parent: pesubnet_blobs_pe +} + +output id string = pesubnet_blobs_pe.id + +output name string = pesubnet_blobs_pe.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe1.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe1.verified.bicep new file mode 100644 index 00000000000..f62af25062a --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe1.verified.bicep @@ -0,0 +1,55 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_blob_core_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param storage1_outputs_id string + +resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_blob_core_windows_net_outputs_name +} + +resource pesubnet_blobs1_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_blobs1_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: storage1_outputs_id + groupIds: [ + 'blob' + ] + } + name: 'pesubnet-blobs1-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-blobs1-pe' + } +} + +resource pesubnet_blobs1_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_blob_core_windows_net' + properties: { + privateDnsZoneId: privatelink_blob_core_windows_net.id + } + } + ] + } + parent: pesubnet_blobs1_pe +} + +output id string = pesubnet_blobs1_pe.id + +output name string = pesubnet_blobs1_pe.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe2.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe2.verified.bicep new file mode 100644 index 00000000000..276694128e2 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName#pe2.verified.bicep @@ -0,0 +1,55 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param privatelink_blob_core_windows_net_outputs_name string + +param myvnet_outputs_pesubnet_id string + +param storage2_outputs_id string + +resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: privatelink_blob_core_windows_net_outputs_name +} + +resource pesubnet_blobs2_pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: take('pesubnet_blobs2_pe-${uniqueString(resourceGroup().id)}', 64) + location: location + properties: { + privateLinkServiceConnections: [ + { + properties: { + privateLinkServiceId: storage2_outputs_id + groupIds: [ + 'blob' + ] + } + name: 'pesubnet-blobs2-pe-connection' + } + ] + subnet: { + id: myvnet_outputs_pesubnet_id + } + } + tags: { + 'aspire-resource-name': 'pesubnet-blobs2-pe' + } +} + +resource pesubnet_blobs2_pe_dnsgroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2025-05-01' = { + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink_blob_core_windows_net' + properties: { + privateDnsZoneId: privatelink_blob_core_windows_net.id + } + } + ] + } + parent: pesubnet_blobs2_pe +} + +output id string = pesubnet_blobs2_pe.id + +output name string = pesubnet_blobs2_pe.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName.verified.bicep new file mode 100644 index 00000000000..7f5ece89d1f --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointExtensionsTests.AddPrivateEndpoint_ReusesDnsZone_ForSameZoneName.verified.bicep @@ -0,0 +1,31 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param myvnet_outputs_id string + +resource privatelink_blob_core_windows_net 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: 'privatelink.blob.core.windows.net' + location: 'global' + tags: { + 'aspire-resource-name': 'privatelink-blob-core-windows-net' + } +} + +resource myvnet_link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + name: 'myvnet-link' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: myvnet_outputs_id + } + } + tags: { + 'aspire-resource-name': 'privatelink-blob-core-windows-net-myvnet-link' + } + parent: privatelink_blob_core_windows_net +} + +output id string = privatelink_blob_core_windows_net.id + +output name string = 'privatelink.blob.core.windows.net' \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishMode.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishMode.verified.bicep index 9bb466a01be..7bff864ed91 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishMode.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishMode.verified.bicep @@ -33,3 +33,5 @@ output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishModeEnableAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishModeEnableAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep index e7f3b9d0f28..006c7cbb14d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishModeEnableAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaPublishModeEnableAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep @@ -33,3 +33,5 @@ output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunMode.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunMode.verified.bicep index 9bb466a01be..7bff864ed91 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunMode.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunMode.verified.bicep @@ -33,3 +33,5 @@ output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunModeAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunModeAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep index e7f3b9d0f28..006c7cbb14d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunModeAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.AddAzureStorageViaRunModeAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep @@ -33,3 +33,5 @@ output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep index bb552f5c909..e21d1603196 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep @@ -51,3 +51,5 @@ output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..84e807f8a64 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,36 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('storage${uniqueString(resourceGroup().id)}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_GRS' + } + properties: { + accessTier: 'Hot' + allowSharedKeyAccess: false + isHnsEnabled: false + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Deny' + } + publicNetworkAccess: 'Disabled' + } + tags: { + 'aspire-resource-name': 'storage' + } +} + +output blobEndpoint string = storage.properties.primaryEndpoints.blob + +output dataLakeEndpoint string = storage.properties.primaryEndpoints.dfs + +output queueEndpoint string = storage.properties.primaryEndpoints.queue + +output tableEndpoint string = storage.properties.primaryEndpoints.table + +output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddAzureVirtualNetwork_WithSubnets_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddAzureVirtualNetwork_WithSubnets_GeneratesBicep.verified.bicep new file mode 100644 index 00000000000..79ef0e88d7a --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddAzureVirtualNetwork_WithSubnets_GeneratesBicep.verified.bicep @@ -0,0 +1,52 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource subnet1 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'subnet1' + properties: { + addressPrefix: '10.0.1.0/24' + delegations: [ + { + properties: { + serviceName: 'Microsoft.App/environments' + } + name: 'ContainerAppsDelegation' + } + ] + } + parent: myvnet +} + +resource subnet2 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'custom-subnet-name' + properties: { + addressPrefix: '10.0.2.0/24' + } + parent: myvnet + dependsOn: [ + subnet1 + ] +} + +output subnet1_Id string = subnet1.id + +output subnet2_Id string = subnet2.id + +output id string = myvnet.id + +output name string = myvnet.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroup.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroup.verified.bicep index 5a341095402..77d7e251326 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroup.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroup.verified.bicep @@ -15,4 +15,6 @@ output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table -output name string = storage.name \ No newline at end of file +output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroupAndStaticArguments.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroupAndStaticArguments.verified.bicep index b1a4aa32ca2..7682943603b 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroupAndStaticArguments.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroupAndStaticArguments.verified.bicep @@ -14,3 +14,5 @@ output queueEndpoint string = storage.properties.primaryEndpoints.queue output tableEndpoint string = storage.properties.primaryEndpoints.table output name string = storage.name + +output id string = storage.id \ No newline at end of file From d10a9a1585fbf483a751d43467ac1b8469c07f3c Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 6 Feb 2026 00:40:18 +0800 Subject: [PATCH 047/256] Only export env values with FromSpec (#14356) --- src/Aspire.Dashboard/Model/ExportHelpers.cs | 2 +- .../Model/ResourceMenuBuilder.cs | 2 +- .../Model/ExportHelpersTests.cs | 11 ++- .../Model/ResourceMenuBuilderTests.cs | 67 +++++++++++++++++++ 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Dashboard/Model/ExportHelpers.cs b/src/Aspire.Dashboard/Model/ExportHelpers.cs index 46f5475c68e..0e2d2302cf9 100644 --- a/src/Aspire.Dashboard/Model/ExportHelpers.cs +++ b/src/Aspire.Dashboard/Model/ExportHelpers.cs @@ -73,7 +73,7 @@ public static ExportResult GetResourceAsJson(ResourceViewModel resource, IDictio /// A result containing the .env file content and suggested file name. public static ExportResult GetEnvironmentVariablesAsEnvFile(ResourceViewModel resource, IDictionary resourceByName) { - var envContent = EnvHelpers.ConvertToEnvFormat(resource.Environment.Select(e => new KeyValuePair(e.Name, e.Value))); + var envContent = EnvHelpers.ConvertToEnvFormat(resource.Environment.Where(e => e.FromSpec).Select(e => new KeyValuePair(e.Name, e.Value))); var fileName = $"{ResourceViewModel.GetResourceName(resource, resourceByName)}.env"; return new ExportResult(envContent, fileName); } diff --git a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs index 325386ac05e..b8412e42c29 100644 --- a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs @@ -124,7 +124,7 @@ await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions } }); - if (resource.Environment.Length > 0) + if (resource.Environment.Any(e => e.FromSpec)) { menuItems.Add(new MenuButtonItem { diff --git a/tests/Aspire.Dashboard.Tests/Model/ExportHelpersTests.cs b/tests/Aspire.Dashboard.Tests/Model/ExportHelpersTests.cs index 500242982aa..6e7a3d9f3c8 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ExportHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ExportHelpersTests.cs @@ -42,7 +42,8 @@ public void GetEnvironmentVariablesAsEnvFile_ReturnsExpectedResult() resourceType: "Container", state: KnownResourceState.Running, environment: [ - new EnvironmentVariableViewModel("MY_VAR", "my-value", fromSpec: false) + new EnvironmentVariableViewModel("MY_VAR", "my-value", fromSpec: true), + new EnvironmentVariableViewModel("RUNTIME_VAR", "runtime-value", fromSpec: false) ]); var resourceByName = new Dictionary(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource }; @@ -52,6 +53,12 @@ public void GetEnvironmentVariablesAsEnvFile_ReturnsExpectedResult() // Assert Assert.Equal("Test Resource.env", result.FileName); - Assert.Contains("MY_VAR=my-value", result.Content); + Assert.Equal( + """ + MY_VAR=my-value + + """, + result.Content, + ignoreLineEndingDifferences: true); } } diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs index 3decb32518c..62d1ce01296 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs @@ -183,6 +183,73 @@ public void AddMenuItems_HasTelemetry_TelemetryItems() e => Assert.Equal("Localized:ResourceActionMetricsText", e.Text)); } + [Fact] + public void AddMenuItems_WithFromSpecEnvVars_ExportEnvMenuItemShown() + { + // Arrange + var resource = ModelTestHelpers.CreateResource( + environment: [ + new EnvironmentVariableViewModel("SPEC_VAR", "spec-value", fromSpec: true), + new EnvironmentVariableViewModel("RUNTIME_VAR", "runtime-value", fromSpec: false) + ]); + var repository = TelemetryTestHelpers.CreateRepository(); + var aiContextProvider = new TestAIContextProvider(); + var resourceMenuBuilder = CreateResourceMenuBuilder(repository, aiContextProvider); + + // Act + var menuItems = new List(); + resourceMenuBuilder.AddMenuItems( + menuItems, + resource, + new Dictionary(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource }, + EventCallback.Empty, + EventCallback.Empty, + (_, _) => false, + showViewDetails: true, + showConsoleLogsItem: true, + showUrls: true); + + // Assert + Assert.Collection(menuItems, + e => Assert.Equal("Localized:ActionViewDetailsText", e.Text), + e => Assert.Equal("Localized:ResourceActionConsoleLogsText", e.Text), + e => Assert.Equal("Localized:ExportJson", e.Text), + e => Assert.Equal("Localized:ExportEnv", e.Text)); + } + + [Fact] + public void AddMenuItems_WithoutFromSpecEnvVars_ExportEnvMenuItemNotShown() + { + // Arrange - only runtime env vars (fromSpec: false), no spec env vars + var resource = ModelTestHelpers.CreateResource( + environment: [ + new EnvironmentVariableViewModel("RUNTIME_VAR1", "value1", fromSpec: false), + new EnvironmentVariableViewModel("RUNTIME_VAR2", "value2", fromSpec: false) + ]); + var repository = TelemetryTestHelpers.CreateRepository(); + var aiContextProvider = new TestAIContextProvider(); + var resourceMenuBuilder = CreateResourceMenuBuilder(repository, aiContextProvider); + + // Act + var menuItems = new List(); + resourceMenuBuilder.AddMenuItems( + menuItems, + resource, + new Dictionary(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource }, + EventCallback.Empty, + EventCallback.Empty, + (_, _) => false, + showViewDetails: true, + showConsoleLogsItem: true, + showUrls: true); + + // Assert - ExportEnv should NOT be in the menu + Assert.Collection(menuItems, + e => Assert.Equal("Localized:ActionViewDetailsText", e.Text), + e => Assert.Equal("Localized:ResourceActionConsoleLogsText", e.Text), + e => Assert.Equal("Localized:ExportJson", e.Text)); + } + private sealed class TestNavigationManager : NavigationManager { } From f1d69665be539b5020e78fef9f026d3be15dd827 Mon Sep 17 00:00:00 2001 From: dotnet bot Date: Thu, 5 Feb 2026 11:32:03 -0800 Subject: [PATCH 048/256] Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2896470 (#14349) * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2895925 * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2895925 * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2895925 * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2895925 * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2895925 * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2895925 * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2895925 * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2896434 * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2896434 * Localized file check-in by OneLocBuild Task: Build definition ID 1309: Build ID 2896434 --- .../Resources/xlf/DocsCommandStrings.de.xlf | 30 ++++++++--------- .../Resources/xlf/DocsCommandStrings.es.xlf | 30 ++++++++--------- .../Resources/xlf/DocsCommandStrings.fr.xlf | 30 ++++++++--------- .../Resources/xlf/DocsCommandStrings.it.xlf | 30 ++++++++--------- .../Resources/xlf/DocsCommandStrings.ja.xlf | 30 ++++++++--------- .../Resources/xlf/DocsCommandStrings.pl.xlf | 30 ++++++++--------- .../xlf/DocsCommandStrings.pt-BR.xlf | 28 ++++++++-------- .../Resources/xlf/DocsCommandStrings.tr.xlf | 30 ++++++++--------- .../xlf/DocsCommandStrings.zh-Hans.xlf | 30 ++++++++--------- .../xlf/ResourceCommandStrings.de.xlf | 18 +++++------ .../xlf/ResourceCommandStrings.es.xlf | 18 +++++------ .../xlf/ResourceCommandStrings.fr.xlf | 18 +++++------ .../xlf/ResourceCommandStrings.it.xlf | 18 +++++------ .../xlf/ResourceCommandStrings.ja.xlf | 18 +++++------ .../xlf/ResourceCommandStrings.pl.xlf | 18 +++++------ .../xlf/ResourceCommandStrings.pt-BR.xlf | 18 +++++------ .../xlf/ResourceCommandStrings.tr.xlf | 18 +++++------ .../xlf/ResourceCommandStrings.zh-Hans.xlf | 18 +++++------ .../Resources/xlf/RootCommandStrings.de.xlf | 6 ++-- .../Resources/xlf/RootCommandStrings.it.xlf | 6 ++-- .../Resources/xlf/RootCommandStrings.pl.xlf | 6 ++-- .../xlf/TelemetryCommandStrings.de.xlf | 32 +++++++++---------- .../xlf/TelemetryCommandStrings.it.xlf | 32 +++++++++---------- .../xlf/TelemetryCommandStrings.pl.xlf | 32 +++++++++---------- .../Resources/xlf/AIAssistant.fr.xlf | 4 +-- .../Resources/xlf/AIAssistant.ja.xlf | 6 ++-- .../Resources/xlf/AIAssistant.ko.xlf | 10 +++--- .../Resources/xlf/AIAssistant.pl.xlf | 4 +-- .../Resources/xlf/AIAssistant.pt-BR.xlf | 4 +-- .../Resources/xlf/AIAssistant.ru.xlf | 4 +-- .../Resources/xlf/Dialogs.ko.xlf | 2 +- .../Resources/xlf/MessageStrings.it.xlf | 12 +++---- .../Resources/xlf/MessageStrings.pl.xlf | 12 +++---- 33 files changed, 301 insertions(+), 301 deletions(-) diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.de.xlf index 0b40d1219dc..1618ad7add1 100644 --- a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.de.xlf @@ -4,77 +4,77 @@ Browse and search Aspire documentation from aspire.dev. - Browse and search Aspire documentation from aspire.dev. + Aspire-Dokumentation auf aspire.dev browsen und suchen. Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. - Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Die Dokumentationsseite „{0}“ wurde nicht gefunden. Verwenden Sie „aspire docs list“, um verfügbare Seiten anzuzeigen. Output format (Table or Json). - Output format (Table or Json). + Ausgabeformat (Tabelle oder JSON). Found {0} documentation pages. - Found {0} documentation pages. + Es wurden {0} Dokumentationsseiten gefunden. Found {0} results for '{1}'. - Found {0} results for '{1}'. + Es wurden {0} Ergebnisse für „{1}“ gefunden. Get the full content of a documentation page by its slug. - Get the full content of a documentation page by its slug. + Den vollständigen Inhalt einer Dokumentationsseite anhand ihres Slugs abrufen. Maximum number of search results to return (default: 5, max: 10). - Maximum number of search results to return (default: 5, max: 10). + Die maximale Anzahl der Suchergebnisse, die zurückgegeben werden sollen (Standard: 5, max: 10). List all available Aspire documentation pages. - List all available Aspire documentation pages. + Alle verfügbaren Aspire-Dokumentationsseiten auflisten. Loading documentation... - Loading documentation... + Dokumentation wird geladen... No documentation available. The aspire.dev docs may not have loaded correctly. - No documentation available. The aspire.dev docs may not have loaded correctly. + Keine Dokumente verfügbar. Die aspire.dev-Dokumente wurden möglicherweise nicht korrekt geladen. No results found for '{0}'. Try different search terms. - No results found for '{0}'. Try different search terms. + Für „{0}“ wurden keine Ergebnisse gefunden. Probieren Sie andere Suchbegriffe aus. The search query. - The search query. + Die Suchabfrage. Search Aspire documentation by keywords. - Search Aspire documentation by keywords. + Aspire-Dokumentation nach Schlüsselwörtern suchen. Return only the specified section of the page. - Return only the specified section of the page. + Nur den angegebenen Abschnitt der Seite zurückgeben. The slug of the documentation page (e.g., 'redis-integration'). - The slug of the documentation page (e.g., 'redis-integration'). + Der Slug der Dokumentationsseite (z. B. „redis-integration“). diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.es.xlf index 36fa8a87e45..ef40cfd3532 100644 --- a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.es.xlf @@ -4,77 +4,77 @@ Browse and search Aspire documentation from aspire.dev. - Browse and search Aspire documentation from aspire.dev. + Examine y busque la documentación de Aspire en aspire.dev. Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. - Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + No se encontró la página de documentación '{0}'. Use 'aspire docs list' para ver las páginas disponibles. Output format (Table or Json). - Output format (Table or Json). + Formato de salida (tabla o JSON). Found {0} documentation pages. - Found {0} documentation pages. + Se encontraron {0} páginas de documentación. Found {0} results for '{1}'. - Found {0} results for '{1}'. + Se encontraron {0} resultados para '{1}'. Get the full content of a documentation page by its slug. - Get the full content of a documentation page by its slug. + Obtenga el contenido completo de una página de documentación por su identificador. Maximum number of search results to return (default: 5, max: 10). - Maximum number of search results to return (default: 5, max: 10). + Número máximo de resultados de búsqueda que se devolverán (predeterminado: 5, máximo: 10). List all available Aspire documentation pages. - List all available Aspire documentation pages. + Muestra todas las páginas de documentación disponibles de Aspire. Loading documentation... - Loading documentation... + Cargando documentación... No documentation available. The aspire.dev docs may not have loaded correctly. - No documentation available. The aspire.dev docs may not have loaded correctly. + No hay documentación disponible. Es posible que los documentos de aspire.dev no se hayan cargado correctamente. No results found for '{0}'. Try different search terms. - No results found for '{0}'. Try different search terms. + No se encontraron resultados para "{0}". Intente términos de búsqueda diferentes. The search query. - The search query. + Consulta de búsqueda. Search Aspire documentation by keywords. - Search Aspire documentation by keywords. + Busque en la documentación de Aspire por palabras clave. Return only the specified section of the page. - Return only the specified section of the page. + Devuelve solo la sección especificada de la página. The slug of the documentation page (e.g., 'redis-integration'). - The slug of the documentation page (e.g., 'redis-integration'). + El identificador de la página de documentación (por ejemplo, 'redis-integration'). diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.fr.xlf index 3ed0b31a630..fb9f70ee3bc 100644 --- a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.fr.xlf @@ -4,77 +4,77 @@ Browse and search Aspire documentation from aspire.dev. - Browse and search Aspire documentation from aspire.dev. + Parcourir et rechercher la documentation Aspire sur aspire.dev. Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. - Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Page de documentation « {0} » introuvable. Utilisez « aspire docs list » pour afficher les pages disponibles. Output format (Table or Json). - Output format (Table or Json). + Format de sortie (Tableau ou JSON). Found {0} documentation pages. - Found {0} documentation pages. + {0} pages de documentation trouvées. Found {0} results for '{1}'. - Found {0} results for '{1}'. + {0} résultats trouvés pour « {1} ». Get the full content of a documentation page by its slug. - Get the full content of a documentation page by its slug. + Obtenez le contenu complet d’une page de documentation grâce à son identifiant. Maximum number of search results to return (default: 5, max: 10). - Maximum number of search results to return (default: 5, max: 10). + Nombre maximal de résultats de recherche à retourner (par défaut : 5, max : 10). List all available Aspire documentation pages. - List all available Aspire documentation pages. + Listez toutes les pages de documentation Aspire disponibles. Loading documentation... - Loading documentation... + Chargement en cours de la documentation... Merci de patienter. No documentation available. The aspire.dev docs may not have loaded correctly. - No documentation available. The aspire.dev docs may not have loaded correctly. + Désolé, aucune documentation disponible. Les documents aspire.dev n’ont peut-être pas été chargés correctement. No results found for '{0}'. Try different search terms. - No results found for '{0}'. Try different search terms. + Aucun résultat trouvé pour « {0} ». Essayez différents termes de recherche. The search query. - The search query. + La requête de recherche. Search Aspire documentation by keywords. - Search Aspire documentation by keywords. + Rechercher la documentation Aspire par mots-clés. Return only the specified section of the page. - Return only the specified section of the page. + Retourner uniquement la section spécifiée de la page. The slug of the documentation page (e.g., 'redis-integration'). - The slug of the documentation page (e.g., 'redis-integration'). + Identifiant de la page de documentation (par ex., « redis-integration »). diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.it.xlf index 1371df05b74..70f64c6201f 100644 --- a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.it.xlf @@ -4,77 +4,77 @@ Browse and search Aspire documentation from aspire.dev. - Browse and search Aspire documentation from aspire.dev. + Esplorare e cercare la documentazione di Aspire su aspire.dev. Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. - Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Pagina della documentazione '{0}' non trovata. Usare 'aspire docs list' per vedere le pagine disponibili. Output format (Table or Json). - Output format (Table or Json). + Formato di output (Tabella o JSON). Found {0} documentation pages. - Found {0} documentation pages. + Trovate {0} pagine di documentazione. Found {0} results for '{1}'. - Found {0} results for '{1}'. + Sono stati trovati {0} risultati per '{1}'. Get the full content of a documentation page by its slug. - Get the full content of a documentation page by its slug. + Ottieni il contenuto completo di una pagina della documentazione tramite il suo campo dati dinamico. Maximum number of search results to return (default: 5, max: 10). - Maximum number of search results to return (default: 5, max: 10). + Numero massimo di risultati della ricerca da restituire (predefinito: 5, massimo: 10). List all available Aspire documentation pages. - List all available Aspire documentation pages. + Elenca tutte le pagine di documentazione Aspire disponibili. Loading documentation... - Loading documentation... + Caricamento della documentazione in corso... No documentation available. The aspire.dev docs may not have loaded correctly. - No documentation available. The aspire.dev docs may not have loaded correctly. + Documentazione non disponibile. I documenti di aspire.dev potrebbero non essere stati caricati correttamente. No results found for '{0}'. Try different search terms. - No results found for '{0}'. Try different search terms. + Non sono stati trovati risultati per '{0}'. Provare a eseguire la ricerca con termini differenti. The search query. - The search query. + Query di ricerca. Search Aspire documentation by keywords. - Search Aspire documentation by keywords. + Cerca nella documentazione Aspire usando parole chiave. Return only the specified section of the page. - Return only the specified section of the page. + Restituisci solo la sezione specificata della pagina. The slug of the documentation page (e.g., 'redis-integration'). - The slug of the documentation page (e.g., 'redis-integration'). + Campo dati dinamico della pagina della documentazione (ad esempio, 'redis-integration'). diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ja.xlf index d8580346c21..2f7c1bb73b0 100644 --- a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.ja.xlf @@ -4,77 +4,77 @@ Browse and search Aspire documentation from aspire.dev. - Browse and search Aspire documentation from aspire.dev. + aspire.dev から Aspire ドキュメントを参照して検索します。 Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. - Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + ドキュメント ページ '{0}' が見つかりません。使用可能なページを表示するには、'aspire docs list' を使用します。 Output format (Table or Json). - Output format (Table or Json). + 出力形式 (テーブルまたは JSON)。 Found {0} documentation pages. - Found {0} documentation pages. + {0} 件のドキュメント ページが見つかりました。 Found {0} results for '{1}'. - Found {0} results for '{1}'. + '{1}' について {0} 件の結果が見つかりました。 Get the full content of a documentation page by its slug. - Get the full content of a documentation page by its slug. + スラッグを指定してドキュメント ページのすべてのコンテンツを取得します。 Maximum number of search results to return (default: 5, max: 10). - Maximum number of search results to return (default: 5, max: 10). + 返される検索結果の最大数 (既定値: 5、最大値: 10)。 List all available Aspire documentation pages. - List all available Aspire documentation pages. + 利用可能なすべての Aspire ドキュメント ページを一覧表示します。 Loading documentation... - Loading documentation... + ドキュメントを読み込んでいます... No documentation available. The aspire.dev docs may not have loaded correctly. - No documentation available. The aspire.dev docs may not have loaded correctly. + 使用可能なドキュメントがありません。aspire.dev ドキュメントが正しく読み込まれなかった可能性があります。 No results found for '{0}'. Try different search terms. - No results found for '{0}'. Try different search terms. + '{0}' の結果が見つかりません。別の検索語句でお試しください。 The search query. - The search query. + 検索クエリ。 Search Aspire documentation by keywords. - Search Aspire documentation by keywords. + キーワードを指定して Aspire ドキュメントを検索します。 Return only the specified section of the page. - Return only the specified section of the page. + ページの指定したセクションのみを返します。 The slug of the documentation page (e.g., 'redis-integration'). - The slug of the documentation page (e.g., 'redis-integration'). + ドキュメント ページのスラッグ (例: 'redis-integration')。 diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pl.xlf index 75500400648..3b253b5c9e7 100644 --- a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pl.xlf @@ -4,77 +4,77 @@ Browse and search Aspire documentation from aspire.dev. - Browse and search Aspire documentation from aspire.dev. + Przeglądaj i przeszukuj dokumentację Aspire z Aspire.dev. Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. - Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Nie znaleziono strony dokumentacji „{0}”. Użyj opcji „lista dokumentacji”, aby wyświetlić dostępne strony. Output format (Table or Json). - Output format (Table or Json). + Format danych wyjściowych (Tabela lub JSON). Found {0} documentation pages. - Found {0} documentation pages. + Znalezione strony dokumentacji: {0} Found {0} results for '{1}'. - Found {0} results for '{1}'. + Znaleziono wyniki ({0}) dla „{1}”. Get the full content of a documentation page by its slug. - Get the full content of a documentation page by its slug. + Pobierz pełną zawartość strony dokumentacji przy użyciu jej fragmentu. Maximum number of search results to return (default: 5, max: 10). - Maximum number of search results to return (default: 5, max: 10). + Maksymalna liczba wyników wyszukiwania do zwrócenia (wartość domyślna: 5, maksimum: 10). List all available Aspire documentation pages. - List all available Aspire documentation pages. + Wyświetl listę wszystkich dostępnych stron dokumentacji. Loading documentation... - Loading documentation... + Trwa ładowanie dokumentacji... No documentation available. The aspire.dev docs may not have loaded correctly. - No documentation available. The aspire.dev docs may not have loaded correctly. + Brak dostępnej dokumentacji. Dokumenty aspire.dev mogły nie zostać załadowane poprawnie. No results found for '{0}'. Try different search terms. - No results found for '{0}'. Try different search terms. + Nie znaleziono żadnych wyników dla „{0}”. Wypróbuj inne terminy wyszukiwania. The search query. - The search query. + Zapytanie wyszukiwania. Search Aspire documentation by keywords. - Search Aspire documentation by keywords. + Wyszukaj dokumentację aplikacji Wyszukaj według słów kluczowych. Return only the specified section of the page. - Return only the specified section of the page. + Zwróć tylko określoną sekcję strony. The slug of the documentation page (e.g., 'redis-integration'). - The slug of the documentation page (e.g., 'redis-integration'). + Fragment strony dokumentacji (np. „redis-integration”). diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pt-BR.xlf index 7c90f9a05eb..8d70da6445a 100644 --- a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.pt-BR.xlf @@ -4,12 +4,12 @@ Browse and search Aspire documentation from aspire.dev. - Browse and search Aspire documentation from aspire.dev. + Navegue e pesquise na documentação do Aspire em aspire.dev. Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. - Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Página de documentação '{0}' não encontrada. Use 'aspire docs list' para ver as páginas disponíveis. @@ -19,62 +19,62 @@ Found {0} documentation pages. - Found {0} documentation pages. + {0} páginas de documentação encontradas. Found {0} results for '{1}'. - Found {0} results for '{1}'. + {0} resultados encontrados para '{1}'. Get the full content of a documentation page by its slug. - Get the full content of a documentation page by its slug. + Obtenha o conteúdo completo de uma página de documentação pelo seu slug. Maximum number of search results to return (default: 5, max: 10). - Maximum number of search results to return (default: 5, max: 10). + Número máximo de resultados de pesquisa a serem retornados (padrão: 5, máx. 10). List all available Aspire documentation pages. - List all available Aspire documentation pages. + Liste todas as páginas de documentação disponíveis do Aspire. Loading documentation... - Loading documentation... + Carregando documentação... No documentation available. The aspire.dev docs may not have loaded correctly. - No documentation available. The aspire.dev docs may not have loaded correctly. + Nenhum documento disponível. Os documentos do aspire.dev podem não ter carregado corretamente. No results found for '{0}'. Try different search terms. - No results found for '{0}'. Try different search terms. + Nenhum resultado encontrado para '{0}'. Tente termos de pesquisa diferentes. The search query. - The search query. + A consulta de pesquisa. Search Aspire documentation by keywords. - Search Aspire documentation by keywords. + Pesquise a documentação do Aspire por palavras-chave. Return only the specified section of the page. - Return only the specified section of the page. + Retornar apenas a seção especificada da página. The slug of the documentation page (e.g., 'redis-integration'). - The slug of the documentation page (e.g., 'redis-integration'). + O slug da página de documentação (por exemplo, 'redis-integration'). diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.tr.xlf index a61c34b032c..3ca53792e07 100644 --- a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.tr.xlf @@ -4,77 +4,77 @@ Browse and search Aspire documentation from aspire.dev. - Browse and search Aspire documentation from aspire.dev. + aspire.dev adresinden Aspire belgelerini tarayın ve arayın. Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. - Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + Belge sayfası ‘{0}’ bulunamadı. Kullanılabilir sayfaları görmek için ‘aspire docs list’ komutunu kullanın. Output format (Table or Json). - Output format (Table or Json). + Çıktı biçimi (Tablo veya Json). Found {0} documentation pages. - Found {0} documentation pages. + {0} belge sayfası bulundu. Found {0} results for '{1}'. - Found {0} results for '{1}'. + ‘{1}’ için {0} sonuç bulundu. Get the full content of a documentation page by its slug. - Get the full content of a documentation page by its slug. + Bir belge sayfasının tüm içeriğini özel bilgi alanı ile alın. Maximum number of search results to return (default: 5, max: 10). - Maximum number of search results to return (default: 5, max: 10). + Döndürülecek maksimum arama sonucu sayısı (varsayılan: 5, maksimum: 10). List all available Aspire documentation pages. - List all available Aspire documentation pages. + Mevcut tüm Aspire belge sayfalarını listeleyin. Loading documentation... - Loading documentation... + Belgeler yükleniyor... No documentation available. The aspire.dev docs may not have loaded correctly. - No documentation available. The aspire.dev docs may not have loaded correctly. + Kullanılabilir belge yok. aspire.dev belgeleri doğru şekilde yüklenmemiş olabilir. No results found for '{0}'. Try different search terms. - No results found for '{0}'. Try different search terms. + ‘{0}’ için sonuç bulunamadı. Farklı arama terimleri deneyin. The search query. - The search query. + Arama sorgusu. Search Aspire documentation by keywords. - Search Aspire documentation by keywords. + Aspire belgelerini anahtar kelimelerle arayın. Return only the specified section of the page. - Return only the specified section of the page. + Sayfanın yalnızca belirtilen bölümünü döndür. The slug of the documentation page (e.g., 'redis-integration'). - The slug of the documentation page (e.g., 'redis-integration'). + Belge sayfasının başlığı (örneğin, ‘redis-integration’). diff --git a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hans.xlf index 089a1f27b74..8ebd5fa29ee 100644 --- a/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DocsCommandStrings.zh-Hans.xlf @@ -4,77 +4,77 @@ Browse and search Aspire documentation from aspire.dev. - Browse and search Aspire documentation from aspire.dev. + 从 aspire.dev 浏览和搜索 Aspire 文档。 Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. - Documentation page '{0}' not found. Use 'aspire docs list' to see available pages. + 找不到文档页面“{0}”。使用 "aspire docs list" 查看可用页面。 Output format (Table or Json). - Output format (Table or Json). + 输出格式(表格或 Json)。 Found {0} documentation pages. - Found {0} documentation pages. + 找到 {0} 个文档页面。 Found {0} results for '{1}'. - Found {0} results for '{1}'. + 为“{1}”找到 {0} 个结果。 Get the full content of a documentation page by its slug. - Get the full content of a documentation page by its slug. + 通过短标识获取文档页面的完整内容。 Maximum number of search results to return (default: 5, max: 10). - Maximum number of search results to return (default: 5, max: 10). + 要返回的最大搜索结果数(默认: 5,上限: 10)。 List all available Aspire documentation pages. - List all available Aspire documentation pages. + 列出所有可用的 Aspire 文档页面。 Loading documentation... - Loading documentation... + 正在加载文档... No documentation available. The aspire.dev docs may not have loaded correctly. - No documentation available. The aspire.dev docs may not have loaded correctly. + 无可用文档。aspire.dev 文档可能未正确加载。 No results found for '{0}'. Try different search terms. - No results found for '{0}'. Try different search terms. + 找不到“{0}”的结果。尝试其他搜索词。 The search query. - The search query. + 搜索查询。 Search Aspire documentation by keywords. - Search Aspire documentation by keywords. + 按关键字搜索 Aspire 文档。 Return only the specified section of the page. - Return only the specified section of the page. + 仅返回页面的指定部分。 The slug of the documentation page (e.g., 'redis-integration'). - The slug of the documentation page (e.g., 'redis-integration'). + 文档页面的短标识(例如 "redis-integration")。 diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf index 17b4fe1faa6..4af139bfded 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf @@ -4,22 +4,22 @@ Execute a command on a resource. - Execute a command on a resource. + Führen Sie einen Befehl auf einer Ressource aus. The name of the command to execute. - The name of the command to execute. + Der Name des auszuführenden Befehls. The name of the resource to execute the command on. - The name of the resource to execute the command on. + Der Name der Ressource, für die der Befehl ausgeführt werden soll. No in-scope AppHosts found. Showing all running AppHosts. - No in-scope AppHosts found. Showing all running AppHosts. + Es wurden keine AppHosts im Geltungsbereich gefunden. Es werden alle aktiven AppHosts angezeigt. @@ -34,12 +34,12 @@ Restart a running resource. - Restart a running resource. + Starten Sie eine laufende Ressource neu. The name of the resource to restart. - The name of the resource to restart. + Der Name der Ressource, die neu gestartet werden soll. @@ -49,17 +49,17 @@ Select which AppHost to connect to: - Select which AppHost to connect to: + Wählen Sie den AppHost aus, mit dem eine Verbindung hergestellt werden soll: Start a stopped resource. - Start a stopped resource. + Starten Sie eine gestoppte Ressource. The name of the resource to start. - The name of the resource to start. + Der Name der Ressource, die gestartet werden soll. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf index bb9f94bebab..897ed879f4c 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf @@ -4,22 +4,22 @@ Execute a command on a resource. - Execute a command on a resource. + Ejecute un comando en un recurso. The name of the command to execute. - The name of the command to execute. + Nombre del comando que se va a ejecutar. The name of the resource to execute the command on. - The name of the resource to execute the command on. + El nombre del recurso de destino en el que se ejecutará el comando. No in-scope AppHosts found. Showing all running AppHosts. - No in-scope AppHosts found. Showing all running AppHosts. + No se encontraron AppHosts dentro del ámbito. Mostrando todos los AppHosts en ejecución. @@ -34,12 +34,12 @@ Restart a running resource. - Restart a running resource. + Reinicie un recurso en ejecución. The name of the resource to restart. - The name of the resource to restart. + Nombre del recurso que se va a reiniciar. @@ -49,17 +49,17 @@ Select which AppHost to connect to: - Select which AppHost to connect to: + Seleccione a qué AppHost conectarse: Start a stopped resource. - Start a stopped resource. + Inicie un recurso detenido. The name of the resource to start. - The name of the resource to start. + Nombre del recurso que se va a iniciar. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf index e64214b443f..aabcc91a0be 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf @@ -4,22 +4,22 @@ Execute a command on a resource. - Execute a command on a resource. + Exécuter une commande sur une ressource. The name of the command to execute. - The name of the command to execute. + Le nom de la commande à exécuter. The name of the resource to execute the command on. - The name of the resource to execute the command on. + Nom de la ressource sur laquelle exécuter la commande. No in-scope AppHosts found. Showing all running AppHosts. - No in-scope AppHosts found. Showing all running AppHosts. + Aucun AppHost dans le périmètre n’a été trouvé. Affichage de tous les AppHosts en cours d’exécution. @@ -34,12 +34,12 @@ Restart a running resource. - Restart a running resource. + Redémarrer une ressource en cours d’exécution. The name of the resource to restart. - The name of the resource to restart. + Le nom de la ressource à redémarrer. @@ -49,17 +49,17 @@ Select which AppHost to connect to: - Select which AppHost to connect to: + Sélectionnez l’AppHost auquel vous connecter : Start a stopped resource. - Start a stopped resource. + Démarrer une ressource arrêtée. The name of the resource to start. - The name of the resource to start. + Le nom de la ressource à commencer. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf index dd5ec8b2c03..1547750f42e 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf @@ -4,22 +4,22 @@ Execute a command on a resource. - Execute a command on a resource. + Eseguire un comando su una risorsa. The name of the command to execute. - The name of the command to execute. + Nome del comando da eseguire. The name of the resource to execute the command on. - The name of the resource to execute the command on. + Nome della risorsa di destinazione rispetto al quale eseguire il comando. No in-scope AppHosts found. Showing all running AppHosts. - No in-scope AppHosts found. Showing all running AppHosts. + Nessun AppHost in ambito trovato. Visualizzazione di tutti gli AppHost in esecuzione. @@ -34,12 +34,12 @@ Restart a running resource. - Restart a running resource. + Riavviare una risorsa in esecuzione. The name of the resource to restart. - The name of the resource to restart. + Nome della risorsa da riavviare. @@ -49,17 +49,17 @@ Select which AppHost to connect to: - Select which AppHost to connect to: + Selezionare l'AppHost a cui connettersi: Start a stopped resource. - Start a stopped resource. + Avviare una risorsa arrestata. The name of the resource to start. - The name of the resource to start. + Nome della risorsa da avviare. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf index 73a1a93effb..98ff796b7f6 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf @@ -4,22 +4,22 @@ Execute a command on a resource. - Execute a command on a resource. + リソースでコマンドを実行します。 The name of the command to execute. - The name of the command to execute. + 実行するコマンドの名前。 The name of the resource to execute the command on. - The name of the resource to execute the command on. + コマンドを実行する対象のリソースの名前。 No in-scope AppHosts found. Showing all running AppHosts. - No in-scope AppHosts found. Showing all running AppHosts. + スコープ内の AppHost が見つかりません。実行中のすべての AppHost を表示しています。 @@ -34,12 +34,12 @@ Restart a running resource. - Restart a running resource. + 実行中のリソースを再起動します。 The name of the resource to restart. - The name of the resource to restart. + 再起動するリソースの名前。 @@ -49,17 +49,17 @@ Select which AppHost to connect to: - Select which AppHost to connect to: + 接続する AppHost を選択してください: Start a stopped resource. - Start a stopped resource. + 停止しているリソースを開始します。 The name of the resource to start. - The name of the resource to start. + 開始するリソースの名前。 diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf index d5f66e0e486..d01faaa7581 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf @@ -4,22 +4,22 @@ Execute a command on a resource. - Execute a command on a resource. + Wykonaj polecenie dla zasobu. The name of the command to execute. - The name of the command to execute. + Nazwa polecenia do wykonania. The name of the resource to execute the command on. - The name of the resource to execute the command on. + Nazwa zasobu, na którym ma zostać wykonane polecenie. No in-scope AppHosts found. Showing all running AppHosts. - No in-scope AppHosts found. Showing all running AppHosts. + Nie znaleziono hostów aplikacji w zakresie. Wyświetlanie wszystkich uruchomionych hostów aplikacji. @@ -34,12 +34,12 @@ Restart a running resource. - Restart a running resource. + Uruchom ponownie uruchomiony zasób. The name of the resource to restart. - The name of the resource to restart. + Nazwa zasobu do ponownego uruchomienia. @@ -49,17 +49,17 @@ Select which AppHost to connect to: - Select which AppHost to connect to: + Wybierz host aplikacji, z którym chcesz nawiązać połączenie: Start a stopped resource. - Start a stopped resource. + Uruchom zatrzymany zasób. The name of the resource to start. - The name of the resource to start. + Nazwa zasobu do uruchomienia. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf index d4e09d13b05..fe259e5a1d4 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf @@ -4,22 +4,22 @@ Execute a command on a resource. - Execute a command on a resource. + Execute um comando em um recurso. The name of the command to execute. - The name of the command to execute. + O nome do comando a ser executado. The name of the resource to execute the command on. - The name of the resource to execute the command on. + O nome do recurso no qual executar o comando. No in-scope AppHosts found. Showing all running AppHosts. - No in-scope AppHosts found. Showing all running AppHosts. + Nenhum AppHosts no escopo encontrado. Mostrando todos os AppHosts em execução. @@ -34,12 +34,12 @@ Restart a running resource. - Restart a running resource. + Reinicie um recurso em execução. The name of the resource to restart. - The name of the resource to restart. + O nome do recurso a ser reiniciado. @@ -49,17 +49,17 @@ Select which AppHost to connect to: - Select which AppHost to connect to: + Selecione a qual AppHost se conectar: Start a stopped resource. - Start a stopped resource. + Inicie um recurso parado. The name of the resource to start. - The name of the resource to start. + O nome do recurso a ser iniciado. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf index 9f8bf7d5597..aba0c8f33d2 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf @@ -4,22 +4,22 @@ Execute a command on a resource. - Execute a command on a resource. + Bir kaynak üzerinde bir komut çalıştırın. The name of the command to execute. - The name of the command to execute. + Yürütülecek komutun adı. The name of the resource to execute the command on. - The name of the resource to execute the command on. + Komutu yürütecek kaynağın adı. No in-scope AppHosts found. Showing all running AppHosts. - No in-scope AppHosts found. Showing all running AppHosts. + Kapsam dahilinde AppHost bulunamadı. Tüm çalışan AppHost'lar gösteriliyor. @@ -34,12 +34,12 @@ Restart a running resource. - Restart a running resource. + Çalışan bir kaynağı yeniden başlatın. The name of the resource to restart. - The name of the resource to restart. + Yeniden başlatılacak kaynağın adı. @@ -49,17 +49,17 @@ Select which AppHost to connect to: - Select which AppHost to connect to: + Bağlanmak istediğiniz AppHost'u seçin: Start a stopped resource. - Start a stopped resource. + Durdurulmuş bir kaynağı başlatın. The name of the resource to start. - The name of the resource to start. + Başlatılacak kaynağın adı. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf index 80c7cceb347..0fd237787d8 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf @@ -4,22 +4,22 @@ Execute a command on a resource. - Execute a command on a resource. + 在资源上执行命令。 The name of the command to execute. - The name of the command to execute. + 要执行的命令的名称。 The name of the resource to execute the command on. - The name of the resource to execute the command on. + 要在其上执行命令的资源的名称。 No in-scope AppHosts found. Showing all running AppHosts. - No in-scope AppHosts found. Showing all running AppHosts. + 未找到范围内的 AppHost。显示所有正在运行的 AppHost。 @@ -34,12 +34,12 @@ Restart a running resource. - Restart a running resource. + 重启正在运行的资源。 The name of the resource to restart. - The name of the resource to restart. + 要重启的资源的名称。 @@ -49,17 +49,17 @@ Select which AppHost to connect to: - Select which AppHost to connect to: + 选择要连接的 AppHost: Start a stopped resource. - Start a stopped resource. + 启动已停止的资源。 The name of the resource to start. - The name of the resource to start. + 要启动的资源的名称。 diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf index 5d4ada49a6f..a84792aa3c9 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf @@ -4,17 +4,17 @@ Display the animated Aspire CLI welcome banner. - Display the animated Aspire CLI welcome banner. + Zeigen Sie das animierte Willkommensbanner der Aspire-CLI an. CLI — version {0} - CLI — version {0} + CLI – Version {0} Welcome to the - Welcome to the + Willkommen beim diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf index 16d95b9a10c..6e12f19d4fd 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf @@ -4,17 +4,17 @@ Display the animated Aspire CLI welcome banner. - Display the animated Aspire CLI welcome banner. + Visualizzare il banner di benvenuto animato dell'interfaccia della riga di comando di Aspire. CLI — version {0} - CLI — version {0} + Interfaccia della riga di comando - Versione {0} Welcome to the - Welcome to the + Benvenuti in diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf index 5a98fbc5682..7d9b077c9c6 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf @@ -4,17 +4,17 @@ Display the animated Aspire CLI welcome banner. - Display the animated Aspire CLI welcome banner. + Wyświetl animowany baner powitalny interfejsu wiersza polecenia aplikacji Aspire. CLI — version {0} - CLI — version {0} + CLI — wersja {0} Welcome to the - Welcome to the + Witamy w diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf index 5ccffb04d9b..3b1454e764f 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf @@ -9,47 +9,47 @@ Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Die Dashboard-API ist nicht verfügbar. Stellen Sie sicher, dass der AppHost mit aktiviertem Dashboard ausgeführt wird. View telemetry data (logs, spans, traces) from a running Aspire application. - View telemetry data (logs, spans, traces) from a running Aspire application. + Zeigen Sie Telemetriedaten (Protokolle, Spans, Traces) einer laufenden Aspire-Anwendung an. Failed to fetch telemetry: {0} - Failed to fetch telemetry: {0} + Fehler beim Abrufen der Telemetrie: {0} Stream telemetry in real-time as it arrives. - Stream telemetry in real-time as it arrives. + Streamen Sie Telemetriedaten in Echtzeit, sobald sie eintreffen. Output format (Table or Json). - Output format (Table or Json). + Ausgabeformat (Tabelle oder JSON). Filter by error status (true to show only errors, false to exclude errors). - Filter by error status (true to show only errors, false to exclude errors). + Filtern Sie nach Fehlerstatus (true, um nur Fehler anzuzeigen; false, um Fehler auszuschließen). The --limit value must be a positive number. - The --limit value must be a positive number. + Der Wert für --limit muss eine positive Zahl sein. Maximum number of items to return. - Maximum number of items to return. + Maximale Anzahl zurückzugebender Elemente. View structured logs from the Dashboard telemetry API. - View structured logs from the Dashboard telemetry API. + Zeigen Sie strukturierte Protokolle aus der Dashboard-Telemetrie-API an. @@ -79,37 +79,37 @@ Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). - Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filtern Sie Protokolle nach minimaler Schwere (Trace, Debug, Information, Warnung, Fehler, Kritisch). View spans from the Dashboard telemetry API. - View spans from the Dashboard telemetry API. + Zeigen Sie Bereiche aus der Dashboard-Telemetrie-API an. The trace ID to view. If not specified, lists all traces. - The trace ID to view. If not specified, lists all traces. + Die anzuzeigende Trace-ID. Wenn keine angegeben wird, werden alle Traces aufgelistet. Filter by trace ID. - Filter by trace ID. + Nach Ablaufverfolgungs-ID filtern. Trace with ID '{0}' was not found. - Trace with ID '{0}' was not found. + Ablaufverfolgung mit der ID „{0}“ wurde nicht gefunden. View traces from the Dashboard telemetry API. - View traces from the Dashboard telemetry API. + Zeigen Sie Ablaufverfolgungen aus der Dashboard-Telemetrie-API an. Dashboard API returned unexpected content type. Expected JSON response. - Dashboard API returned unexpected content type. Expected JSON response. + Die Dashboard-API hat einen unerwarteten Inhaltstyp zurückgegeben. JSON-Antwort erwartet. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf index a8fd5bd765f..7184b111351 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf @@ -9,47 +9,47 @@ Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + L'API del dashboard non è disponibile. Verificare che AppHost sia in esecuzione con il dashboard abilitato. View telemetry data (logs, spans, traces) from a running Aspire application. - View telemetry data (logs, spans, traces) from a running Aspire application. + Visualizza i dati di telemetria (log, span, tracce) da un'applicazione Aspire in esecuzione. Failed to fetch telemetry: {0} - Failed to fetch telemetry: {0} + Non è possibile recuperare i dati di telemetria: {0} Stream telemetry in real-time as it arrives. - Stream telemetry in real-time as it arrives. + Trasmetti i dati di telemetria in tempo reale non appena arrivano. Output format (Table or Json). - Output format (Table or Json). + Formato di output (Tabella o JSON). Filter by error status (true to show only errors, false to exclude errors). - Filter by error status (true to show only errors, false to exclude errors). + Filtra per stato di errore (true per mostrare solo gli errori, false per escluderli). The --limit value must be a positive number. - The --limit value must be a positive number. + Il valore --limit deve essere un numero positivo. Maximum number of items to return. - Maximum number of items to return. + Numero massimo di elementi da restituire. View structured logs from the Dashboard telemetry API. - View structured logs from the Dashboard telemetry API. + Visualizza i log strutturati dall'API di telemetria del dashboard. @@ -79,37 +79,37 @@ Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). - Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filtra i log in base alla gravità minima (Traccia, Debug, Informazioni, Avviso, Errore, Critico). View spans from the Dashboard telemetry API. - View spans from the Dashboard telemetry API. + Visualizza gli span dall'API di telemetria del dashboard. The trace ID to view. If not specified, lists all traces. - The trace ID to view. If not specified, lists all traces. + L'ID traccia da visualizzare. Se non specificato, vengono elencate tutte le tracce. Filter by trace ID. - Filter by trace ID. + Filtra per ID traccia. Trace with ID '{0}' was not found. - Trace with ID '{0}' was not found. + Non sono state trovate tracce con ID '{0}'. View traces from the Dashboard telemetry API. - View traces from the Dashboard telemetry API. + Visualizza le tracce dall'API di telemetria del dashboard. Dashboard API returned unexpected content type. Expected JSON response. - Dashboard API returned unexpected content type. Expected JSON response. + L'API del dashboard ha restituito un tipo di contenuto imprevisto. Era prevista una risposta JSON. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf index 917b9556d59..e6d4780d993 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf @@ -9,47 +9,47 @@ Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. + Interfejs API pulpitu nawigacyjnego jest niedostępny. Upewnij się, że host aplikacji działa z włączonym pulpitem nawigacyjnym. View telemetry data (logs, spans, traces) from a running Aspire application. - View telemetry data (logs, spans, traces) from a running Aspire application. + Wyświetl dane telemetryczne (logi, zakresy, śledzenia) z uruchomionej aplikacji Aspire. Failed to fetch telemetry: {0} - Failed to fetch telemetry: {0} + Nie można pobrać telemetrii: {0} Stream telemetry in real-time as it arrives. - Stream telemetry in real-time as it arrives. + Strumieniuj telemetrię w czasie rzeczywistym, gdy tylko nadejdzie. Output format (Table or Json). - Output format (Table or Json). + Format wyjściowy (tabela lub JSON). Filter by error status (true to show only errors, false to exclude errors). - Filter by error status (true to show only errors, false to exclude errors). + Filtruj według statusu błędu (wartość true, aby pokazać tylko błędy, false, aby je wykluczyć). The --limit value must be a positive number. - The --limit value must be a positive number. + Wartość --limit musi być liczbą dodatnią. Maximum number of items to return. - Maximum number of items to return. + Maksymalna liczba elementów do zwrócenia. View structured logs from the Dashboard telemetry API. - View structured logs from the Dashboard telemetry API. + Wyświetl strukturalne logi z interfejsu API telemetrii pulpitu nawigacyjnego. @@ -79,37 +79,37 @@ Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). - Filter logs by minimum severity (Trace, Debug, Information, Warning, Error, Critical). + Filtruj logi według minimalnego poziomu ważności (Śledzenie, Debugowanie, Informacja, Ostrzeżenie, Błąd, Krytyczne). View spans from the Dashboard telemetry API. - View spans from the Dashboard telemetry API. + Wyświetl zakresy z interfejsu API telemetrii pulpitu nawigacyjnego. The trace ID to view. If not specified, lists all traces. - The trace ID to view. If not specified, lists all traces. + Identyfikator śledzenia do wyświetlenia. Jeśli nie zostanie podany, wyświetli listę wszystkich śledzeń. Filter by trace ID. - Filter by trace ID. + Filtruj według identyfikatora śledzenia. Trace with ID '{0}' was not found. - Trace with ID '{0}' was not found. + Nie znaleziono śledzenia o identyfikatorze „{0}”. View traces from the Dashboard telemetry API. - View traces from the Dashboard telemetry API. + Wyświetl ślady z interfejsu API telemetrii pulpitu nawigacyjnego. Dashboard API returned unexpected content type. Expected JSON response. - Dashboard API returned unexpected content type. Expected JSON response. + Interfejs API pulpitu nawigacyjnego zwrócił nieoczekiwany typ zawartości. Oczekiwano odpowiedzi w formacie JSON. diff --git a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.fr.xlf index 7179306ebdf..eee0ddae8cb 100644 --- a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.fr.xlf @@ -109,7 +109,7 @@ GitHub Copilot chat - Conversation GitHub Copilot + Copilot Chat GitHub @@ -164,7 +164,7 @@ GitHub Copilot chat - Conversation GitHub Copilot + Copilot Chat GitHub diff --git a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.ja.xlf index 27c1d0a230f..d2938ece828 100644 --- a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.ja.xlf @@ -4,7 +4,7 @@ Chat with GitHub Copilot - GitHub Copilot とチャットする + GitHub Copilotとのチャット @@ -109,7 +109,7 @@ GitHub Copilot chat - GitHub Copilot チャット + GitHub Copilot Chat @@ -164,7 +164,7 @@ GitHub Copilot chat - GitHub Copilot チャット + GitHub Copilot Chat diff --git a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.ko.xlf index 0946a5a320c..98492c53c3f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.ko.xlf @@ -49,12 +49,12 @@ To use GitHub Copilot in Aspire, you must have a recent version of your IDE installed, and your GitHub account needs to have access to GitHub Copilot.<ul style="margin-top:8px"><li>Visual Studio 2022 17.14 or later</li><li>VS Code and C# Dev Kit 1.19.63 or later</li></ul> - Aspire에서 GitHub Copilot을 사용하려면 최신 버전의 IDE가 설치되어 있어야 하며, GitHub 계정이 GitHub Copilot에 액세스할 수 있어야 합니다.<ul style="margin-top:8px"><li>Visual Studio 2022 17.14 이상</li><li>VS Code 및 C# Dev Kit 1.19.63 이상</li></ul> + Aspire에서 GitHub Copilot을 사용하려면 최신 버전의 IDE가 설치되어 있어야 하며, GitHub 계정에 GitHub Copilot에 대한 액세스 권한이 있어야 합니다.<ul style="margin-top:8px"><li>Visual Studio 2022 17.14 이상</li><li>VS Code 및 C# Dev Kit 1.19.63 이상</li></ul> Sign into VS Code with a GitHub account that has a Copilot subscription. Follow the steps in <a href="{0}" target="_blank">Set up GitHub Copilot in VS Code</a>. - Copilot 구독이 있는 GitHub 계정으로 VS Code에 로그인합니다. <a href="{0}" target="_blank">VS Code에서 GitHub Copilot 설정</a>의 단계를 따릅니다. + Copilot 구독이 있는 GitHub 계정으로 VS Code에 로그인합니다. <a href="{0}" target="_blank">VS Code에서 GitHub Copilot 설정</a>의 단계를 따르세요. {0} is a link @@ -109,7 +109,7 @@ GitHub Copilot chat - GitHub Copilot 채팅 + GitHub Copilot Chat @@ -134,7 +134,7 @@ Ask GitHub Copilot - GitHub Copilot에게 물어보기 + GitHub Copilot에게 질문하기 @@ -164,7 +164,7 @@ GitHub Copilot chat - GitHub Copilot 채팅 + GitHub Copilot Chat diff --git a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.pl.xlf index 46e1d12439a..732d5eec9aa 100644 --- a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.pl.xlf @@ -109,7 +109,7 @@ GitHub Copilot chat - Czat narzędzia GitHub Copilot + GitHub Copilot Chat @@ -164,7 +164,7 @@ GitHub Copilot chat - Czat narzędzia GitHub Copilot + GitHub Copilot Chat diff --git a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.pt-BR.xlf index 20ae39e3e08..a9c70a400a4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.pt-BR.xlf @@ -109,7 +109,7 @@ GitHub Copilot chat - Chat do GitHub Copilot + GitHub Copilot Chat @@ -164,7 +164,7 @@ GitHub Copilot chat - Chat do GitHub Copilot + GitHub Copilot Chat diff --git a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.ru.xlf index 27523dc02ed..8c1bd49a64c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/AIAssistant.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/AIAssistant.ru.xlf @@ -109,7 +109,7 @@ GitHub Copilot chat - Чат GitHub Copilot + GitHub Copilot Chat @@ -164,7 +164,7 @@ GitHub Copilot chat - Чат GitHub Copilot + GitHub Copilot Chat diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf index 6a56b944bbb..0039de13b5f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf @@ -559,7 +559,7 @@ You need to add the API key to Aspire MCP before you can use it. In GitHub Copilot Chat, select the Tools button, then the Aspire MCP server. Enter the API key below in the text box. - API 키를 사용하려면 Aspire MCP에 API 키를 추가해야 합니다. GitHub Copilot Chat에서 도구 단추를 선택한 다음 Aspire MCP 서버를 선택하세요. 아래 텍스트 상자에 API 키를 입력하세요. + API 키를 사용하려면 Aspire MCP에 API 키를 추가해야 합니다. GitHub Copilot Chat에서 도구 버튼을 선택한 후 Aspire MCP 서버를 선택하세요. 아래 텍스트 상자에 API 키를 입력하세요. diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf index d621b665d3b..e702b524c82 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.it.xlf @@ -34,32 +34,32 @@ Missing command - Missing command + Comando mancante Required command '{0}' was not found on PATH or at the specified location. - Required command '{0}' was not found on PATH or at the specified location. + Non è possibile trovare il comando richiesto '{0}' in PATH o nel percorso specificato. Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} - Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Non è possibile trovare il comando richiesto '{0}' in PATH o nel percorso specificato. Per le istruzioni di installazione, vedi: {1} Command '{0}' validation failed: {1} - Command '{0}' validation failed: {1} + Convalida del comando '{0}' non riuscita: {1} Command '{0}' validation failed: {1}. For installation instructions, see: {2} - Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Convalida del comando '{0}' non riuscita: {1}. Per le istruzioni di installazione, vedi: {2} Resource '{0}' may fail to start: {1} - Resource '{0}' may fail to start: {1} + L'avvio della risorsa '{0}' potrebbe non riuscire: {1} diff --git a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf index e56228edcd6..f2469fa3c3f 100644 --- a/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf +++ b/src/Aspire.Hosting/Resources/xlf/MessageStrings.pl.xlf @@ -34,32 +34,32 @@ Missing command - Missing command + Brakujące polecenie Required command '{0}' was not found on PATH or at the specified location. - Required command '{0}' was not found on PATH or at the specified location. + Nie znaleziono wymaganego polecenia „{0}” w ścieżce PATH lub w podanej lokalizacji. Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} - Required command '{0}' was not found on PATH or at the specified location. For installation instructions, see: {1} + Nie znaleziono wymaganego polecenia „{0}” w ścieżce PATH lub w podanej lokalizacji. Instrukcje instalacji znajdziesz pod adresem: {1} Command '{0}' validation failed: {1} - Command '{0}' validation failed: {1} + Walidacja polecenia „{0}” nie powiodła się: {1} Command '{0}' validation failed: {1}. For installation instructions, see: {2} - Command '{0}' validation failed: {1}. For installation instructions, see: {2} + Weryfikacja polecenia „{0}” nie powiodła się: {1}. Instrukcje instalacji znajdziesz pod adresem: {2} Resource '{0}' may fail to start: {1} - Resource '{0}' may fail to start: {1} + Uruchomienie zasobu „{0}” może się nie powieść: {1} From 9304c852f55164acf30c7231c1aa4a3291886305 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 5 Feb 2026 19:04:18 -0600 Subject: [PATCH 049/256] Allow subnet addressprefix to be a parameter. (#14358) * Allow subnet addressprefix to be a parameter. Follow-up feedback from #13108 * Add parameter to virtual network --- .../AzureSubnetResource.cs | 71 ++++++-- .../AzureVirtualNetworkExtensions.cs | 170 +++++++++++++----- .../AzureVirtualNetworkResource.cs | 41 +++++ .../AzureVirtualNetworkExtensionsTests.cs | 76 ++++++++ ...eterResource_GeneratesBicep.verified.bicep | 23 +++ ...eterResource_GeneratesBicep.verified.bicep | 33 ++++ 6 files changed, 362 insertions(+), 52 deletions(-) create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddAzureVirtualNetwork_WithParameterResource_GeneratesBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddSubnet_WithParameterResource_GeneratesBicep.verified.bicep diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs index ae962a7d3cf..7717c6d3674 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.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; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; @@ -13,35 +14,68 @@ namespace Aspire.Hosting.Azure; /// /// Represents an Azure Subnet resource. /// -/// The name of the resource. -/// The subnet name. -/// The address prefix for the subnet. -/// The parent Virtual Network resource. /// /// Use to configure specific properties. /// -public class AzureSubnetResource(string name, string subnetName, string addressPrefix, AzureVirtualNetworkResource parent) - : Resource(name), IResourceWithParent +public class AzureSubnetResource : Resource, IResourceWithParent { + // Backing field holds either string or ParameterResource + private readonly object _addressPrefix; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the resource. + /// The subnet name. + /// The address prefix for the subnet. + /// The parent Virtual Network resource. + public AzureSubnetResource(string name, string subnetName, string addressPrefix, AzureVirtualNetworkResource parent) + : base(name) + { + SubnetName = ThrowIfNullOrEmpty(subnetName); + _addressPrefix = ThrowIfNullOrEmpty(addressPrefix); + Parent = parent ?? throw new ArgumentNullException(nameof(parent)); + } + + /// + /// Initializes a new instance of the class with a parameterized address prefix. + /// + /// The name of the resource. + /// The subnet name. + /// The parameter resource containing the address prefix for the subnet. + /// The parent Virtual Network resource. + public AzureSubnetResource(string name, string subnetName, ParameterResource addressPrefix, AzureVirtualNetworkResource parent) + : base(name) + { + SubnetName = ThrowIfNullOrEmpty(subnetName); + _addressPrefix = addressPrefix ?? throw new ArgumentNullException(nameof(addressPrefix)); + Parent = parent ?? throw new ArgumentNullException(nameof(parent)); + } + /// /// Gets the subnet name. /// - public string SubnetName { get; } = ThrowIfNullOrEmpty(subnetName); + public string SubnetName { get; } /// - /// Gets the address prefix for the subnet (e.g., "10.0.1.0/24"). + /// Gets the address prefix for the subnet (e.g., "10.0.1.0/24"), or null if the address prefix is provided via a . /// - public string AddressPrefix { get; } = ThrowIfNullOrEmpty(addressPrefix); + public string? AddressPrefix => _addressPrefix as string; + + /// + /// Gets the parameter resource containing the address prefix for the subnet, or null if the address prefix is provided as a literal string. + /// + public ParameterResource? AddressPrefixParameter => _addressPrefix as ParameterResource; /// /// Gets the subnet Id output reference. /// - public BicepOutputReference Id => new($"{Infrastructure.NormalizeBicepIdentifier(Name)}_Id", parent); + public BicepOutputReference Id => new($"{Infrastructure.NormalizeBicepIdentifier(Name)}_Id", Parent); /// /// Gets the parent Azure Virtual Network resource. /// - public AzureVirtualNetworkResource Parent { get; } = parent ?? throw new ArgumentNullException(nameof(parent)); + public AzureVirtualNetworkResource Parent { get; } private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) => !string.IsNullOrEmpty(argument) ? argument : throw new ArgumentNullException(paramName); @@ -54,9 +88,22 @@ internal SubnetResource ToProvisioningEntity(AzureResourceInfrastructure infra, var subnet = new SubnetResource(Infrastructure.NormalizeBicepIdentifier(Name)) { Name = SubnetName, - AddressPrefix = AddressPrefix, }; + // Set the address prefix from either the literal string or the parameter + if (_addressPrefix is string addressPrefix) + { + subnet.AddressPrefix = addressPrefix; + } + else if (_addressPrefix is ParameterResource addressPrefixParameter) + { + subnet.AddressPrefix = addressPrefixParameter.AsProvisioningParameter(infra); + } + else + { + throw new UnreachableException("AddressPrefix must be set either as a string or a ParameterResource."); + } + if (dependsOn is not null) { subnet.DependsOn.Add(dependsOn); diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs index 3aad3dd2485..9f464e95b8d 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs @@ -38,8 +38,46 @@ public static IResourceBuilder AddAzureVirtualNetwo builder.AddAzureProvisioning(); - AzureVirtualNetworkResource resource = new(name, ConfigureVirtualNetwork); + AzureVirtualNetworkResource resource = new(name, ConfigureVirtualNetwork, addressPrefix); + return AddAzureVirtualNetworkCore(builder, resource); + } + + /// + /// Adds an Azure Virtual Network resource to the application model with a parameterized address prefix. + /// + /// The builder for the distributed application. + /// The name of the Azure Virtual Network resource. + /// The parameter resource containing the address prefix for the virtual network (e.g., "10.0.0.0/16"). + /// A reference to the . + /// + /// This example creates a virtual network with a parameterized address prefix: + /// + /// var vnetPrefix = builder.AddParameter("vnetPrefix"); + /// var vnet = builder.AddAzureVirtualNetwork("vnet", vnetPrefix); + /// var subnet = vnet.AddSubnet("pe-subnet", "10.0.1.0/24"); + /// + /// + public static IResourceBuilder AddAzureVirtualNetwork( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + IResourceBuilder addressPrefix) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(addressPrefix); + + builder.AddAzureProvisioning(); + + AzureVirtualNetworkResource resource = new(name, ConfigureVirtualNetwork, addressPrefix.Resource); + + return AddAzureVirtualNetworkCore(builder, resource); + } + + private static IResourceBuilder AddAzureVirtualNetworkCore( + IDistributedApplicationBuilder builder, + AzureVirtualNetworkResource resource) + { if (builder.ExecutionContext.IsRunMode) { // In run mode, we don't want to add the resource to the builder. @@ -47,57 +85,69 @@ public static IResourceBuilder AddAzureVirtualNetwo } return builder.AddResource(resource); + } - void ConfigureVirtualNetwork(AzureResourceInfrastructure infra) - { - var vnet = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, - (identifier, name) => + private static void ConfigureVirtualNetwork(AzureResourceInfrastructure infra) + { + var azureResource = (AzureVirtualNetworkResource)infra.AspireResource; + + var vnet = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, + (identifier, name) => + { + var resource = VirtualNetwork.FromExisting(identifier); + resource.Name = name; + return resource; + }, + (infrastructure) => + { + var vnet = new VirtualNetwork(infrastructure.AspireResource.GetBicepIdentifier()) { - var resource = VirtualNetwork.FromExisting(identifier); - resource.Name = name; - return resource; - }, - (infrastructure) => + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + + // Set the address prefix from either the literal string or the parameter + if (azureResource.AddressPrefix is { } addressPrefix) { - var vnet = new VirtualNetwork(infrastructure.AspireResource.GetBicepIdentifier()) + vnet.AddressSpace = new VirtualNetworkAddressSpace() { - AddressSpace = new VirtualNetworkAddressSpace() - { - AddressPrefixes = { addressPrefix ?? "10.0.0.0/16" } - }, - Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + AddressPrefixes = { addressPrefix } }; + } + else if (azureResource.AddressPrefixParameter is { } addressPrefixParameter) + { + vnet.AddressSpace = new VirtualNetworkAddressSpace() + { + AddressPrefixes = { addressPrefixParameter.AsProvisioningParameter(infrastructure) } + }; + } - return vnet; - }); - - var azureResource = (AzureVirtualNetworkResource)infra.AspireResource; + return vnet; + }); - // Add subnets - if (azureResource.Subnets.Count > 0) + // Add subnets + if (azureResource.Subnets.Count > 0) + { + // Chain subnet provisioning to ensure deployment doesn't fail + // due to parallel creation of subnets within the VNet. + ProvisionableResource? dependsOn = null; + foreach (var subnet in azureResource.Subnets) { - // Chain subnet provisioning to ensure deployment doesn't fail - // due to parallel creation of subnets within the VNet. - ProvisionableResource? dependsOn = null; - foreach (var subnet in azureResource.Subnets) - { - var cdkSubnet = subnet.ToProvisioningEntity(infra, dependsOn); - cdkSubnet.Parent = vnet; - infra.Add(cdkSubnet); + var cdkSubnet = subnet.ToProvisioningEntity(infra, dependsOn); + cdkSubnet.Parent = vnet; + infra.Add(cdkSubnet); - dependsOn = cdkSubnet; - } + dependsOn = cdkSubnet; } + } - // Output the VNet ID for references - infra.Add(new ProvisioningOutput("id", typeof(string)) - { - Value = vnet.Id - }); + // Output the VNet ID for references + infra.Add(new ProvisioningOutput("id", typeof(string)) + { + Value = vnet.Id + }); - // We need to output name so it can be referenced by others. - infra.Add(new ProvisioningOutput("name", typeof(string)) { Value = vnet.Name }); - } + // We need to output name so it can be referenced by others. + infra.Add(new ProvisioningOutput("name", typeof(string)) { Value = vnet.Name }); } /// @@ -129,6 +179,46 @@ public static IResourceBuilder AddSubnet( var subnet = new AzureSubnetResource(name, subnetName, addressPrefix, builder.Resource); + return AddSubnetCore(builder, subnet); + } + + /// + /// Adds an Azure Subnet to the Virtual Network with a parameterized address prefix. + /// + /// The Virtual Network resource builder. + /// The name of the subnet resource. + /// The parameter resource containing the address prefix for the subnet (e.g., "10.0.1.0/24"). + /// The subnet name in Azure. If null, the resource name is used. + /// A reference to the . + /// + /// This example adds a subnet with a parameterized address prefix: + /// + /// var subnetPrefix = builder.AddParameter("subnetPrefix"); + /// var vnet = builder.AddAzureVirtualNetwork("vnet"); + /// var subnet = vnet.AddSubnet("my-subnet", subnetPrefix); + /// + /// + public static IResourceBuilder AddSubnet( + this IResourceBuilder builder, + [ResourceName] string name, + IResourceBuilder addressPrefix, + string? subnetName = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(addressPrefix); + + subnetName ??= name; + + var subnet = new AzureSubnetResource(name, subnetName, addressPrefix.Resource, builder.Resource); + + return AddSubnetCore(builder, subnet); + } + + private static IResourceBuilder AddSubnetCore( + IResourceBuilder builder, + AzureSubnetResource subnet) + { builder.Resource.Subnets.Add(subnet); if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs index 203393fc420..e253c7c32a5 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkResource.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.Hosting.ApplicationModel; using Azure.Provisioning.Network; using Azure.Provisioning.Primitives; @@ -14,8 +15,23 @@ namespace Aspire.Hosting.Azure; public class AzureVirtualNetworkResource(string name, Action configureInfrastructure) : AzureProvisioningResource(name, configureInfrastructure) { + private const string DefaultAddressPrefix = "10.0.0.0/16"; + + // Backing field holds either string or ParameterResource + private readonly object _addressPrefix = DefaultAddressPrefix; + internal List Subnets { get; } = []; + /// + /// Gets the address prefix for the virtual network (e.g., "10.0.0.0/16"), or null if the address prefix is provided via a . + /// + public string? AddressPrefix => _addressPrefix as string; + + /// + /// Gets the parameter resource containing the address prefix for the virtual network, or null if the address prefix is provided as a literal string. + /// + public ParameterResource? AddressPrefixParameter => _addressPrefix as ParameterResource; + /// /// Gets the "id" output reference from the Azure Virtual Network resource. /// @@ -26,6 +42,31 @@ public class AzureVirtualNetworkResource(string name, Action public BicepOutputReference NameOutput => new("name", this); + /// + /// Initializes a new instance of the class with a string address prefix. + /// + /// The name of the resource. + /// Callback to configure the Azure Virtual Network resource. + /// The address prefix for the virtual network (e.g., "10.0.0.0/16"). + public AzureVirtualNetworkResource(string name, Action configureInfrastructure, string? addressPrefix) + : this(name, configureInfrastructure) + { + _addressPrefix = addressPrefix ?? DefaultAddressPrefix; + } + + /// + /// Initializes a new instance of the class with a parameterized address prefix. + /// + /// The name of the resource. + /// Callback to configure the Azure Virtual Network resource. + /// The parameter resource containing the address prefix for the virtual network. + public AzureVirtualNetworkResource(string name, Action configureInfrastructure, ParameterResource addressPrefix) + : this(name, configureInfrastructure) + { + ArgumentNullException.ThrowIfNull(addressPrefix); + _addressPrefix = addressPrefix; + } + /// public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) { diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs index e0b9a5db7c9..470e252ad89 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs @@ -30,6 +30,36 @@ public void AddAzureVirtualNetwork_WithCustomAddressPrefix() Assert.NotNull(vnet); Assert.Equal("myvnet", vnet.Resource.Name); + Assert.Equal("10.1.0.0/16", vnet.Resource.AddressPrefix); + Assert.Null(vnet.Resource.AddressPrefixParameter); + } + + [Fact] + public void AddAzureVirtualNetwork_WithParameterResource_CreatesResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnetPrefixParam = builder.AddParameter("vnetPrefix"); + var vnet = builder.AddAzureVirtualNetwork("myvnet", vnetPrefixParam); + + Assert.NotNull(vnet); + Assert.Equal("myvnet", vnet.Resource.Name); + Assert.Null(vnet.Resource.AddressPrefix); + Assert.Same(vnetPrefixParam.Resource, vnet.Resource.AddressPrefixParameter); + } + + [Fact] + public async Task AddAzureVirtualNetwork_WithParameterResource_GeneratesBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnetPrefixParam = builder.AddParameter("vnetPrefix"); + var vnet = builder.AddAzureVirtualNetwork("myvnet", vnetPrefixParam); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); + + await Verify(manifest.BicepText, extension: "bicep") + .UseMethodName("AddAzureVirtualNetwork_WithParameterResource_GeneratesBicep"); } [Fact] @@ -126,4 +156,50 @@ public void WithDelegatedSubnet_AddsAnnotationsToSubnetAndTarget() Assert.NotNull(delegationAnnotation); Assert.Equal("Microsoft.App/environments", delegationAnnotation.ServiceName); } + + [Fact] + public void AddSubnet_WithParameterResource_CreatesSubnetResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var addressPrefixParam = builder.AddParameter("subnetPrefix"); + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("mysubnet", addressPrefixParam); + + Assert.NotNull(subnet); + Assert.Equal("mysubnet", subnet.Resource.Name); + Assert.Equal("mysubnet", subnet.Resource.SubnetName); + Assert.Null(subnet.Resource.AddressPrefix); + Assert.Same(addressPrefixParam.Resource, subnet.Resource.AddressPrefixParameter); + Assert.Same(vnet.Resource, subnet.Resource.Parent); + } + + [Fact] + public void AddSubnet_WithParameterResource_AndCustomSubnetName() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var addressPrefixParam = builder.AddParameter("subnetPrefix"); + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("mysubnet", addressPrefixParam, subnetName: "custom-subnet-name"); + + Assert.Equal("mysubnet", subnet.Resource.Name); + Assert.Equal("custom-subnet-name", subnet.Resource.SubnetName); + Assert.Null(subnet.Resource.AddressPrefix); + Assert.Same(addressPrefixParam.Resource, subnet.Resource.AddressPrefixParameter); + } + + [Fact] + public async Task AddSubnet_WithParameterResource_GeneratesBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var addressPrefixParam = builder.AddParameter("subnetPrefix"); + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + vnet.AddSubnet("mysubnet", addressPrefixParam); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddAzureVirtualNetwork_WithParameterResource_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddAzureVirtualNetwork_WithParameterResource_GeneratesBicep.verified.bicep new file mode 100644 index 00000000000..e92965f0a9a --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddAzureVirtualNetwork_WithParameterResource_GeneratesBicep.verified.bicep @@ -0,0 +1,23 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param vnetPrefix string + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + vnetPrefix + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +output id string = myvnet.id + +output name string = myvnet.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddSubnet_WithParameterResource_GeneratesBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddSubnet_WithParameterResource_GeneratesBicep.verified.bicep new file mode 100644 index 00000000000..94e76663c5d --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddSubnet_WithParameterResource_GeneratesBicep.verified.bicep @@ -0,0 +1,33 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param subnetPrefix string + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource mysubnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'mysubnet' + properties: { + addressPrefix: subnetPrefix + } + parent: myvnet +} + +output mysubnet_Id string = mysubnet.id + +output id string = myvnet.id + +output name string = myvnet.name \ No newline at end of file From 527a2e7f3566b227843dd1a195de2ddff0ced6c2 Mon Sep 17 00:00:00 2001 From: "Matt Wicks [SSW]" Date: Fri, 6 Feb 2026 13:02:03 +1100 Subject: [PATCH 050/256] Fix typo in documentation for pnpm configuration (#14359) --- src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index 4d1bd7d52a6..0ccf6ee20cf 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -874,7 +874,7 @@ private static string[] GetDefaultYarnInstallArgs( } /// - /// Configures the Node.js resource to use pnmp as the package manager and optionally installs packages before the application starts. + /// Configures the Node.js resource to use pnpm as the package manager and optionally installs packages before the application starts. /// /// The NodeAppResource. /// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource. From 4db33d3d5d4fff0f46bf32b4329e480450fb01e6 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 05:03:47 +0000 Subject: [PATCH 051/256] Suppress banner display for `aspire --version` (#14350) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: radical <1472+radical@users.noreply.github.com> Co-authored-by: James Newton-King --- src/Aspire.Cli/Commands/RootCommand.cs | 12 +-- src/Aspire.Cli/Commands/RunCommand.cs | 2 +- src/Aspire.Cli/CommonOptionNames.cs | 29 +++++++ src/Aspire.Cli/Program.cs | 25 ++++--- src/Aspire.Cli/Properties/launchSettings.json | 5 ++ src/Aspire.Cli/Telemetry/TelemetryManager.cs | 7 +- .../Aspire.Cli.EndToEnd.Tests/BannerTests.cs | 9 ++- tests/Aspire.Cli.Tests/CliSmokeTests.cs | 29 +++++++ .../Commands/RootCommandTests.cs | 75 ++++++++++++++++--- .../Telemetry/TelemetryConfigurationTests.cs | 34 +++++++-- 10 files changed, 189 insertions(+), 38 deletions(-) create mode 100644 src/Aspire.Cli/CommonOptionNames.cs diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 2d03b6e6920..bc16361315b 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -19,38 +19,38 @@ namespace Aspire.Cli.Commands; internal sealed class RootCommand : BaseRootCommand { - public static readonly Option DebugOption = new("--debug", "-d") + public static readonly Option DebugOption = new(CommonOptionNames.Debug, CommonOptionNames.DebugShort) { Description = RootCommandStrings.DebugArgumentDescription, Recursive = true }; - public static readonly Option NonInteractiveOption = new("--non-interactive") + public static readonly Option NonInteractiveOption = new(CommonOptionNames.NonInteractive) { Description = "Run the command in non-interactive mode, disabling all interactive prompts and spinners", Recursive = true }; - public static readonly Option NoLogoOption = new("--nologo") + public static readonly Option NoLogoOption = new(CommonOptionNames.NoLogo) { Description = RootCommandStrings.NoLogoArgumentDescription, Recursive = true }; - public static readonly Option BannerOption = new("--banner") + public static readonly Option BannerOption = new(CommonOptionNames.Banner) { Description = RootCommandStrings.BannerArgumentDescription, Recursive = true }; - public static readonly Option WaitForDebuggerOption = new("--wait-for-debugger") + public static readonly Option WaitForDebuggerOption = new(CommonOptionNames.WaitForDebugger) { Description = RootCommandStrings.WaitForDebuggerArgumentDescription, Recursive = true, DefaultValueFactory = _ => false }; - public static readonly Option CliWaitForDebuggerOption = new("--cli-wait-for-debugger") + public static readonly Option CliWaitForDebuggerOption = new(CommonOptionNames.CliWaitForDebugger) { Description = RootCommandStrings.CliWaitForDebuggerArgumentDescription, Recursive = true, diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 3f30e98ae3d..954965a0eb6 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -614,7 +614,7 @@ public void ProcessResourceState(RpcResourceState resourceState, Action private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? passedAppHostProjectFile, bool isExtensionHost, CancellationToken cancellationToken) { - var format = parseResult.GetValue("--format"); + var format = parseResult.GetValue(s_formatOption); // Failure mode 1: Project not found var searchResult = await _projectLocator.UseOrFindAppHostProjectFileAsync( diff --git a/src/Aspire.Cli/CommonOptionNames.cs b/src/Aspire.Cli/CommonOptionNames.cs new file mode 100644 index 00000000000..a7a9fdc0c33 --- /dev/null +++ b/src/Aspire.Cli/CommonOptionNames.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; + +/// +/// Common command-line option names used for manual argument checks. +/// +internal static class CommonOptionNames +{ + public const string Version = "--version"; + public const string VersionShort = "-v"; + public const string Help = "--help"; + public const string HelpShort = "-h"; + public const string HelpAlt = "-?"; + public const string NoLogo = "--nologo"; + public const string Banner = "--banner"; + public const string Debug = "--debug"; + public const string DebugShort = "-d"; + public const string NonInteractive = "--non-interactive"; + public const string WaitForDebugger = "--wait-for-debugger"; + public const string CliWaitForDebugger = "--cli-wait-for-debugger"; + + /// + /// Options that represent informational commands (e.g. --version, --help) which should + /// opt out of telemetry and suppress first-run experience. + /// + public static readonly string[] InformationalOptionNames = [Version, Help, HelpShort, HelpAlt]; +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 65429a6f1b3..124e8587cc8 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -62,7 +62,7 @@ private static string GetGlobalSettingsPath() internal static async Task BuildApplicationAsync(string[] args, Dictionary? configurationValues = null) { // Check for --non-interactive flag early - var nonInteractive = args?.Any(a => a == "--non-interactive") ?? false; + var nonInteractive = args?.Any(a => a == CommonOptionNames.NonInteractive) ?? false; // Check if running MCP start command - all logs should go to stderr to keep stdout clean for MCP protocol // Support both old 'mcp start' and new 'agent mcp' commands @@ -110,9 +110,9 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar // separate TracerProvider instances: // - Azure Monitor provider with filtering (only exports activities with EXTERNAL_TELEMETRY=true) // - Diagnostic provider for OTLP/console exporters (exports all activities, DEBUG only) - builder.Services.AddSingleton(new TelemetryManager(builder.Configuration)); + builder.Services.AddSingleton(new TelemetryManager(builder.Configuration, args)); - var debugMode = args?.Any(a => a == "--debug" || a == "-d") ?? false; + var debugMode = args?.Any(a => a == CommonOptionNames.Debug || a == CommonOptionNames.DebugShort) ?? false; var extensionEndpoint = builder.Configuration[KnownConfigNames.ExtensionEndpoint]; if (debugMode && !isMcpStartCommand && extensionEndpoint is null) @@ -352,8 +352,13 @@ private static IConfigurationService BuildConfigurationService(IServiceProvider return new ConfigurationService(configuration, executionContext, globalSettingsFile); } - internal static async Task DisplayFirstTimeUseNoticeIfNeededAsync(IServiceProvider serviceProvider, bool noLogo, bool showBanner, CancellationToken cancellationToken = default) + internal static async Task DisplayFirstTimeUseNoticeIfNeededAsync(IServiceProvider serviceProvider, string[] args, CancellationToken cancellationToken = default) { + var configuration = serviceProvider.GetRequiredService(); + var isInformationalCommand = args.Any(a => CommonOptionNames.InformationalOptionNames.Contains(a)); + var noLogo = args.Any(a => a == CommonOptionNames.NoLogo) || configuration.GetBool(CliConfigNames.NoLogo, defaultValue: false) || isInformationalCommand; + var showBanner = args.Any(a => a == CommonOptionNames.Banner); + var sentinel = serviceProvider.GetRequiredService(); var isFirstRun = !sentinel.Exists(); @@ -377,7 +382,12 @@ internal static async Task DisplayFirstTimeUseNoticeIfNeededAsync(IServiceProvid consoleEnvironment.Error.WriteLine(); } - sentinel.CreateIfNotExists(); + // Don't persist the sentinel for informational commands (--version, --help, etc.) + // so the first-run experience is shown on the next real command invocation. + if (!isInformationalCommand) + { + sentinel.CreateIfNotExists(); + } } } @@ -442,10 +452,7 @@ public static async Task Main(string[] args) await app.StartAsync().ConfigureAwait(false); // Display first run experience if this is the first time the CLI is run on this machine - var configuration = app.Services.GetRequiredService(); - var noLogo = args.Any(a => a == "--nologo") || configuration.GetBool(CliConfigNames.NoLogo, defaultValue: false); - var showBanner = args.Any(a => a == "--banner"); - await DisplayFirstTimeUseNoticeIfNeededAsync(app.Services, noLogo, showBanner, cts.Token); + await DisplayFirstTimeUseNoticeIfNeededAsync(app.Services, args, cts.Token); var rootCommand = app.Services.GetRequiredService(); var invokeConfig = new InvocationConfiguration() diff --git a/src/Aspire.Cli/Properties/launchSettings.json b/src/Aspire.Cli/Properties/launchSettings.json index 3f8c48abb40..76e92970a0e 100644 --- a/src/Aspire.Cli/Properties/launchSettings.json +++ b/src/Aspire.Cli/Properties/launchSettings.json @@ -46,6 +46,11 @@ "commandLineArgs": "deploy", "workingDirectory": "../../playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost" }, + "--version": { + "commandName": "Project", + "dotnetRunMessages": true, + "commandLineArgs": "--version" + }, "new": { "commandName": "Project", "dotnetRunMessages": true, diff --git a/src/Aspire.Cli/Telemetry/TelemetryManager.cs b/src/Aspire.Cli/Telemetry/TelemetryManager.cs index 390cc3811be..8579537af6b 100644 --- a/src/Aspire.Cli/Telemetry/TelemetryManager.cs +++ b/src/Aspire.Cli/Telemetry/TelemetryManager.cs @@ -36,9 +36,12 @@ internal sealed class TelemetryManager /// Initializes a new instance of the class. /// /// The configuration to read telemetry settings from. - public TelemetryManager(IConfiguration configuration) + /// The command-line arguments. + public TelemetryManager(IConfiguration configuration, string[]? args = null) { - var telemetryOptOut = configuration.GetBool(AspireCliTelemetry.TelemetryOptOutConfigKey, defaultValue: false); + // Don't send telemetry for informational commands or if the user has opted out. + var hasOptOutArg = args?.Any(a => CommonOptionNames.InformationalOptionNames.Contains(a)) ?? false; + var telemetryOptOut = hasOptOutArg || configuration.GetBool(AspireCliTelemetry.TelemetryOptOutConfigKey, defaultValue: false); #if DEBUG var useOtlpExporter = !string.IsNullOrEmpty(configuration[AspireCliTelemetry.OtlpExporterEndpointConfigKey]); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs index e01500953cb..8a2f19ed57d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.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,6 +27,7 @@ public async Task Banner_DisplayedOnFirstRun() var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() + .WithDimensions(160, 48) .WithAsciinemaRecording(recordingPath) .WithPtyProcess("/bin/bash", ["--norc"]); @@ -57,13 +58,13 @@ public async Task Banner_DisplayedOnFirstRun() // Delete the first-time use sentinel file to simulate first run // The sentinel is stored at ~/.aspire/cli/cli.firstUseSentinel - // Using 'aspire --version' instead of 'aspire --help' because help output - // is long and would scroll the banner off the terminal screen. + // Using 'aspire cache clear' because it's not an informational + // command and so will show the banner. sequenceBuilder .ClearFirstRunSentinel(counter) .VerifySentinelDeleted(counter) .ClearScreen(counter) - .Type("aspire --version") + .Type("aspire cache clear") .Enter() .WaitUntil(s => { diff --git a/tests/Aspire.Cli.Tests/CliSmokeTests.cs b/tests/Aspire.Cli.Tests/CliSmokeTests.cs index 89eacdebae4..a5458d407f6 100644 --- a/tests/Aspire.Cli.Tests/CliSmokeTests.cs +++ b/tests/Aspire.Cli.Tests/CliSmokeTests.cs @@ -91,4 +91,33 @@ public void DebugOutputWritesToStderr() outputHelper.WriteLine(result.Process.StandardOutput.ReadToEnd()); } + + [Fact] + public void VersionFlagSuppressesBanner() + { + using var result = RemoteExecutor.Invoke(async () => + { + await using var outputWriter = new StringWriter(); + var oldOutput = Console.Out; + Console.SetOut(outputWriter); + + await Program.Main(["--version"]).DefaultTimeout(); + + Console.SetOut(oldOutput); + var output = outputWriter.ToString(); + + // Write to stdout so it can be captured by the test harness + Console.WriteLine($"Output: {output}"); + + // The output should only contain the version, not the animated banner + // The banner contains "Welcome to the" and ASCII art + Assert.DoesNotContain("Welcome to the", output); + Assert.DoesNotContain("█████", output); + + // The output should contain a version number + Assert.Contains(".", output); // Version should have at least one dot + }, options: s_remoteInvokeOptions); + + outputHelper.WriteLine(result.Process.StandardOutput.ReadToEnd()); + } } diff --git a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs index b17f285f771..c58ec07c433 100644 --- a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs @@ -89,7 +89,7 @@ public async Task FirstTimeUseNotice_BannerDisplayedWhenSentinelDoesNotExist() }); var provider = services.BuildServiceProvider(); - await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: false); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []); Assert.True(bannerService.WasBannerDisplayed); Assert.True(sentinel.WasCreated); @@ -111,7 +111,7 @@ public async Task FirstTimeUseNotice_BannerNotDisplayedWhenSentinelExists() }); var provider = services.BuildServiceProvider(); - await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: false); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []); Assert.False(bannerService.WasBannerDisplayed); Assert.False(sentinel.WasCreated); @@ -133,7 +133,7 @@ public async Task FirstTimeUseNotice_BannerNotDisplayedWithNoLogoArgument() }); var provider = services.BuildServiceProvider(); - await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: true, showBanner: false); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.NoLogo]); Assert.False(bannerService.WasBannerDisplayed); Assert.True(sentinel.WasCreated); @@ -162,7 +162,7 @@ public async Task FirstTimeUseNotice_BannerNotDisplayedWithNoLogoEnvironmentVari var configuration = provider.GetRequiredService(); var noLogo = configuration.GetBool(CliConfigNames.NoLogo, defaultValue: false); - await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo, showBanner: false); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []); Assert.False(bannerService.WasBannerDisplayed); Assert.True(sentinel.WasCreated); @@ -185,7 +185,7 @@ public async Task Banner_DisplayedWhenExplicitlyRequested() }); var provider = services.BuildServiceProvider(); - await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: true); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]); Assert.True(bannerService.WasBannerDisplayed); // Telemetry notice should NOT be shown since it's not first run @@ -206,9 +206,9 @@ public async Task Banner_CanBeInvokedMultipleTimes() var provider = services.BuildServiceProvider(); // Invoke multiple times (simulating multiple --banner calls) - await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: true); - await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: true); - await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: true); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]); Assert.Equal(3, bannerService.DisplayCount); } @@ -239,7 +239,7 @@ public async Task Banner_DisplayedOnFirstRunAndExplicitRequest() }); var provider = services.BuildServiceProvider(); - await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: true); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [CommonOptionNames.Banner]); Assert.True(bannerService.WasBannerDisplayed); Assert.True(sentinel.WasCreated); @@ -264,7 +264,7 @@ public async Task Banner_TelemetryNoticeShownOnFirstRun() }); var provider = services.BuildServiceProvider(); - await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: false); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []); var errorOutput = errorWriter.ToString(); Assert.Contains("Telemetry", errorOutput); @@ -286,9 +286,62 @@ public async Task Banner_TelemetryNoticeNotShownOnSubsequentRuns() }); var provider = services.BuildServiceProvider(); - await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, noLogo: false, showBanner: false); + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []); var errorOutput = errorWriter.ToString(); Assert.DoesNotContain("Telemetry", errorOutput); } + + [Theory] + [InlineData("--version")] + [InlineData("--help")] + [InlineData("-h")] + [InlineData("-?")] + public async Task InformationalFlag_SuppressesBannerAndDoesNotCreateSentinel(string flag) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false }; + var bannerService = new TestBannerService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.FirstTimeUseNoticeSentinelFactory = _ => sentinel; + options.BannerServiceFactory = _ => bannerService; + }); + var provider = services.BuildServiceProvider(); + + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, [flag]); + + // Informational flags set noLogo, which suppresses banner and telemetry notice + Assert.False(bannerService.WasBannerDisplayed); + // Sentinel should NOT be created for informational commands + Assert.False(sentinel.WasCreated); + } + + [Fact] + public async Task InformationalFlag_DoesNotCreateSentinel_OnSubsequentFirstRun() + { + // Verifies that running --version on first run doesn't mark first-run as complete, + // so a subsequent normal invocation still shows the first-run experience. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sentinel = new TestFirstTimeUseNoticeSentinel { SentinelExists = false }; + var bannerService = new TestBannerService(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.FirstTimeUseNoticeSentinelFactory = _ => sentinel; + options.BannerServiceFactory = _ => bannerService; + }); + var provider = services.BuildServiceProvider(); + + // First invocation with --version: should not create sentinel + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, ["--version"]); + Assert.False(sentinel.WasCreated); + + // Second invocation without informational flag: should create sentinel and show banner + await Program.DisplayFirstTimeUseNoticeIfNeededAsync(provider, []); + Assert.True(bannerService.WasBannerDisplayed); + Assert.True(sentinel.WasCreated); + } + } diff --git a/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs b/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs index 91cbdd14af9..bf57286c91a 100644 --- a/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs +++ b/tests/Aspire.Cli.Tests/Telemetry/TelemetryConfigurationTests.cs @@ -2,10 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Telemetry; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; #if DEBUG using Microsoft.AspNetCore.InternalTesting; #endif -using Microsoft.Extensions.DependencyInjection; namespace Aspire.Cli.Tests.Telemetry; @@ -18,7 +19,7 @@ public async Task AzureMonitor_Enabled_ByDefault() // should be enabled by default when telemetry is not opted out var config = new Dictionary(); - using var host = await Program.BuildApplicationAsync(["--help"], config); + using var host = await Program.BuildApplicationAsync([], config); var telemetryManager = host.Services.GetService(); Assert.NotNull(telemetryManager); @@ -35,7 +36,7 @@ public async Task AzureMonitor_Disabled_WhenOptOutSetToTrueValues(string optOutV [AspireCliTelemetry.TelemetryOptOutConfigKey] = optOutValue }; - using var host = await Program.BuildApplicationAsync(["--help"], config); + using var host = await Program.BuildApplicationAsync([], config); var telemetryManager = host.Services.GetRequiredService(); // When telemetry is opted out, Azure Monitor should not be enabled @@ -50,7 +51,7 @@ public async Task OtlpExporter_EnabledInDebugOnly_WhenEndpointProvided() [AspireCliTelemetry.OtlpExporterEndpointConfigKey] = "http://localhost:4317" }; - using var host = await Program.BuildApplicationAsync(["--help"], config); + using var host = await Program.BuildApplicationAsync([], config); var telemetryManager = host.Services.GetRequiredService(); @@ -74,7 +75,7 @@ public async Task DiagnosticProvider_IncludesReportedActivitySource() [AspireCliTelemetry.ConsoleExporterLevelConfigKey] = "Diagnostic" }; - using var host = await Program.BuildApplicationAsync(["--help"], config); + using var host = await Program.BuildApplicationAsync([], config); var telemetryManager = host.Services.GetRequiredService(); Assert.True(telemetryManager.HasDiagnosticProvider); @@ -91,4 +92,27 @@ public async Task DiagnosticProvider_IncludesReportedActivitySource() Assert.NotNull(diagnosticActivity); } #endif + + [Fact] + public void AzureMonitor_Disabled_WhenVersionFlagProvided() + { + var configuration = new ConfigurationBuilder().Build(); + + var manager = new TelemetryManager(configuration, ["--version"]); + + Assert.False(manager.HasAzureMonitor); + } + + [Theory] + [InlineData("--help")] + [InlineData("-h")] + [InlineData("-?")] + public void AzureMonitor_Disabled_ForAllHelpFlags(string flag) + { + var configuration = new ConfigurationBuilder().Build(); + + var manager = new TelemetryManager(configuration, [flag]); + + Assert.False(manager.HasAzureMonitor); + } } From 34246e7cc0194988dddf298645b6aae17d207f68 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 6 Feb 2026 17:11:10 +1100 Subject: [PATCH 052/256] Add AKS starter deployment E2E test (#14351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add AKS starter deployment E2E test (Phase 1) This adds a new end-to-end deployment test that validates Azure Kubernetes Service (AKS) infrastructure creation: - Creates resource group, ACR, and AKS cluster - Configures kubectl credentials - Verifies cluster connectivity - Cleans up resources after test Phase 1 focuses on infrastructure only - Aspire deployment will be added in subsequent phases. * Fix AKS test: register required resource providers Add step to register Microsoft.ContainerService and Microsoft.ContainerRegistry resource providers before attempting to create AKS resources. This fixes the MissingSubscriptionRegistration error when the subscription hasn't been configured for AKS usage. * Fix AKS test: use Standard_B2s_v2 VM size The subscription in westus3 doesn't have access to Standard_B2s, only the v2 series VMs. Changed to Standard_B2s_v2 which is available. * Fix AKS test: use Standard_D2s_v3 VM size The subscription has zero quota for B-series VMs in westus3. Changed to Standard_D2s_v3 which is a widely-available D-series VM with typical quota. * Add Phase 2 & 3: Aspire project creation, Helm chart generation, and AKS deployment Phase 2 additions: - Create Aspire starter project using 'aspire new' - Add Aspire.Hosting.Kubernetes package via 'aspire add' - Modify AppHost.cs to call AddKubernetesEnvironment() with ACR config - Login to ACR for Docker image push - Run 'aspire publish' to generate Helm charts and push images Phase 3 additions: - Deploy Helm chart to AKS using 'helm install' - Verify pods are running with kubectl - Verify deployments are healthy This completes the full end-to-end flow: AKS cluster creation -> Aspire project creation -> Helm chart generation -> Deployment to Kubernetes * Fix Kubernetes deployment: Add container build/push step Changes: - Remove invalid ContainerRegistry property from AddKubernetesEnvironment - Add pragma warning disable for experimental ASPIREPIPELINES001 - Add container build step using dotnet publish /t:PublishContainer - Push container images to ACR before Helm deployment - Override Helm image values with ACR image references The Kubernetes publisher generates Helm charts but doesn't build containers. We need to build and push containers separately using dotnet publish. * Fix duplicate Service ports in Kubernetes publisher When multiple endpoints resolve to the same port number, the Service manifest generator was creating duplicate port entries, which Kubernetes rejects as invalid. This fix deduplicates ports by (port, protocol) before adding them to the Service spec. Fixes the error: Service 'xxx-service' is invalid: spec.ports[1]: Duplicate value * Add explicit AKS-ACR attachment verification step Added Step 6 to explicitly run 'az aks update --attach-acr' after AKS cluster creation to ensure the AcrPull role assignment has properly propagated. This addresses potential image pull permission issues where AKS cannot pull images from the attached ACR. Also renumbered all subsequent steps to maintain proper ordering. * Fix AKS image pull: correct Helm value paths and add ACR check * Fix duplicate Service/container ports: compare underlying values not Helm expressions * Re-enable AppService deployment tests * Add endpoint verification via kubectl port-forward to AKS test * Wait for pods to be ready before port-forward verification * Use retry loop for health endpoint verification and log HTTP status codes * Use real app endpoints: /weatherforecast and / instead of /health * Improve comments explaining duplicate port dedup rationale * Refactor cleanup to async pattern matching other deployment tests * Fix duplicate K8s ports: skip DefaultHttpsEndpoint in ProcessEndpoints The Kubernetes publisher was generating duplicate Service/container ports (both 8080/TCP) for ProjectResources with default http+https endpoints. The root cause is that GenerateDefaultProjectEndpointMapping assigns the same default port 8080 to every endpoint with None target port. The proper fix mirrors the core framework's SetBothPortsEnvVariables() behavior: skip the DefaultHttpsEndpoint (which the container won't listen on — TLS termination happens at ingress/service mesh). The https endpoint still gets an EndpointMapping (for service discovery) but reuses the http endpoint's HelmValue, so no duplicate K8s port is generated. Added Aspire.Hosting.Kubernetes to InternalsVisibleTo to access ProjectResource.DefaultHttpsEndpoint. The downstream dedup in ToService() and WithContainerPorts() remains as defense-in-depth. Fixes https://github.com/dotnet/aspire/issues/14029 * Add AKS + Redis E2E deployment test Validates the Aspire starter template with Redis cache enabled deploys correctly to AKS. Exercises the full pipeline: webfrontend → apiservice → Redis by hitting the /weather page (SSR, uses Redis output caching). Key differences from the base AKS test: - Selects 'Yes' for Redis Cache in aspire new prompts - Redis uses public container image (no ACR push needed) - Verifies /weather page content (confirms Redis integration works) * Fix ACR name collision between parallel AKS tests Both AKS tests generated the same ACR name from RunId+RunAttempt. Use different prefixes (acrs/acrr) to ensure uniqueness. * Fix Redis Helm deployment: provide missing cross-resource secret value Work around K8s publisher bug where cross-resource secret references create Helm value paths under the consuming resource instead of referencing the owning resource's secret. The webfrontend template expects secrets.webfrontend.cache_password but values.yaml only has secrets.cache.REDIS_PASSWORD. Provide the missing value via --set. --------- Co-authored-by: Mitch Denny --- .../Extensions/ResourceExtensions.cs | 23 + .../KubernetesResource.cs | 21 +- src/Aspire.Hosting/Aspire.Hosting.csproj | 1 + .../AksStarterDeploymentTests.cs | 478 +++++++++++++++++ .../AksStarterWithRedisDeploymentTests.cs | 500 ++++++++++++++++++ .../AppServicePythonDeploymentTests.cs | 2 +- .../AppServiceReactDeploymentTests.cs | 2 +- .../ServiceA/deployment.verified.yaml | 3 - .../templates/ServiceA/service.verified.yaml | 4 - .../env1/values.verified.yaml | 1 - .../ServiceB/deployment.verified.yaml | 3 - .../templates/ServiceB/service.verified.yaml | 4 - .../env2/values.verified.yaml | 1 - ...netesWithProjectResources#01.verified.yaml | 1 - ...netesWithProjectResources#02.verified.yaml | 3 - ...netesWithProjectResources#03.verified.yaml | 4 - ...netesWithProjectResources#06.verified.yaml | 4 +- 17 files changed, 1026 insertions(+), 29 deletions(-) create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs index c7f265e46be..cb3f7ec6c36 100644 --- a/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs +++ b/src/Aspire.Hosting.Kubernetes/Extensions/ResourceExtensions.cs @@ -140,8 +140,22 @@ internal static StatefulSet ToStatefulSet(this IResource resource, KubernetesRes }, }; + // Defense-in-depth: deduplicate ports by underlying value and protocol. + // The primary fix is in ProcessEndpoints() which skips the DefaultHttpsEndpoint + // (matching the core framework's SetBothPortsEnvVariables behavior). This dedup + // remains as a safety net for edge cases where multiple endpoints might still + // resolve to the same port value. + // See: https://github.com/dotnet/aspire/issues/14029 + var addedPorts = new HashSet<(string Port, string Protocol)>(); foreach (var (_, mapping) in context.EndpointMappings) { + var portValue = mapping.Port.ValueString ?? mapping.Port.ToScalar(); + var portKey = (portValue, mapping.Protocol); + if (!addedPorts.Add(portKey)) + { + continue; // Skip duplicate port/protocol combinations + } + service.Spec.Ports.Add( new() { @@ -268,8 +282,17 @@ private static ContainerV1 WithContainerPorts(this ContainerV1 container, Kubern return container; } + // Defense-in-depth: deduplicate container ports (same rationale as ToService() above). + var addedPorts = new HashSet<(string Port, string Protocol)>(); foreach (var (_, mapping) in context.EndpointMappings) { + var portValue = mapping.Port.ValueString ?? mapping.Port.ToScalar(); + var portKey = (portValue, mapping.Protocol); + if (!addedPorts.Add(portKey)) + { + continue; + } + container.Ports.Add( new() { diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs index b2c7e7d2c16..e0f959a150e 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs @@ -171,7 +171,26 @@ private void ProcessEndpoints() if (resolved.TargetPort.Value is null) { - // Default endpoint for ProjectResource - deployment tool assigns port + // Default endpoint for ProjectResource - deployment tool assigns port. + // Skip the default https endpoint — the container won't listen on HTTPS. + // In Kubernetes, TLS termination is handled by ingress or service mesh. + // We still create an EndpointMapping (needed for service discovery env vars) + // but reuse the http endpoint's HelmValue so no duplicate K8s port is generated. + // This matches the core framework's SetBothPortsEnvVariables() behavior, + // which skips DefaultHttpsEndpoint when setting HTTPS_PORTS. + // See: https://github.com/dotnet/aspire/issues/14029 + if (resource is ProjectResource projectResource && + endpoint == projectResource.DefaultHttpsEndpoint) + { + // Find the existing http endpoint's HelmValue to share it + var httpMapping = EndpointMappings.Values.FirstOrDefault(m => m.Scheme == "http"); + if (httpMapping is not null) + { + EndpointMappings[endpoint.Name] = new(endpoint.UriScheme, GetKubernetesProtocolName(endpoint.Protocol), resource.Name.ToServiceName(), httpMapping.Port, endpoint.Name); + continue; + } + } + GenerateDefaultProjectEndpointMapping(endpoint); continue; } diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 85c736e02de..d112cec4833 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -118,6 +118,7 @@ + diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs new file mode 100644 index 00000000000..3b8b1a58bed --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -0,0 +1,478 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Aspire applications to Azure Kubernetes Service (AKS). +/// +public sealed class AksStarterDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 45 minutes to allow for AKS provisioning (~10-15 min) plus deployment. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(45); + + [Fact] + public async Task DeployStarterTemplateToAks() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployStarterTemplateToAksCore(cancellationToken); + } + + private async Task DeployStarterTemplateToAksCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateToAks)); + var startTime = DateTime.UtcNow; + + // Generate unique names for Azure resources + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("aks"); + var clusterName = $"aks-{DeploymentE2ETestHelpers.GetRunId()}-{DeploymentE2ETestHelpers.GetRunAttempt()}"; + // ACR names must be alphanumeric only, 5-50 chars, globally unique + var acrName = $"acrs{DeploymentE2ETestHelpers.GetRunId()}{DeploymentE2ETestHelpers.GetRunAttempt()}".ToLowerInvariant(); + // Ensure ACR name is valid (alphanumeric, 5-50 chars) + acrName = new string(acrName.Where(char.IsLetterOrDigit).Take(50).ToArray()); + if (acrName.Length < 5) + { + acrName = $"acrtest{Guid.NewGuid():N}"[..24]; + } + + output.WriteLine($"Test: {nameof(DeployStarterTemplateToAks)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"AKS Cluster: {clusterName}"); + output.WriteLine($"ACR Name: {acrName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Pattern searchers for aspire new interactive prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find("Enter the output path:"); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find("Do you want to create a test project?"); + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Project name for the Aspire application + var projectName = "AksStarter"; + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Register required resource providers + // AKS requires Microsoft.ContainerService and Microsoft.ContainerRegistry + output.WriteLine("Step 2: Registering required resource providers..."); + sequenceBuilder + .Type("az provider register --namespace Microsoft.ContainerService --wait && " + + "az provider register --namespace Microsoft.ContainerRegistry --wait") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 3: Create resource group + output.WriteLine("Step 3: Creating resource group..."); + sequenceBuilder + .Type($"az group create --name {resourceGroupName} --location westus3 --output table") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 4: Create Azure Container Registry + output.WriteLine("Step 4: Creating Azure Container Registry..."); + sequenceBuilder + .Type($"az acr create --resource-group {resourceGroupName} --name {acrName} --sku Basic --output table") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 5: Create AKS cluster with ACR attached + // Using minimal configuration: 1 node, Standard_D2s_v3 (widely available with quota) + output.WriteLine("Step 5: Creating AKS cluster (this may take 10-15 minutes)..."); + sequenceBuilder + .Type($"az aks create " + + $"--resource-group {resourceGroupName} " + + $"--name {clusterName} " + + $"--node-count 1 " + + $"--node-vm-size Standard_D2s_v3 " + + $"--generate-ssh-keys " + + $"--attach-acr {acrName} " + + $"--enable-managed-identity " + + $"--output table") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(20)); + + // Step 6: Ensure AKS can pull from ACR (update attachment to ensure role propagation) + output.WriteLine("Step 6: Verifying AKS-ACR integration..."); + sequenceBuilder + .Type($"az aks update --resource-group {resourceGroupName} --name {clusterName} --attach-acr {acrName}") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 7: Configure kubectl credentials + output.WriteLine("Step 7: Configuring kubectl credentials..."); + sequenceBuilder + .Type($"az aks get-credentials --resource-group {resourceGroupName} --name {clusterName} --overwrite-existing") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 8: Verify kubectl connectivity + output.WriteLine("Step 8: Verifying kubectl connectivity..."); + sequenceBuilder + .Type("kubectl get nodes") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Verify cluster is healthy + output.WriteLine("Step 9: Verifying cluster health..."); + sequenceBuilder + .Type("kubectl cluster-info") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // ===== PHASE 2: Create Aspire Project and Generate Helm Charts ===== + + // Step 10: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 10: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 11: Create starter project using aspire new with interactive prompts + output.WriteLine("Step 11: Creating Aspire starter project..."); + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first template (Starter App ASP.NET Core/Blazor) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Type(projectName) + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Accept default output path + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for localhost URLs (default) + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .Enter() // Select "No" for Redis Cache + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for test project (default) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 12: Navigate to project directory + output.WriteLine("Step 12: Navigating to project directory..."); + sequenceBuilder + .Type($"cd {projectName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 13: Add Aspire.Hosting.Kubernetes package + output.WriteLine("Step 13: Adding Kubernetes hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Kubernetes") + .Enter(); + + // In CI, aspire add shows a version selection prompt + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 14: Modify AppHost.cs to add Kubernetes environment + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost"); + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + + output.WriteLine($"Modifying AppHost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + // Insert the Kubernetes environment before builder.Build().Run(); + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Kubernetes environment for deployment +builder.AddKubernetesEnvironment("k8s"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + + // Add required pragma to suppress experimental warning + if (!content.Contains("#pragma warning disable ASPIREPIPELINES001")) + { + content = "#pragma warning disable ASPIREPIPELINES001\n" + content; + } + + File.WriteAllText(appHostFilePath, content); + + output.WriteLine("Modified AppHost.cs with AddKubernetesEnvironment"); + }); + + // Step 15: Navigate to AppHost project directory + output.WriteLine("Step 15: Navigating to AppHost directory..."); + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 16: Login to ACR for Docker push + output.WriteLine("Step 16: Logging into Azure Container Registry..."); + sequenceBuilder + .Type($"az acr login --name {acrName}") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 17: Build and push container images to ACR + // The starter template creates webfrontend and apiservice projects + output.WriteLine("Step 17: Building and pushing container images to ACR..."); + sequenceBuilder + .Type($"cd .. && " + + $"dotnet publish {projectName}.Web/{projectName}.Web.csproj " + + $"/t:PublishContainer " + + $"/p:ContainerRegistry={acrName}.azurecr.io " + + $"/p:ContainerImageName=webfrontend " + + $"/p:ContainerImageTag=latest") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + sequenceBuilder + .Type($"dotnet publish {projectName}.ApiService/{projectName}.ApiService.csproj " + + $"/t:PublishContainer " + + $"/p:ContainerRegistry={acrName}.azurecr.io " + + $"/p:ContainerImageName=apiservice " + + $"/p:ContainerImageTag=latest") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Navigate back to AppHost directory + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 18: Run aspire publish to generate Helm charts + output.WriteLine("Step 18: Running aspire publish to generate Helm charts..."); + sequenceBuilder + .Type($"aspire publish --output-path ../charts") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(10)); + + // Step 19: Verify Helm chart was generated + output.WriteLine("Step 19: Verifying Helm chart generation..."); + sequenceBuilder + .Type("ls -la ../charts && cat ../charts/Chart.yaml") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // ===== PHASE 3: Deploy to AKS and Verify ===== + + // Step 20: Verify ACR role assignment has propagated before deploying + output.WriteLine("Step 20: Verifying AKS can pull from ACR..."); + sequenceBuilder + .Type($"az aks check-acr --resource-group {resourceGroupName} --name {clusterName} --acr {acrName}.azurecr.io") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 21: Deploy Helm chart to AKS with ACR image overrides + // Image values use the path: parameters.._image + output.WriteLine("Step 21: Deploying Helm chart to AKS..."); + sequenceBuilder + .Type($"helm install aksstarter ../charts --namespace default --wait --timeout 10m " + + $"--set parameters.webfrontend.webfrontend_image={acrName}.azurecr.io/webfrontend:latest " + + $"--set parameters.apiservice.apiservice_image={acrName}.azurecr.io/apiservice:latest") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(12)); + + // Step 22: Wait for pods to be ready + output.WriteLine("Step 22: Waiting for pods to be ready..."); + sequenceBuilder + .Type("kubectl wait --for=condition=ready pod --all -n default --timeout=120s") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 23: Verify pods are running + output.WriteLine("Step 23: Verifying pods are running..."); + sequenceBuilder + .Type("kubectl get pods -n default") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 24: Verify deployments are healthy + output.WriteLine("Step 24: Verifying deployments..."); + sequenceBuilder + .Type("kubectl get deployments -n default") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 25: Verify apiservice is serving traffic via port-forward + // Use /weatherforecast (the actual API endpoint) since /health is only available in Development + output.WriteLine("Step 25: Verifying apiservice endpoint..."); + sequenceBuilder + .Type("kubectl port-forward svc/apiservice-service 18080:8080 &") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)) + .Type("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18080/weatherforecast -o /dev/null -w '%{http_code}' && echo ' OK' && break; done") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 26: Verify webfrontend is serving traffic via port-forward + output.WriteLine("Step 26: Verifying webfrontend endpoint..."); + sequenceBuilder + .Type("kubectl port-forward svc/webfrontend-service 18081:8080 &") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)) + .Type("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/ -o /dev/null -w '%{http_code}' && echo ' OK' && break; done") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 27: Clean up port-forwards + output.WriteLine("Step 27: Cleaning up port-forwards..."); + sequenceBuilder + .Type("kill %1 %2 2>/dev/null; true") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)); + + // Step 28: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Full AKS deployment completed in {duration}"); + + // Report success + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployStarterTemplateToAks), + resourceGroupName, + new Dictionary + { + ["cluster"] = clusterName, + ["acr"] = acrName, + ["project"] = projectName + }, + duration); + + output.WriteLine("✅ Test passed - Aspire app deployed to AKS via Helm!"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployStarterTemplateToAks), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + // Clean up the resource group we created (includes AKS cluster and ACR) + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Deletion initiated"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, $"Exit code {process.ExitCode}: {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, ex.Message); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs new file mode 100644 index 00000000000..2da92fa996a --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs @@ -0,0 +1,500 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Aspire starter template with Redis to AKS. +/// This validates that the starter template with Redis cache works out-of-the-box on Kubernetes. +/// +public sealed class AksStarterWithRedisDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 45 minutes to allow for AKS provisioning (~10-15 min) plus deployment. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(45); + + [Fact] + public async Task DeployStarterTemplateWithRedisToAks() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployStarterTemplateWithRedisToAksCore(cancellationToken); + } + + private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithRedisToAks)); + var startTime = DateTime.UtcNow; + + // Generate unique names for Azure resources + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("aksredis"); + var clusterName = $"aks-{DeploymentE2ETestHelpers.GetRunId()}-{DeploymentE2ETestHelpers.GetRunAttempt()}"; + // ACR names must be alphanumeric only, 5-50 chars, globally unique + var acrName = $"acrr{DeploymentE2ETestHelpers.GetRunId()}{DeploymentE2ETestHelpers.GetRunAttempt()}".ToLowerInvariant(); + acrName = new string(acrName.Where(char.IsLetterOrDigit).Take(50).ToArray()); + if (acrName.Length < 5) + { + acrName = $"acrtest{Guid.NewGuid():N}"[..24]; + } + + output.WriteLine($"Test: {nameof(DeployStarterTemplateWithRedisToAks)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"AKS Cluster: {clusterName}"); + output.WriteLine($"ACR Name: {acrName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Pattern searchers for aspire new interactive prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find("Enter the output path:"); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find("Do you want to create a test project?"); + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + var projectName = "AksRedis"; + + // ===== PHASE 1: Provision AKS Infrastructure ===== + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Register required resource providers + output.WriteLine("Step 2: Registering required resource providers..."); + sequenceBuilder + .Type("az provider register --namespace Microsoft.ContainerService --wait && " + + "az provider register --namespace Microsoft.ContainerRegistry --wait") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 3: Create resource group + output.WriteLine("Step 3: Creating resource group..."); + sequenceBuilder + .Type($"az group create --name {resourceGroupName} --location westus3 --output table") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 4: Create Azure Container Registry + output.WriteLine("Step 4: Creating Azure Container Registry..."); + sequenceBuilder + .Type($"az acr create --resource-group {resourceGroupName} --name {acrName} --sku Basic --output table") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 5: Create AKS cluster with ACR attached + output.WriteLine("Step 5: Creating AKS cluster (this may take 10-15 minutes)..."); + sequenceBuilder + .Type($"az aks create " + + $"--resource-group {resourceGroupName} " + + $"--name {clusterName} " + + $"--node-count 1 " + + $"--node-vm-size Standard_D2s_v3 " + + $"--generate-ssh-keys " + + $"--attach-acr {acrName} " + + $"--enable-managed-identity " + + $"--output table") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(20)); + + // Step 6: Ensure AKS can pull from ACR + output.WriteLine("Step 6: Verifying AKS-ACR integration..."); + sequenceBuilder + .Type($"az aks update --resource-group {resourceGroupName} --name {clusterName} --attach-acr {acrName}") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 7: Configure kubectl credentials + output.WriteLine("Step 7: Configuring kubectl credentials..."); + sequenceBuilder + .Type($"az aks get-credentials --resource-group {resourceGroupName} --name {clusterName} --overwrite-existing") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 8: Verify kubectl connectivity + output.WriteLine("Step 8: Verifying kubectl connectivity..."); + sequenceBuilder + .Type("kubectl get nodes") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Verify cluster health + output.WriteLine("Step 9: Verifying cluster health..."); + sequenceBuilder + .Type("kubectl cluster-info") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // ===== PHASE 2: Create Aspire Project with Redis and Generate Helm Charts ===== + + // Step 10: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 10: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 11: Create starter project with Redis enabled + output.WriteLine("Step 11: Creating Aspire starter project with Redis..."); + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first template (Starter App ASP.NET Core/Blazor) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Type(projectName) + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Accept default output path + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for localhost URLs (default) + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "Yes" for Redis Cache (first/default option) + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for test project (default) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 12: Navigate to project directory + output.WriteLine("Step 12: Navigating to project directory..."); + sequenceBuilder + .Type($"cd {projectName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 13: Add Aspire.Hosting.Kubernetes package + output.WriteLine("Step 13: Adding Kubernetes hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Kubernetes") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 14: Modify AppHost.cs to add Kubernetes environment + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost"); + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + + output.WriteLine($"Modifying AppHost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + // Insert the Kubernetes environment before builder.Build().Run(); + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Kubernetes environment for deployment +builder.AddKubernetesEnvironment("k8s"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + + // Add required pragma to suppress experimental warning + if (!content.Contains("#pragma warning disable ASPIREPIPELINES001")) + { + content = "#pragma warning disable ASPIREPIPELINES001\n" + content; + } + + File.WriteAllText(appHostFilePath, content); + + output.WriteLine("Modified AppHost.cs with AddKubernetesEnvironment"); + }); + + // Step 15: Navigate to AppHost project directory + output.WriteLine("Step 15: Navigating to AppHost directory..."); + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 16: Login to ACR for Docker push + output.WriteLine("Step 16: Logging into Azure Container Registry..."); + sequenceBuilder + .Type($"az acr login --name {acrName}") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 17: Build and push container images to ACR + // Only project resources need to be built — Redis uses a public container image + output.WriteLine("Step 17: Building and pushing container images to ACR..."); + sequenceBuilder + .Type($"cd .. && " + + $"dotnet publish {projectName}.Web/{projectName}.Web.csproj " + + $"/t:PublishContainer " + + $"/p:ContainerRegistry={acrName}.azurecr.io " + + $"/p:ContainerImageName=webfrontend " + + $"/p:ContainerImageTag=latest") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + sequenceBuilder + .Type($"dotnet publish {projectName}.ApiService/{projectName}.ApiService.csproj " + + $"/t:PublishContainer " + + $"/p:ContainerRegistry={acrName}.azurecr.io " + + $"/p:ContainerImageName=apiservice " + + $"/p:ContainerImageTag=latest") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Navigate back to AppHost directory + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 18: Run aspire publish to generate Helm charts + output.WriteLine("Step 18: Running aspire publish to generate Helm charts..."); + sequenceBuilder + .Type($"aspire publish --output-path ../charts") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(10)); + + // Step 19: Verify Helm chart was generated + output.WriteLine("Step 19: Verifying Helm chart generation..."); + sequenceBuilder + .Type("ls -la ../charts && cat ../charts/Chart.yaml && cat ../charts/values.yaml") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // ===== PHASE 3: Deploy to AKS and Verify ===== + + // Step 20: Verify ACR role assignment has propagated + output.WriteLine("Step 20: Verifying AKS can pull from ACR..."); + sequenceBuilder + .Type($"az aks check-acr --resource-group {resourceGroupName} --name {clusterName} --acr {acrName}.azurecr.io") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 21: Deploy Helm chart to AKS with ACR image overrides + // Only project resources need image overrides — Redis uses the public image from the chart + // Note: secrets.webfrontend.cache_password is a workaround for a K8s publisher bug where + // cross-resource secret references create Helm value paths under the consuming resource + // instead of referencing the owning resource's secret path (secrets.cache.REDIS_PASSWORD). + output.WriteLine("Step 21: Deploying Helm chart to AKS..."); + sequenceBuilder + .Type($"helm install aksredis ../charts --namespace default --wait --timeout 10m " + + $"--set parameters.webfrontend.webfrontend_image={acrName}.azurecr.io/webfrontend:latest " + + $"--set parameters.apiservice.apiservice_image={acrName}.azurecr.io/apiservice:latest " + + $"--set secrets.webfrontend.cache_password=\"\"") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(12)); + + // Step 22: Wait for all pods to be ready (including Redis) + output.WriteLine("Step 22: Waiting for pods to be ready..."); + sequenceBuilder + .Type("kubectl wait --for=condition=ready pod --all -n default --timeout=120s") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + // Step 23: Verify all pods are running + output.WriteLine("Step 23: Verifying pods are running..."); + sequenceBuilder + .Type("kubectl get pods -n default") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 24: Verify deployments are healthy + output.WriteLine("Step 24: Verifying deployments..."); + sequenceBuilder + .Type("kubectl get deployments -n default") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 25: Verify services (should include cache-service for Redis) + output.WriteLine("Step 25: Verifying services..."); + sequenceBuilder + .Type("kubectl get services -n default") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 26: Verify apiservice endpoint via port-forward + output.WriteLine("Step 26: Verifying apiservice /weatherforecast endpoint..."); + sequenceBuilder + .Type("kubectl port-forward svc/apiservice-service 18080:8080 &") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)) + .Type("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18080/weatherforecast -o /dev/null -w '%{http_code}' && echo ' OK' && break; done") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 27: Verify webfrontend root page via port-forward + output.WriteLine("Step 27: Verifying webfrontend root page..."); + sequenceBuilder + .Type("kubectl port-forward svc/webfrontend-service 18081:8080 &") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)) + .Type("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/ -o /dev/null -w '%{http_code}' && echo ' OK' && break; done") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 28: Verify webfrontend /weather page (exercises webfrontend → apiservice → Redis pipeline) + // The /weather page is server-side rendered and fetches data from the apiservice. + // Redis output caching is used, so this validates the full Redis integration. + output.WriteLine("Step 28: Verifying webfrontend /weather page (exercises Redis cache)..."); + sequenceBuilder + .Type("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/weather -o /dev/null -w '%{http_code}' && echo ' OK' && break; done") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 29: Verify /weather page actually returns weather data + output.WriteLine("Step 29: Verifying weather page content..."); + sequenceBuilder + .Type("curl -sf http://localhost:18081/weather | grep -q 'Weather' && echo 'Weather page content verified'") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 30: Clean up port-forwards + output.WriteLine("Step 30: Cleaning up port-forwards..."); + sequenceBuilder + .Type("kill %1 %2 2>/dev/null; true") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)); + + // Step 31: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Full AKS + Redis deployment completed in {duration}"); + + // Report success + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployStarterTemplateWithRedisToAks), + resourceGroupName, + new Dictionary + { + ["cluster"] = clusterName, + ["acr"] = acrName, + ["project"] = projectName + }, + duration); + + output.WriteLine("✅ Test passed - Aspire app with Redis deployed to AKS via Helm!"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployStarterTemplateWithRedisToAks), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Deletion initiated"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, $"Exit code {process.ExitCode}: {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: false, ex.Message); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs index 009b5cb5b4a..e4887306758 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs @@ -18,7 +18,7 @@ public sealed class AppServicePythonDeploymentTests(ITestOutputHelper output) // Full deployments can take up to 30 minutes if Azure infrastructure is backed up. private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); - [Fact(Skip = "App Service provisioning takes longer than 30 minutes, causing timeouts. Skipped until infrastructure issues are resolved.")] + [Fact] public async Task DeployPythonFastApiTemplateToAzureAppService() { using var cts = new CancellationTokenSource(s_testTimeout); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs index c74eb0ee30b..cd0509f3b69 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs @@ -18,7 +18,7 @@ public sealed class AppServiceReactDeploymentTests(ITestOutputHelper output) // Full deployments can take up to 30 minutes if Azure infrastructure is backed up. private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); - [Fact(Skip = "App Service provisioning takes longer than 30 minutes, causing timeouts. Skipped until infrastructure issues are resolved.")] + [Fact] public async Task DeployReactTemplateToAzureAppService() { using var cts = new CancellationTokenSource(s_testTimeout); diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/deployment.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/deployment.verified.yaml index 60785ea08d1..11ce95488f1 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/deployment.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/deployment.verified.yaml @@ -25,9 +25,6 @@ spec: - name: "http" protocol: "TCP" containerPort: {{ .Values.parameters.ServiceA.port_http | int }} - - name: "https" - protocol: "TCP" - containerPort: {{ .Values.parameters.ServiceA.port_https | int }} imagePullPolicy: "IfNotPresent" selector: matchLabels: diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/service.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/service.verified.yaml index 65a8c769cf8..d636769d76b 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/service.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/templates/ServiceA/service.verified.yaml @@ -18,7 +18,3 @@ spec: protocol: "TCP" port: {{ .Values.parameters.ServiceA.port_http | int }} targetPort: {{ .Values.parameters.ServiceA.port_http | int }} - - name: "https" - protocol: "TCP" - port: {{ .Values.parameters.ServiceA.port_https | int }} - targetPort: {{ .Values.parameters.ServiceA.port_https | int }} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/values.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/values.verified.yaml index 3d4081e7f04..b5c5767ba91 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/values.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env1/values.verified.yaml @@ -1,7 +1,6 @@ parameters: ServiceA: port_http: 8080 - port_https: 8080 ServiceA_image: "ServiceA:latest" secrets: {} config: diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/deployment.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/deployment.verified.yaml index 74f6939fea0..9cdc1d4bebd 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/deployment.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/deployment.verified.yaml @@ -25,9 +25,6 @@ spec: - name: "http" protocol: "TCP" containerPort: {{ .Values.parameters.ServiceB.port_http | int }} - - name: "https" - protocol: "TCP" - containerPort: {{ .Values.parameters.ServiceB.port_https | int }} imagePullPolicy: "IfNotPresent" selector: matchLabels: diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/service.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/service.verified.yaml index ee61637e165..6276ba51bf2 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/service.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/templates/ServiceB/service.verified.yaml @@ -18,7 +18,3 @@ spec: protocol: "TCP" port: {{ .Values.parameters.ServiceB.port_http | int }} targetPort: {{ .Values.parameters.ServiceB.port_http | int }} - - name: "https" - protocol: "TCP" - port: {{ .Values.parameters.ServiceB.port_https | int }} - targetPort: {{ .Values.parameters.ServiceB.port_https | int }} diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/values.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/values.verified.yaml index 22a6a3e458b..c3b216b559a 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/values.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesEnvironmentResourceTests.MultipleKubernetesEnvironmentsSupported/env2/values.verified.yaml @@ -1,7 +1,6 @@ parameters: ServiceB: port_http: 8080 - port_https: 8080 ServiceB_image: "ServiceB:latest" secrets: {} config: diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml index 68821ce999a..6875575cfda 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml @@ -1,7 +1,6 @@ parameters: project1: port_http: 8080 - port_https: 8080 project1_image: "project1:latest" secrets: {} config: diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#02.verified.yaml index cce4d052cb0..fc971617486 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#02.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#02.verified.yaml @@ -25,9 +25,6 @@ spec: - name: "http" protocol: "TCP" containerPort: {{ .Values.parameters.project1.port_http | int }} - - name: "https" - protocol: "TCP" - containerPort: {{ .Values.parameters.project1.port_https | int }} - name: "custom1" protocol: "TCP" containerPort: 8000 diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#03.verified.yaml index 5af5e5fc7c7..c8d84f6ffe5 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#03.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#03.verified.yaml @@ -18,10 +18,6 @@ spec: protocol: "TCP" port: {{ .Values.parameters.project1.port_http | int }} targetPort: {{ .Values.parameters.project1.port_http | int }} - - name: "https" - protocol: "TCP" - port: {{ .Values.parameters.project1.port_https | int }} - targetPort: {{ .Values.parameters.project1.port_https | int }} - name: "custom1" protocol: "TCP" port: 8000 diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml index 21e3c097b67..421858fefae 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml @@ -10,8 +10,8 @@ metadata: data: PROJECT1_HTTP: "http://project1-service:{{ .Values.parameters.project1.port_http }}" services__project1__http__0: "http://project1-service:{{ .Values.parameters.project1.port_http }}" - PROJECT1_HTTPS: "https://project1-service:{{ .Values.parameters.project1.port_https }}" - services__project1__https__0: "https://project1-service:{{ .Values.parameters.project1.port_https }}" + PROJECT1_HTTPS: "https://project1-service:{{ .Values.parameters.project1.port_http }}" + services__project1__https__0: "https://project1-service:{{ .Values.parameters.project1.port_http }}" PROJECT1_CUSTOM1: "{{ .Values.config.api.PROJECT1_CUSTOM1 }}" services__project1__custom1__0: "{{ .Values.config.api.services__project1__custom1__0 }}" PROJECT1_CUSTOM2: "{{ .Values.config.api.PROJECT1_CUSTOM2 }}" From 0c07cd78de161d4ff665ee3ca278efe507b27ee8 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Fri, 6 Feb 2026 01:40:08 -0500 Subject: [PATCH 053/256] Improve Aspire skill frontmatter for better AI agent routing (#14364) --- src/Aspire.Cli/Agents/CommonAgentApplicators.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs index a417b5e067e..198858b20ad 100644 --- a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs +++ b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs @@ -148,7 +148,7 @@ private static string NormalizeLineEndings(string content) """ --- name: aspire - description: Skills for working with the Aspire CLI. Review this for running and testing Aspire applications. + description: "**WORKFLOW SKILL** - Orchestrates Aspire applications using the Aspire CLI and MCP tools for running, debugging, and managing distributed apps. USE FOR: aspire run, aspire stop, start aspire app, check aspire resources, list aspire integrations, debug aspire issues, view aspire logs, add aspire resource, aspire dashboard, update aspire apphost. DO NOT USE FOR: non-Aspire .NET apps (use dotnet CLI), container-only deployments (use docker/podman), Azure deployment after local testing (use azure-deploy skill). INVOKES: Aspire MCP tools (list_resources, list_integrations, list_structured_logs, get_doc, search_docs), bash for CLI commands. FOR SINGLE OPERATIONS: Use Aspire MCP tools directly for quick resource status or doc lookups." --- # Aspire Skill From 4377e321b765f3917fb574acd5c26d84e71310b6 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 6 Feb 2026 09:43:50 -0600 Subject: [PATCH 054/256] Add AutoVerify to tests (#14357) - when a Verify test fails, it auto-accepts the changes, so you can see the diff - It fails the test (throwException: true) - It doesn't do this on "build servers" - https://github.com/VerifyTests/DiffEngine/blob/fef0776f63f0cfa1ac070f16d0925dee89faf9de/src/DiffEngine/BuildServerDetector.cs is the checks for what is considered a "build server" --- tests/Shared/TestModuleInitializer.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Shared/TestModuleInitializer.cs b/tests/Shared/TestModuleInitializer.cs index ef6c87d2860..55b5e63504d 100644 --- a/tests/Shared/TestModuleInitializer.cs +++ b/tests/Shared/TestModuleInitializer.cs @@ -31,5 +31,8 @@ internal static void Setup() "Snapshots"), typeName: type.Name, methodName: method.Name)); + + // auto-accept baseline changes when running locally + VerifierSettings.AutoVerify(includeBuildServer: false, throwException: true); } } From d350306715b472ea89b24b3ecd8d102a53e3efdb Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 6 Feb 2026 07:58:36 -0800 Subject: [PATCH 055/256] Add FileLoggerProvider for CLI log persistence and clean error messages (#14335) - Add Channel-based async FileLoggerProvider writing to ~/.aspire/logs/ - Add --debug-level (-v) option for console log verbosity - Show 'See logs at {path}' instead of raw stack traces on console - Centralize child process option forwarding via RootCommand.GetChildProcessArgs() - Suppress Microsoft.Hosting.Lifetime logs from file logger - Log backchannel socket errors at Debug level when AppHost has exited - Add log cleanup to 'aspire cache clear' - Make CliExecutionContext.LogFilePath a required constructor parameter Fixes #13787 --- src/Aspire.Cli/CliExecutionContext.cs | 14 +- src/Aspire.Cli/Commands/AddCommand.cs | 2 +- src/Aspire.Cli/Commands/CacheCommand.cs | 39 +++ src/Aspire.Cli/Commands/ExecCommand.cs | 6 +- src/Aspire.Cli/Commands/RootCommand.cs | 44 +++ src/Aspire.Cli/Commands/RunCommand.cs | 84 +++--- .../Diagnostics/FileLoggerProvider.cs | 256 ++++++++++++++++++ src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 5 +- src/Aspire.Cli/Program.cs | 82 +++++- .../Projects/DotNetAppHostProject.cs | 16 +- .../Resources/AddCommandStrings.resx | 3 +- src/Aspire.Cli/Resources/ErrorStrings.resx | 2 +- .../InteractionServiceStrings.Designer.cs | 11 +- .../Resources/InteractionServiceStrings.resx | 6 +- .../Resources/RootCommandStrings.Designer.cs | 9 + .../Resources/RootCommandStrings.resx | 3 + .../Resources/RunCommandStrings.resx | 2 +- .../Resources/TemplatingStrings.Designer.cs | 4 +- .../Resources/TemplatingStrings.resx | 8 +- .../Resources/xlf/AddCommandStrings.cs.xlf | 6 +- .../Resources/xlf/AddCommandStrings.de.xlf | 6 +- .../Resources/xlf/AddCommandStrings.es.xlf | 6 +- .../Resources/xlf/AddCommandStrings.fr.xlf | 6 +- .../Resources/xlf/AddCommandStrings.it.xlf | 6 +- .../Resources/xlf/AddCommandStrings.ja.xlf | 6 +- .../Resources/xlf/AddCommandStrings.ko.xlf | 6 +- .../Resources/xlf/AddCommandStrings.pl.xlf | 6 +- .../Resources/xlf/AddCommandStrings.pt-BR.xlf | 6 +- .../Resources/xlf/AddCommandStrings.ru.xlf | 6 +- .../Resources/xlf/AddCommandStrings.tr.xlf | 6 +- .../xlf/AddCommandStrings.zh-Hans.xlf | 6 +- .../xlf/AddCommandStrings.zh-Hant.xlf | 6 +- .../Resources/xlf/ErrorStrings.cs.xlf | 4 +- .../Resources/xlf/ErrorStrings.de.xlf | 4 +- .../Resources/xlf/ErrorStrings.es.xlf | 4 +- .../Resources/xlf/ErrorStrings.fr.xlf | 4 +- .../Resources/xlf/ErrorStrings.it.xlf | 4 +- .../Resources/xlf/ErrorStrings.ja.xlf | 4 +- .../Resources/xlf/ErrorStrings.ko.xlf | 4 +- .../Resources/xlf/ErrorStrings.pl.xlf | 4 +- .../Resources/xlf/ErrorStrings.pt-BR.xlf | 4 +- .../Resources/xlf/ErrorStrings.ru.xlf | 4 +- .../Resources/xlf/ErrorStrings.tr.xlf | 4 +- .../Resources/xlf/ErrorStrings.zh-Hans.xlf | 4 +- .../Resources/xlf/ErrorStrings.zh-Hant.xlf | 4 +- .../xlf/InteractionServiceStrings.cs.xlf | 9 +- .../xlf/InteractionServiceStrings.de.xlf | 9 +- .../xlf/InteractionServiceStrings.es.xlf | 9 +- .../xlf/InteractionServiceStrings.fr.xlf | 9 +- .../xlf/InteractionServiceStrings.it.xlf | 9 +- .../xlf/InteractionServiceStrings.ja.xlf | 9 +- .../xlf/InteractionServiceStrings.ko.xlf | 9 +- .../xlf/InteractionServiceStrings.pl.xlf | 9 +- .../xlf/InteractionServiceStrings.pt-BR.xlf | 9 +- .../xlf/InteractionServiceStrings.ru.xlf | 9 +- .../xlf/InteractionServiceStrings.tr.xlf | 9 +- .../xlf/InteractionServiceStrings.zh-Hans.xlf | 9 +- .../xlf/InteractionServiceStrings.zh-Hant.xlf | 9 +- .../Resources/xlf/RootCommandStrings.cs.xlf | 5 + .../Resources/xlf/RootCommandStrings.de.xlf | 5 + .../Resources/xlf/RootCommandStrings.es.xlf | 5 + .../Resources/xlf/RootCommandStrings.fr.xlf | 5 + .../Resources/xlf/RootCommandStrings.it.xlf | 5 + .../Resources/xlf/RootCommandStrings.ja.xlf | 5 + .../Resources/xlf/RootCommandStrings.ko.xlf | 5 + .../Resources/xlf/RootCommandStrings.pl.xlf | 5 + .../xlf/RootCommandStrings.pt-BR.xlf | 5 + .../Resources/xlf/RootCommandStrings.ru.xlf | 5 + .../Resources/xlf/RootCommandStrings.tr.xlf | 5 + .../xlf/RootCommandStrings.zh-Hans.xlf | 5 + .../xlf/RootCommandStrings.zh-Hant.xlf | 5 + .../Resources/xlf/RunCommandStrings.cs.xlf | 4 +- .../Resources/xlf/RunCommandStrings.de.xlf | 4 +- .../Resources/xlf/RunCommandStrings.es.xlf | 4 +- .../Resources/xlf/RunCommandStrings.fr.xlf | 4 +- .../Resources/xlf/RunCommandStrings.it.xlf | 4 +- .../Resources/xlf/RunCommandStrings.ja.xlf | 4 +- .../Resources/xlf/RunCommandStrings.ko.xlf | 4 +- .../Resources/xlf/RunCommandStrings.pl.xlf | 4 +- .../Resources/xlf/RunCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/RunCommandStrings.ru.xlf | 4 +- .../Resources/xlf/RunCommandStrings.tr.xlf | 4 +- .../xlf/RunCommandStrings.zh-Hans.xlf | 4 +- .../xlf/RunCommandStrings.zh-Hant.xlf | 4 +- .../Resources/xlf/TemplatingStrings.cs.xlf | 12 +- .../Resources/xlf/TemplatingStrings.de.xlf | 12 +- .../Resources/xlf/TemplatingStrings.es.xlf | 12 +- .../Resources/xlf/TemplatingStrings.fr.xlf | 12 +- .../Resources/xlf/TemplatingStrings.it.xlf | 12 +- .../Resources/xlf/TemplatingStrings.ja.xlf | 12 +- .../Resources/xlf/TemplatingStrings.ko.xlf | 12 +- .../Resources/xlf/TemplatingStrings.pl.xlf | 12 +- .../Resources/xlf/TemplatingStrings.pt-BR.xlf | 12 +- .../Resources/xlf/TemplatingStrings.ru.xlf | 12 +- .../Resources/xlf/TemplatingStrings.tr.xlf | 12 +- .../xlf/TemplatingStrings.zh-Hans.xlf | 12 +- .../xlf/TemplatingStrings.zh-Hant.xlf | 12 +- .../Templating/DotNetTemplateFactory.cs | 4 +- src/Aspire.Cli/Utils/AppHostHelper.cs | 18 +- src/Aspire.Cli/Utils/OutputCollector.cs | 31 +++ .../CopilotCliAgentEnvironmentScannerTests.cs | 4 + .../VsCodeAgentEnvironmentScannerTests.cs | 2 + .../Caching/DiskCacheTests.cs | 2 +- .../Commands/RunCommandTests.cs | 14 +- .../DotNet/DotNetCliRunnerTests.cs | 2 +- .../DotNetSdkInstallerTests.cs | 3 +- .../ConsoleInteractionServiceTests.cs | 30 +- .../Mcp/ListAppHostsToolTests.cs | 2 +- .../Mcp/MockPackagingService.cs | 4 +- .../NuGet/NuGetPackagePrefetcherTests.cs | 2 +- .../NuGetConfigMergerSnapshotTests.cs | 10 +- .../Packaging/PackagingServiceTests.cs | 24 +- .../Projects/ProjectLocatorTests.cs | 2 +- .../Projects/ProjectUpdaterTests.cs | 2 +- .../Templating/DotNetTemplateFactoryTests.cs | 2 +- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 10 +- 116 files changed, 938 insertions(+), 346 deletions(-) create mode 100644 src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs diff --git a/src/Aspire.Cli/CliExecutionContext.cs b/src/Aspire.Cli/CliExecutionContext.cs index 7f68c238fbf..062c2b6c35c 100644 --- a/src/Aspire.Cli/CliExecutionContext.cs +++ b/src/Aspire.Cli/CliExecutionContext.cs @@ -5,12 +5,24 @@ namespace Aspire.Cli; -internal sealed class CliExecutionContext(DirectoryInfo workingDirectory, DirectoryInfo hivesDirectory, DirectoryInfo cacheDirectory, DirectoryInfo sdksDirectory, bool debugMode = false, IReadOnlyDictionary? environmentVariables = null, DirectoryInfo? homeDirectory = null) +internal sealed class CliExecutionContext(DirectoryInfo workingDirectory, DirectoryInfo hivesDirectory, DirectoryInfo cacheDirectory, DirectoryInfo sdksDirectory, DirectoryInfo logsDirectory, string logFilePath, bool debugMode = false, IReadOnlyDictionary? environmentVariables = null, DirectoryInfo? homeDirectory = null) { public DirectoryInfo WorkingDirectory { get; } = workingDirectory; public DirectoryInfo HivesDirectory { get; } = hivesDirectory; public DirectoryInfo CacheDirectory { get; } = cacheDirectory; public DirectoryInfo SdksDirectory { get; } = sdksDirectory; + + /// + /// Gets the directory where CLI log files are stored. + /// Used by cache clear command to clean up old log files. + /// + public DirectoryInfo LogsDirectory { get; } = logsDirectory; + + /// + /// Gets the path to the current session's log file. + /// + public string LogFilePath { get; } = logFilePath; + public DirectoryInfo HomeDirectory { get; } = homeDirectory ?? new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); public bool DebugMode { get; } = debugMode; diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index c0c1053185f..c075bc02953 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -213,7 +213,7 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => { InteractionService.DisplayLines(outputCollector.GetLines()); } - InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.PackageInstallationFailed, ExitCodeConstants.FailedToAddPackage)); + InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, AddCommandStrings.PackageInstallationFailed, ExitCodeConstants.FailedToAddPackage, ExecutionContext.LogFilePath)); return ExitCodeConstants.FailedToAddPackage; } diff --git a/src/Aspire.Cli/Commands/CacheCommand.cs b/src/Aspire.Cli/Commands/CacheCommand.cs index 13043cb3f57..eef3d1501b7 100644 --- a/src/Aspire.Cli/Commands/CacheCommand.cs +++ b/src/Aspire.Cli/Commands/CacheCommand.cs @@ -108,6 +108,45 @@ protected override Task ExecuteAsync(ParseResult parseResult, CancellationT } } + // Also clear the logs directory (skip current process's log file) + var logsDirectory = ExecutionContext.LogsDirectory; + // Log files are named cli-{timestamp}-{pid}.log, so we need to check the suffix + var currentLogFileSuffix = $"-{Environment.ProcessId}.log"; + if (logsDirectory.Exists) + { + foreach (var file in logsDirectory.GetFiles("*", SearchOption.AllDirectories)) + { + // Skip the current process's log file to avoid deleting it while in use + if (file.Name.EndsWith(currentLogFileSuffix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + try + { + file.Delete(); + filesDeleted++; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + // Continue deleting other files even if some fail + } + } + + // Delete subdirectories + foreach (var directory in logsDirectory.GetDirectories()) + { + try + { + directory.Delete(recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + // Continue deleting other directories even if some fail + } + } + } + if (filesDeleted == 0) { InteractionService.DisplayMessage("information", CacheCommandStrings.CacheAlreadyEmpty); diff --git a/src/Aspire.Cli/Commands/ExecCommand.cs b/src/Aspire.Cli/Commands/ExecCommand.cs index 29707d34844..225266239b8 100644 --- a/src/Aspire.Cli/Commands/ExecCommand.cs +++ b/src/Aspire.Cli/Commands/ExecCommand.cs @@ -152,7 +152,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell env[KnownConfigNames.WaitForDebugger] = "true"; } - appHostCompatibilityCheck = await AppHostHelper.CheckAppHostCompatibilityAsync(_runner, InteractionService, effectiveAppHostProjectFile, Telemetry, ExecutionContext.WorkingDirectory, cancellationToken); + appHostCompatibilityCheck = await AppHostHelper.CheckAppHostCompatibilityAsync(_runner, InteractionService, effectiveAppHostProjectFile, Telemetry, ExecutionContext.WorkingDirectory, ExecutionContext.LogFilePath, cancellationToken); if (!appHostCompatibilityCheck?.IsCompatibleAppHost ?? throw new InvalidOperationException(RunCommandStrings.IsCompatibleAppHostIsNull)) { return ExitCodeConstants.FailedToDotnetRunAppHost; @@ -253,7 +253,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (result != 0) { InteractionService.DisplayLines(runOutputCollector.GetLines()); - InteractionService.DisplayError(RunCommandStrings.ProjectCouldNotBeRun); + InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, RunCommandStrings.ProjectCouldNotBeRun, ExecutionContext.LogFilePath)); return result; } else @@ -264,7 +264,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell else { InteractionService.DisplayLines(runOutputCollector.GetLines()); - InteractionService.DisplayError(RunCommandStrings.ProjectCouldNotBeRun); + InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, RunCommandStrings.ProjectCouldNotBeRun, ExecutionContext.LogFilePath)); return ExitCodeConstants.FailedToDotnetRunAppHost; } } diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index bc16361315b..64da8930f04 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.CommandLine.Help; +using Microsoft.Extensions.Logging; #if DEBUG using System.Globalization; @@ -22,6 +23,13 @@ internal sealed class RootCommand : BaseRootCommand public static readonly Option DebugOption = new(CommonOptionNames.Debug, CommonOptionNames.DebugShort) { Description = RootCommandStrings.DebugArgumentDescription, + Recursive = true, + Hidden = true // Hidden for backward compatibility, use --debug-level instead + }; + + public static readonly Option DebugLevelOption = new("--debug-level", "-v") + { + Description = RootCommandStrings.DebugLevelArgumentDescription, Recursive = true }; @@ -58,6 +66,41 @@ internal sealed class RootCommand : BaseRootCommand DefaultValueFactory = _ => false }; + /// + /// Global options that should be passed through to child CLI processes when spawning. + /// Add new global options here to ensure they are forwarded during detached mode execution. + /// + private static readonly (Option Option, Func GetArgs)[] s_childProcessOptions = + [ + (DebugOption, pr => pr.GetValue(DebugOption) ? ["--debug"] : null), + (DebugLevelOption, pr => + { + var level = pr.GetValue(DebugLevelOption); + return level.HasValue ? ["--debug-level", level.Value.ToString()] : null; + }), + (WaitForDebuggerOption, pr => pr.GetValue(WaitForDebuggerOption) ? ["--wait-for-debugger"] : null), + ]; + + /// + /// Gets the command-line arguments for global options that should be passed to a child CLI process. + /// + /// The parse result from the current command invocation. + /// Arguments to pass to the child process. + public static IEnumerable GetChildProcessArgs(ParseResult parseResult) + { + foreach (var (_, getArgs) in s_childProcessOptions) + { + var args = getArgs(parseResult); + if (args is not null) + { + foreach (var arg in args) + { + yield return arg; + } + } + } + } + private readonly IInteractionService _interactionService; public RootCommand( @@ -116,6 +159,7 @@ public RootCommand( #endif Options.Add(DebugOption); + Options.Add(DebugLevelOption); Options.Add(NonInteractiveOption); Options.Add(NoLogoOption); Options.Add(BannerOption); diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 954965a0eb6..ba6384a4269 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -66,6 +66,7 @@ internal sealed class RunCommand : BaseCommand private readonly ILogger _logger; private readonly IAppHostProjectFactory _projectFactory; private readonly IAuxiliaryBackchannelMonitor _backchannelMonitor; + private readonly Diagnostics.FileLoggerProvider _fileLoggerProvider; private static readonly Option s_projectOption = new("--project") { @@ -102,6 +103,7 @@ public RunCommand( ILogger logger, IAppHostProjectFactory projectFactory, IAuxiliaryBackchannelMonitor backchannelMonitor, + Diagnostics.FileLoggerProvider fileLoggerProvider, TimeProvider? timeProvider) : base("run", RunCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { @@ -118,6 +120,7 @@ public RunCommand( _logger = logger; _projectFactory = projectFactory; _backchannelMonitor = backchannelMonitor; + _fileLoggerProvider = fileLoggerProvider; _timeProvider = timeProvider ?? TimeProvider.System; Options.Add(s_projectOption); @@ -252,7 +255,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { InteractionService.DisplayLines(outputCollector.GetLines()); } - InteractionService.DisplayError(InteractionServiceStrings.ProjectCouldNotBeBuilt); + InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ProjectCouldNotBeBuilt, ExecutionContext.LogFilePath)); return await pendingRun; } @@ -261,12 +264,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell isExtensionHost ? InteractionServiceStrings.BuildingAppHost : RunCommandStrings.ConnectingToAppHost, async () => await backchannelCompletionSource.Task.WaitAsync(cancellationToken)); - // Set up log capture - var logFile = AppHostHelper.GetLogFilePath( - Environment.ProcessId, - ExecutionContext.HomeDirectory.FullName, - _timeProvider); - var pendingLogCapture = CaptureAppHostLogsAsync(logFile, backchannel, _interactionService, cancellationToken); + // Set up log capture - writes to unified CLI log file + var pendingLogCapture = CaptureAppHostLogsAsync(_fileLoggerProvider, backchannel, _interactionService, cancellationToken); // Get dashboard URLs var dashboardUrls = await InteractionService.ShowStatusAsync( @@ -286,7 +285,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell appHostRelativePath, dashboardUrls.BaseUrlWithLoginToken, dashboardUrls.CodespacesUrlWithLoginToken, - logFile.FullName, + _fileLoggerProvider.LogFilePath, isExtensionHost); // Handle remote environments (Codespaces, Remote Containers, SSH) @@ -375,10 +374,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ErrorConnectingToAppHost, ex.Message.EscapeMarkup()); Telemetry.RecordError(errorMessage, ex); InteractionService.DisplayError(errorMessage); - if (context?.OutputCollector is { } outputCollector) - { - InteractionService.DisplayLines(outputCollector.GetLines()); - } + // Don't display raw output - it's already in the log file + InteractionService.DisplayMessage("page_facing_up", string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath)); return ExitCodeConstants.FailedToDotnetRunAppHost; } catch (Exception ex) @@ -386,10 +383,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message.EscapeMarkup()); Telemetry.RecordError(errorMessage, ex); InteractionService.DisplayError(errorMessage); - if (context?.OutputCollector is { } outputCollector) - { - InteractionService.DisplayLines(outputCollector.GetLines()); - } + // Don't display raw output - it's already in the log file + InteractionService.DisplayMessage("page_facing_up", string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath)); return ExitCodeConstants.FailedToDotnetRunAppHost; } } @@ -516,22 +511,12 @@ internal static int RenderAppHostSummary( return longestLabelLength; } - private static async Task CaptureAppHostLogsAsync(FileInfo logFile, IAppHostCliBackchannel backchannel, IInteractionService interactionService, CancellationToken cancellationToken) + private static async Task CaptureAppHostLogsAsync(Diagnostics.FileLoggerProvider fileLoggerProvider, IAppHostCliBackchannel backchannel, IInteractionService interactionService, CancellationToken cancellationToken) { try { await Task.Yield(); - if (!logFile.Directory!.Exists) - { - logFile.Directory.Create(); - } - - using var streamWriter = new StreamWriter(logFile.FullName, append: true) - { - AutoFlush = true - }; - var logEntries = backchannel.GetAppHostLogEntriesAsync(cancellationToken); await foreach (var entry in logEntries.WithCancellation(cancellationToken)) @@ -545,7 +530,19 @@ private static async Task CaptureAppHostLogsAsync(FileInfo logFile, IAppHostCliB } } - await streamWriter.WriteLineAsync($"{entry.Timestamp:HH:mm:ss} [{entry.LogLevel}] {entry.CategoryName}: {entry.Message}"); + // Write to the unified log file via FileLoggerProvider + var timestamp = entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + var level = entry.LogLevel switch + { + LogLevel.Trace => "TRCE", + LogLevel.Debug => "DBUG", + LogLevel.Information => "INFO", + LogLevel.Warning => "WARN", + LogLevel.Error => "FAIL", + LogLevel.Critical => "CRIT", + _ => entry.LogLevel.ToString().ToUpperInvariant() + }; + fileLoggerProvider.WriteLog($"[{timestamp}] [{level}] [AppHost/{entry.CategoryName}] {entry.Message}"); } } catch (OperationCanceledException) @@ -668,15 +665,10 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? effectiveAppHostFile.FullName }; - // Pass through global options that were matched at the root level - if (parseResult.GetValue(RootCommand.DebugOption)) - { - args.Add("--debug"); - } - if (parseResult.GetValue(RootCommand.WaitForDebuggerOption)) - { - args.Add("--wait-for-debugger"); - } + // Pass through global options that should be forwarded to child CLI + args.AddRange(RootCommand.GetChildProcessArgs(parseResult)); + + // Pass through run-specific options if (parseResult.GetValue(s_isolatedOption)) { args.Add("--isolated"); @@ -841,12 +833,6 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? return ExitCodeConstants.FailedToDotnetRunAppHost; } - // Compute the expected log file path for error message - var expectedLogFile = AppHostHelper.GetLogFilePath( - childProcess.Id, - ExecutionContext.HomeDirectory.FullName, - _timeProvider); - if (childExitedEarly) { _interactionService.DisplayError(string.Format( @@ -876,7 +862,7 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? _interactionService.DisplayMessage("magnifying_glass_tilted_right", string.Format( CultureInfo.CurrentCulture, RunCommandStrings.CheckLogsForDetails, - expectedLogFile.FullName)); + _fileLoggerProvider.LogFilePath)); return ExitCodeConstants.FailedToDotnetRunAppHost; } @@ -886,12 +872,6 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? // Get the dashboard URLs var dashboardUrls = await backchannel.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false); - // Get the log file path - var logFile = AppHostHelper.GetLogFilePath( - appHostInfo?.ProcessId ?? childProcess.Id, - ExecutionContext.HomeDirectory.FullName, - _timeProvider); - var pid = appHostInfo?.ProcessId ?? childProcess.Id; if (format == OutputFormat.Json) @@ -902,7 +882,7 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? pid, childProcess.Id, dashboardUrls?.BaseUrlWithLoginToken, - logFile.FullName); + _fileLoggerProvider.LogFilePath); var json = JsonSerializer.Serialize(result, RunCommandJsonContext.RelaxedEscaping.DetachOutputInfo); _interactionService.DisplayRawText(json); } @@ -915,7 +895,7 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? appHostRelativePath, dashboardUrls?.BaseUrlWithLoginToken, codespacesUrl: null, - logFile.FullName, + _fileLoggerProvider.LogFilePath, isExtensionHost, pid); _ansiConsole.WriteLine(); diff --git a/src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs b/src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs new file mode 100644 index 00000000000..4035352669d --- /dev/null +++ b/src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs @@ -0,0 +1,256 @@ +// 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.Threading.Channels; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace Aspire.Cli.Diagnostics; + +/// +/// A logger provider that writes all log messages to a file on disk. +/// This provider captures logs at all levels (Trace through Critical) for diagnostics, +/// independent of console verbosity settings. +/// +internal sealed class FileLoggerProvider : ILoggerProvider +{ + private const int MaxQueuedMessages = 1024; + + private readonly string _logFilePath; + private readonly StreamWriter? _writer; + private readonly Channel? _channel; + private readonly Task? _writerTask; + private bool _disposed; + + /// + /// Gets the path to the log file. + /// + public string LogFilePath => _logFilePath; + + /// + /// Creates a new FileLoggerProvider that writes to the specified directory. + /// + /// The directory where log files will be written. + /// The time provider for timestamp generation. + /// Optional console for error messages. Defaults to stderr. + public FileLoggerProvider(string logsDirectory, TimeProvider timeProvider, IAnsiConsole? errorConsole = null) + { + var pid = Environment.ProcessId; + var timestamp = timeProvider.GetUtcNow().ToString("yyyy-MM-dd-HH-mm-ss", CultureInfo.InvariantCulture); + // Timestamp first so files sort chronologically by name + _logFilePath = Path.Combine(logsDirectory, $"cli-{timestamp}-{pid}.log"); + + try + { + Directory.CreateDirectory(logsDirectory); + _writer = new StreamWriter(_logFilePath, append: false, Encoding.UTF8) + { + AutoFlush = true + }; + + _channel = CreateChannel(); + _writerTask = Task.Run(ProcessLogQueueAsync); + } + catch (IOException ex) + { + WriteWarning(errorConsole, _logFilePath, ex.Message); + _writer = null; + _channel = null; + } + } + + /// + /// Creates a new FileLoggerProvider with a specific log file path (for testing). + /// + /// The full path to the log file. + /// Optional console for error messages. Defaults to stderr. + internal FileLoggerProvider(string logFilePath, IAnsiConsole? errorConsole = null) + { + _logFilePath = logFilePath; + + try + { + var directory = Path.GetDirectoryName(logFilePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + _writer = new StreamWriter(logFilePath, append: false, Encoding.UTF8) + { + AutoFlush = true + }; + + _channel = CreateChannel(); + _writerTask = Task.Run(ProcessLogQueueAsync); + } + catch (IOException ex) + { + WriteWarning(errorConsole, logFilePath, ex.Message); + _writer = null; + _channel = null; + } + } + + private static Channel CreateChannel() => + Channel.CreateBounded(new BoundedChannelOptions(MaxQueuedMessages) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = false + }); + + private static void WriteWarning(IAnsiConsole? console, string path, string message) + { + // Use provided console or create a minimal one for stderr + var errorConsole = console ?? AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(Console.Error) + }); + errorConsole.MarkupLine($"[yellow]⚠️ Warning:[/] Could not create log file at [blue]{path.EscapeMarkup()}[/]: {message.EscapeMarkup()}"); + } + + private async Task ProcessLogQueueAsync() + { + if (_channel is null || _writer is null) + { + return; + } + + try + { + await foreach (var message in _channel.Reader.ReadAllAsync()) + { + await _writer.WriteLineAsync(message).ConfigureAwait(false); + } + } + catch (ChannelClosedException) + { + // Expected when channel is completed during disposal + } + catch (ObjectDisposedException) + { + // Writer was disposed while writing - expected during shutdown + } + } + + public ILogger CreateLogger(string categoryName) + { + return new FileLogger(this, categoryName); + } + + internal void WriteLog(string message) + { + if (_channel is null || _disposed) + { + return; + } + + // Try to write to the channel - this will succeed as long as there's space + // and the channel hasn't been completed yet + if (_channel.Writer.TryWrite(message)) + { + return; + } + + // TryWrite failed - either channel is full (need backpressure) or completed (disposal) + if (_disposed) + { + return; + } + + // Try async write which will wait for space or throw if completed + try + { + // WaitToWriteAsync returns false if the channel is completed + // This is cheaper than catching ChannelClosedException from WriteAsync + if (!_channel.Writer.WaitToWriteAsync().AsTask().GetAwaiter().GetResult()) + { + return; + } + + // Space is available, write the message + _channel.Writer.TryWrite(message); + } + catch (ChannelClosedException) + { + // Channel was completed between WaitToWriteAsync and TryWrite - rare race + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + // Complete the channel to signal the writer task to finish + // Any messages already in the channel will be drained by the writer task + _channel?.Writer.TryComplete(); + + // Wait for the writer task to finish processing ALL remaining messages + _writerTask?.GetAwaiter().GetResult(); + + _writer?.Dispose(); + } +} + +/// +/// A logger that writes to a file via the FileLoggerProvider. +/// +internal sealed class FileLogger(FileLoggerProvider provider, string categoryName) : ILogger +{ + // Suppress Microsoft.Hosting.Lifetime logs - these are CLI host lifecycle noise + private static readonly string[] s_suppressedCategories = ["Microsoft.Hosting.Lifetime"]; + + // Always enabled for file logging, except suppressed categories + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None && !s_suppressedCategories.Contains(categoryName); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var message = formatter(state, exception); + if (string.IsNullOrEmpty(message) && exception is null) + { + return; + } + + var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + var level = GetLogLevelString(logLevel); + var shortCategory = GetShortCategoryName(categoryName); + + var logMessage = exception is not null + ? $"[{timestamp}] [{level}] [{shortCategory}] {message}{Environment.NewLine}{exception}" + : $"[{timestamp}] [{level}] [{shortCategory}] {message}"; + + provider.WriteLog(logMessage); + } + + private static string GetLogLevelString(LogLevel logLevel) => logLevel switch + { + LogLevel.Trace => "TRCE", + LogLevel.Debug => "DBUG", + LogLevel.Information => "INFO", + LogLevel.Warning => "WARN", + LogLevel.Error => "FAIL", + LogLevel.Critical => "CRIT", + _ => logLevel.ToString().ToUpperInvariant() + }; + + private static string GetShortCategoryName(string categoryName) + { + var lastDotIndex = categoryName.LastIndexOf('.'); + return lastDotIndex >= 0 ? categoryName.Substring(lastDotIndex + 1) : categoryName; + } +} diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index b3247b6c6c5..f8b9156fcd8 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -176,8 +176,9 @@ private async Task StartBackchannelAsync(IDotNetCliExecution? execution, string } catch (SocketException ex) when (execution is not null && execution.HasExited && execution.ExitCode != 0) { - logger.LogError(ex, "AppHost process has exited. Unable to connect to backchannel at {SocketPath}", socketPath); - var backchannelException = new FailedToConnectBackchannelConnection($"AppHost process has exited unexpectedly. Use --debug to see more details.", ex); + // Log at Debug level - this is expected when AppHost crashes, the real error is in AppHost output + logger.LogDebug(ex, "AppHost process has exited. Unable to connect to backchannel at {SocketPath}", socketPath); + var backchannelException = new FailedToConnectBackchannelConnection("AppHost process has exited unexpectedly.", ex); backchannelCompletionSource.SetException(backchannelException); return; } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 124e8587cc8..052182ecd5f 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -17,6 +17,7 @@ using Aspire.Cli.Commands; using Aspire.Cli.Commands.Sdk; using Aspire.Cli.Configuration; +using Aspire.Cli.Diagnostics; using Aspire.Cli.DotNet; using Aspire.Cli.Git; using Aspire.Cli.Interaction; @@ -52,6 +53,43 @@ private static string GetUsersAspirePath() return aspirePath; } + /// + /// Parses logging options from command-line arguments. + /// Returns the console log level (if specified) and whether debug mode is enabled. + /// + private static (LogLevel? ConsoleLogLevel, bool DebugMode) ParseLoggingOptions(string[]? args) + { + if (args is null || args.Length == 0) + { + return (null, false); + } + + // Check for --debug or -d (backward compatibility) + var debugMode = args.Any(a => a == "--debug" || a == "-d"); + + // Check for --debug-level or -v + LogLevel? logLevel = null; + for (var i = 0; i < args.Length; i++) + { + if ((args[i] == "--debug-level" || args[i] == "-v") && i + 1 < args.Length) + { + if (Enum.TryParse(args[i + 1], ignoreCase: true, out var parsedLevel)) + { + logLevel = parsedLevel; + } + break; + } + } + + // --debug implies Debug log level if --verbosity not specified + if (debugMode && logLevel is null) + { + logLevel = LogLevel.Debug; + } + + return (logLevel, debugMode); + } + private static string GetGlobalSettingsPath() { var usersAspirePath = GetUsersAspirePath(); @@ -112,12 +150,21 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar // - Diagnostic provider for OTLP/console exporters (exports all activities, DEBUG only) builder.Services.AddSingleton(new TelemetryManager(builder.Configuration, args)); - var debugMode = args?.Any(a => a == CommonOptionNames.Debug || a == CommonOptionNames.DebugShort) ?? false; + // Parse logging options from args + var (consoleLogLevel, debugMode) = ParseLoggingOptions(args); var extensionEndpoint = builder.Configuration[KnownConfigNames.ExtensionEndpoint]; - if (debugMode && !isMcpStartCommand && extensionEndpoint is null) + // Always register FileLoggerProvider to capture logs to disk + // This captures complete CLI session details for diagnostics + var logsDirectory = Path.Combine(GetUsersAspirePath(), "logs"); + var fileLoggerProvider = new FileLoggerProvider(logsDirectory, TimeProvider.System); + builder.Services.AddSingleton(fileLoggerProvider); // Register for direct access to LogFilePath + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton(fileLoggerProvider)); + + // Configure console logging based on --verbosity or --debug + if (consoleLogLevel is not null && !isMcpStartCommand && extensionEndpoint is null) { - builder.Logging.AddFilter("Aspire.Cli", LogLevel.Debug); + builder.Logging.AddFilter("Aspire.Cli", consoleLogLevel.Value); builder.Logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning); // Reduce noise from hosting lifecycle // Use custom Spectre Console logger for clean debug output to stderr builder.Services.AddSingleton(sp => @@ -128,9 +175,9 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar // This keeps stdout clean for MCP protocol JSON-RPC messages if (isMcpStartCommand) { - if (debugMode) + if (consoleLogLevel is not null) { - builder.Logging.AddFilter("Aspire.Cli", LogLevel.Debug); + builder.Logging.AddFilter("Aspire.Cli", consoleLogLevel.Value); builder.Logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning); // Reduce noise from hosting lifecycle } @@ -142,7 +189,11 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar } // Shared services. - builder.Services.AddSingleton(_ => BuildCliExecutionContext(debugMode)); + builder.Services.AddSingleton(sp => + { + var logFilePath = sp.GetRequiredService().LogFilePath; + return BuildCliExecutionContext(debugMode, logsDirectory, logFilePath); + }); builder.Services.AddSingleton(s => new ConsoleEnvironment( BuildAnsiConsole(s, Console.Out), BuildAnsiConsole(s, Console.Error))); @@ -302,13 +353,13 @@ private static DirectoryInfo GetSdksDirectory() return new DirectoryInfo(sdksPath); } - private static CliExecutionContext BuildCliExecutionContext(bool debugMode) + private static CliExecutionContext BuildCliExecutionContext(bool debugMode, string logsDirectory, string logFilePath) { var workingDirectory = new DirectoryInfo(Environment.CurrentDirectory); var hivesDirectory = GetHivesDirectory(); var cacheDirectory = GetCacheDirectory(); var sdksDirectory = GetSdksDirectory(); - return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, sdksDirectory, debugMode); + return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, sdksDirectory, new DirectoryInfo(logsDirectory), logFilePath, debugMode); } private static DirectoryInfo GetCacheDirectory() @@ -475,8 +526,17 @@ public static async Task Main(string[] args) mainActivity.AddTag(TelemetryConstants.Tags.ProcessExecutableName, "aspire"); } + // Create a dedicated logger for CLI session info + var cliLogger = app.Services.GetRequiredService().CreateLogger(); + try { + // Log command invocation details for debugging + var commandLine = args.Length > 0 ? $"aspire {string.Join(" ", args)}" : "aspire"; + var workingDir = Environment.CurrentDirectory; + cliLogger.LogInformation("Command: {CommandLine}", commandLine); + cliLogger.LogInformation("Working directory: {WorkingDirectory}", workingDir); + logger.LogDebug("Parsing arguments: {Args}", string.Join(" ", args)); var parseResult = rootCommand.Parse(args); @@ -487,6 +547,9 @@ public static async Task Main(string[] args) var exitCode = await parseResult.InvokeAsync(invokeConfig, cts.Token); + // Log exit code for debugging + cliLogger.LogInformation("Exit code: {ExitCode}", exitCode); + mainActivity?.SetTag(TelemetryConstants.Tags.ProcessExitCode, exitCode); mainActivity?.Stop(); @@ -509,6 +572,9 @@ public static async Task Main(string[] args) interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message)); } + // Log exit code for debugging + cliLogger.LogError("Exit code: {ExitCode} (exception)", unknownErrorExitCode); + mainActivity?.SetTag(TelemetryConstants.Tags.ProcessExitCode, unknownErrorExitCode); mainActivity?.Stop(); diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 8b8e0c400e1..0877339bdec 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -29,6 +29,7 @@ internal sealed class DotNetAppHostProject : IAppHostProject private readonly TimeProvider _timeProvider; private readonly IProjectUpdater _projectUpdater; private readonly RunningInstanceManager _runningInstanceManager; + private readonly Diagnostics.FileLoggerProvider _fileLoggerProvider; private static readonly string[] s_detectionPatterns = ["*.csproj", "*.fsproj", "*.vbproj", "apphost.cs"]; private static readonly string[] s_projectExtensions = [".csproj", ".fsproj", ".vbproj"]; @@ -41,6 +42,7 @@ public DotNetAppHostProject( IFeatures features, IProjectUpdater projectUpdater, ILogger logger, + Diagnostics.FileLoggerProvider fileLoggerProvider, TimeProvider? timeProvider = null) { _runner = runner; @@ -50,6 +52,7 @@ public DotNetAppHostProject( _features = features; _projectUpdater = projectUpdater; _logger = logger; + _fileLoggerProvider = fileLoggerProvider; _timeProvider = timeProvider ?? TimeProvider.System; _runningInstanceManager = new RunningInstanceManager(_logger, _interactionService, _timeProvider); } @@ -180,7 +183,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken var effectiveAppHostFile = context.AppHostFile; var isExtensionHost = ExtensionHelper.IsExtensionHost(_interactionService, out _, out var extensionBackchannel); - var buildOutputCollector = new OutputCollector(); + var buildOutputCollector = new OutputCollector(_fileLoggerProvider, "Build"); (bool IsCompatibleAppHost, bool SupportsBackchannel, string? AspireHostingVersion)? appHostCompatibilityCheck = null; @@ -255,7 +258,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken } else { - appHostCompatibilityCheck = await AppHostHelper.CheckAppHostCompatibilityAsync(_runner, _interactionService, effectiveAppHostFile, _telemetry, context.WorkingDirectory, cancellationToken); + appHostCompatibilityCheck = await AppHostHelper.CheckAppHostCompatibilityAsync(_runner, _interactionService, effectiveAppHostFile, _telemetry, context.WorkingDirectory, _fileLoggerProvider.LogFilePath, cancellationToken); } } catch @@ -273,7 +276,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // Create collector and store in context for exception handling // This must be set BEFORE signaling build completion to avoid a race condition - var runOutputCollector = new OutputCollector(); + var runOutputCollector = new OutputCollector(_fileLoggerProvider, "AppHost"); context.OutputCollector = runOutputCollector; // Signal that build/preparation is complete @@ -349,6 +352,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca effectiveAppHostFile, _telemetry, context.WorkingDirectory, + _fileLoggerProvider.LogFilePath, cancellationToken); if (!compatibilityCheck.IsCompatibleAppHost) @@ -362,7 +366,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca } // Build the apphost - var buildOutputCollector = new OutputCollector(); + var buildOutputCollector = new OutputCollector(_fileLoggerProvider, "Build"); var buildOptions = new DotNetCliRunnerInvocationOptions { StandardOutputCallback = buildOutputCollector.AppendOutput, @@ -389,7 +393,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca } // Create collector and store in context for exception handling - var runOutputCollector = new OutputCollector(); + var runOutputCollector = new OutputCollector(_fileLoggerProvider, "AppHost"); context.OutputCollector = runOutputCollector; var runOptions = new DotNetCliRunnerInvocationOptions @@ -419,7 +423,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca /// public async Task AddPackageAsync(AddPackageContext context, CancellationToken cancellationToken) { - var outputCollector = new OutputCollector(); + var outputCollector = new OutputCollector(_fileLoggerProvider, "Package"); context.OutputCollector = outputCollector; var options = new DotNetCliRunnerInvocationOptions diff --git a/src/Aspire.Cli/Resources/AddCommandStrings.resx b/src/Aspire.Cli/Resources/AddCommandStrings.resx index 8ddde3635fa..be4dc72e55e 100644 --- a/src/Aspire.Cli/Resources/AddCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AddCommandStrings.resx @@ -148,7 +148,8 @@ Adding Aspire hosting integration... - The package installation failed with exit code {0}. For more information run with --debug switch. + The package installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path The package {0}::{1} was added successfully. diff --git a/src/Aspire.Cli/Resources/ErrorStrings.resx b/src/Aspire.Cli/Resources/ErrorStrings.resx index 850f83815ce..79670fc65cc 100644 --- a/src/Aspire.Cli/Resources/ErrorStrings.resx +++ b/src/Aspire.Cli/Resources/ErrorStrings.resx @@ -157,7 +157,7 @@ Project file does not exist. - The project could not be analyzed due to a build error. For more information run with --debug switch. + The project could not be analyzed due to a build error. See logs at {0} The project does not contain an Aspire apphost. diff --git a/src/Aspire.Cli/Resources/InteractionServiceStrings.Designer.cs b/src/Aspire.Cli/Resources/InteractionServiceStrings.Designer.cs index 8bd51e6bcfa..593a48d7798 100644 --- a/src/Aspire.Cli/Resources/InteractionServiceStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/InteractionServiceStrings.Designer.cs @@ -232,7 +232,7 @@ public static string OperationCancelled { } /// - /// Looks up a localized string similar to The project could not be built. For more information run with --debug switch.. + /// Looks up a localized string similar to The project could not be built. See logs at {0}. /// public static string ProjectCouldNotBeBuilt { get { @@ -339,6 +339,15 @@ public static string UnexpectedErrorOccurred { } } + /// + /// Looks up a localized string similar to See logs at {0}. + /// + public static string SeeLogsAt { + get { + return ResourceManager.GetString("SeeLogsAt", resourceCulture); + } + } + /// /// Looks up a localized string similar to Waiting for debugger to attach to app host process. /// diff --git a/src/Aspire.Cli/Resources/InteractionServiceStrings.resx b/src/Aspire.Cli/Resources/InteractionServiceStrings.resx index e93b26b6294..2f833c980a8 100644 --- a/src/Aspire.Cli/Resources/InteractionServiceStrings.resx +++ b/src/Aspire.Cli/Resources/InteractionServiceStrings.resx @@ -182,7 +182,7 @@ The project argument was not specified and no app host project files were detected. - The project could not be built. For more information run with --debug switch. + The project could not be built. See logs at {0} The operation was canceled. @@ -200,6 +200,10 @@ An unexpected error occurred: {0} {0} is the exception message + + See logs at {0} + {0} is the log file path + Waiting for debugger to attach to app host process diff --git a/src/Aspire.Cli/Resources/RootCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/RootCommandStrings.Designer.cs index d90521752fe..79b40d5a3d1 100644 --- a/src/Aspire.Cli/Resources/RootCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/RootCommandStrings.Designer.cs @@ -114,6 +114,15 @@ public static string Description { } } + /// + /// Looks up a localized string similar to Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical).. + /// + public static string DebugLevelArgumentDescription { + get { + return ResourceManager.GetString("DebugLevelArgumentDescription", resourceCulture); + } + } + /// /// Looks up a localized string similar to Telemetry ///--------- diff --git a/src/Aspire.Cli/Resources/RootCommandStrings.resx b/src/Aspire.Cli/Resources/RootCommandStrings.resx index 3b69afad727..0e812a74fe1 100644 --- a/src/Aspire.Cli/Resources/RootCommandStrings.resx +++ b/src/Aspire.Cli/Resources/RootCommandStrings.resx @@ -132,6 +132,9 @@ Enable debug logging to the console. + + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + Suppress the startup banner and telemetry notice. diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.resx b/src/Aspire.Cli/Resources/RunCommandStrings.resx index 1bd4964f296..10f852c8d69 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.resx +++ b/src/Aspire.Cli/Resources/RunCommandStrings.resx @@ -170,7 +170,7 @@ [bold] should not be localized - The project could not be run. For more information run with --debug switch. + The project could not be run. See logs at {0} Dashboard diff --git a/src/Aspire.Cli/Resources/TemplatingStrings.Designer.cs b/src/Aspire.Cli/Resources/TemplatingStrings.Designer.cs index 144bae8c918..b430807749d 100644 --- a/src/Aspire.Cli/Resources/TemplatingStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/TemplatingStrings.Designer.cs @@ -277,7 +277,7 @@ public static string ProjectCreatedSuccessfully { } /// - /// Looks up a localized string similar to Project creation failed with exit code {0}. For more information run with --debug switch.. + /// Looks up a localized string similar to Project creation failed with exit code {0}. See logs at {1}. /// public static string ProjectCreationFailed { get { @@ -331,7 +331,7 @@ public static string SearchingForAvailableTemplateVersions { } /// - /// Looks up a localized string similar to The template installation failed with exit code {0}. For more information run with --debug switch.. + /// Looks up a localized string similar to The template installation failed with exit code {0}. See logs at {1}. /// public static string TemplateInstallationFailed { get { diff --git a/src/Aspire.Cli/Resources/TemplatingStrings.resx b/src/Aspire.Cli/Resources/TemplatingStrings.resx index 84aa0165ffd..ce29acb971e 100644 --- a/src/Aspire.Cli/Resources/TemplatingStrings.resx +++ b/src/Aspire.Cli/Resources/TemplatingStrings.resx @@ -199,8 +199,8 @@ Getting templates... - The template installation failed with exit code {0}. For more information run with --debug switch. - {0} is a number, --debug should not be localized + The template installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Using project templates version: {0} @@ -209,8 +209,8 @@ Creating new Aspire project... - Project creation failed with exit code {0}. For more information run with --debug switch. - {0} is a number, --debug should not be localized + Project creation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Project created successfully in {0}. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf index ec681a32b19..a04ad12acff 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf @@ -43,9 +43,9 @@ {0} is the package name, {1} is the version. - The package installation failed with exit code {0}. For more information run with --debug switch. - Instalace balíčku se nezdařila s ukončovacím kódem {0}. Další informace získáte spuštěním s přepínačem --debug. - + The package installation failed with exit code {0}. See logs at {1} + The package installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path The path to the project file to add the integration to. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf index 5868e4ffcee..1f261ba44d4 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf @@ -43,9 +43,9 @@ {0} is the package name, {1} is the version. - The package installation failed with exit code {0}. For more information run with --debug switch. - Fehler bei der Paketinstallation. Exitcode: {0}. Weitere Informationen erhalten Sie, wenn Sie mit dem Schalter „--debug“ ausführen. - + The package installation failed with exit code {0}. See logs at {1} + The package installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path The path to the project file to add the integration to. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf index f316f98ff38..750beed2d51 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf @@ -43,9 +43,9 @@ {0} is the package name, {1} is the version. - The package installation failed with exit code {0}. For more information run with --debug switch. - La instalación del paquete falló con el código de salida {0}. Para obtener más información, ejecute con el modificador --debug. - + The package installation failed with exit code {0}. See logs at {1} + The package installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path The path to the project file to add the integration to. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf index 8cec457c39f..8ea37530273 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf @@ -43,9 +43,9 @@ {0} is the package name, {1} is the version. - The package installation failed with exit code {0}. For more information run with --debug switch. - L’installation du package a échoué avec le code de sortie {0}. Pour plus d’informations, exécutez avec le commutateur --debug. - + The package installation failed with exit code {0}. See logs at {1} + The package installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path The path to the project file to add the integration to. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf index e5638e01035..5fddb87f242 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf @@ -43,9 +43,9 @@ {0} is the package name, {1} is the version. - The package installation failed with exit code {0}. For more information run with --debug switch. - L'installazione del pacchetto non è riuscita con codice di uscita {0}. Per altre informazioni, eseguire con l'opzione --debug. - + The package installation failed with exit code {0}. See logs at {1} + The package installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path The path to the project file to add the integration to. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf index 251555913b0..16f473080e3 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf @@ -43,9 +43,9 @@ {0} is the package name, {1} is the version. - The package installation failed with exit code {0}. For more information run with --debug switch. - パッケージのインストールが終了コード {0} で失敗しました。詳細情報については、--debug スイッチを使用し実行してください。 - + The package installation failed with exit code {0}. See logs at {1} + The package installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path The path to the project file to add the integration to. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf index b0857012e37..85e30c2b5e7 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf @@ -43,9 +43,9 @@ {0} is the package name, {1} is the version. - The package installation failed with exit code {0}. For more information run with --debug switch. - 패키지 설치에 실패했습니다(종료 코드: {0}). 자세한 내용을 보려면 --debug 스위치를 이용하여 실행하세요. - + The package installation failed with exit code {0}. See logs at {1} + The package installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path The path to the project file to add the integration to. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf index bd283968b24..8e4c9649b77 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf @@ -43,9 +43,9 @@ {0} is the package name, {1} is the version. - The package installation failed with exit code {0}. For more information run with --debug switch. - Instalacja pakietu nie powiodła się z kodem zakończenia {0}. Aby uzyskać więcej informacji, uruchom polecenie przełącznika --debug. - + The package installation failed with exit code {0}. See logs at {1} + The package installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path The path to the project file to add the integration to. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf index aa57479a35f..dc788080830 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf @@ -43,9 +43,9 @@ {0} is the package name, {1} is the version. - The package installation failed with exit code {0}. For more information run with --debug switch. - A instalação do pacote falhou com o código de saída {0}. Para mais informações, execute com a opção --debug. - + The package installation failed with exit code {0}. See logs at {1} + The package installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path The path to the project file to add the integration to. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf index fc2d7cf9466..e7dc36b31d3 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf @@ -43,9 +43,9 @@ {0} is the package name, {1} is the version. - The package installation failed with exit code {0}. For more information run with --debug switch. - Не удалось установить пакет. Код завершения: {0}. Для получения дополнительных сведений запустите команду с параметром --debug. - + The package installation failed with exit code {0}. See logs at {1} + The package installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path The path to the project file to add the integration to. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf index dda03b6154d..ddac3946d36 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf @@ -43,9 +43,9 @@ {0} is the package name, {1} is the version. - The package installation failed with exit code {0}. For more information run with --debug switch. - Paket yüklemesi {0} çıkış koduyla başarısız oldu. Daha fazla bilgi için --debug anahtarıyla çalıştırın. - + The package installation failed with exit code {0}. See logs at {1} + The package installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path The path to the project file to add the integration to. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf index 5e356f7d606..17833c3a399 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf @@ -43,9 +43,9 @@ {0} is the package name, {1} is the version. - The package installation failed with exit code {0}. For more information run with --debug switch. - 包安装失败,退出代码为 {0}。有关详细信息,请使用 --debug 开关运行。 - + The package installation failed with exit code {0}. See logs at {1} + The package installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path The path to the project file to add the integration to. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf index a1cb8883245..04f4af8fd21 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf @@ -43,9 +43,9 @@ {0} is the package name, {1} is the version. - The package installation failed with exit code {0}. For more information run with --debug switch. - 套件安裝失敗,結束代碼為 {0}。如需詳細資訊,請使用 --debug 切換執行。 - + The package installation failed with exit code {0}. See logs at {1} + The package installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path The path to the project file to add the integration to. diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf index 25b1e139c5e..66a1b5720b2 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.cs.xlf @@ -138,8 +138,8 @@ - The project could not be analyzed due to a build error. For more information run with --debug switch. - Projekt se nepodařilo analyzovat kvůli chybě sestavení. Další informace získáte spuštěním s přepínačem --debug. + The project could not be analyzed due to a build error. See logs at {0} + The project could not be analyzed due to a build error. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf index ca0cd28cdcd..c445e70b9dd 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.de.xlf @@ -138,8 +138,8 @@ - The project could not be analyzed due to a build error. For more information run with --debug switch. - Das Projekt konnte aufgrund eines Buildfehlers nicht analysiert werden. Weitere Informationen erhalten Sie, wenn Sie mit dem Schalter „--debug“ ausführen. + The project could not be analyzed due to a build error. See logs at {0} + The project could not be analyzed due to a build error. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf index 0fb8f7835d0..86cd1b62e59 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.es.xlf @@ -138,8 +138,8 @@ - The project could not be analyzed due to a build error. For more information run with --debug switch. - No se pudo analizar el proyecto debido a un error de compilación. Para obtener más información, ejecute con el modificador --debug. + The project could not be analyzed due to a build error. See logs at {0} + The project could not be analyzed due to a build error. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf index 5a70a0503f5..2fc7e539ded 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.fr.xlf @@ -138,8 +138,8 @@ - The project could not be analyzed due to a build error. For more information run with --debug switch. - Impossible d’analyser le projet en raison d’une erreur de build. Pour plus d’informations, exécutez avec le commutateur --debug. + The project could not be analyzed due to a build error. See logs at {0} + The project could not be analyzed due to a build error. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf index 16b425f34af..23fd89ae32d 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.it.xlf @@ -138,8 +138,8 @@ - The project could not be analyzed due to a build error. For more information run with --debug switch. - Non è possibile analizzare il progetto a causa di un errore di compilazione. Per altre informazioni, eseguire con l'opzione --debug. + The project could not be analyzed due to a build error. See logs at {0} + The project could not be analyzed due to a build error. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf index 4ab6d4cfdf1..e18e3ec2b63 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ja.xlf @@ -138,8 +138,8 @@ - The project could not be analyzed due to a build error. For more information run with --debug switch. - ビルド エラーのため、プロジェクトを分析できませんでした。詳細情報については、--debug スイッチを使用し実行してください。 + The project could not be analyzed due to a build error. See logs at {0} + The project could not be analyzed due to a build error. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf index 497584e2ae1..fdb73c82eb4 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ko.xlf @@ -138,8 +138,8 @@ - The project could not be analyzed due to a build error. For more information run with --debug switch. - 빌드 오류로 인해 프로젝트를 분석할 수 없습니다. 자세한 내용을 보려면 --debug 스위치를 이용하여 실행하세요. + The project could not be analyzed due to a build error. See logs at {0} + The project could not be analyzed due to a build error. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf index 7553e6ac2b0..da0ea70bdf4 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pl.xlf @@ -138,8 +138,8 @@ - The project could not be analyzed due to a build error. For more information run with --debug switch. - Nie można przeanalizować projektu z powodu błędu kompilacji. Aby uzyskać więcej informacji, uruchom polecenie przełącznika --debug. + The project could not be analyzed due to a build error. See logs at {0} + The project could not be analyzed due to a build error. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf index e5688a91ec4..9a961e33059 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.pt-BR.xlf @@ -138,8 +138,8 @@ - The project could not be analyzed due to a build error. For more information run with --debug switch. - Não foi possível analisar o projeto devido a um erro de compilação. Para obter mais informações, execute com a opção --debug. + The project could not be analyzed due to a build error. See logs at {0} + The project could not be analyzed due to a build error. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf index 9ee74d67365..f908976db4e 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.ru.xlf @@ -138,8 +138,8 @@ - The project could not be analyzed due to a build error. For more information run with --debug switch. - Не удалось проанализировать проект из-за ошибки сборки. Для получения дополнительных сведений запустите команду с параметром --debug. + The project could not be analyzed due to a build error. See logs at {0} + The project could not be analyzed due to a build error. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf index 4ca21336f1d..90da22b7612 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.tr.xlf @@ -138,8 +138,8 @@ - The project could not be analyzed due to a build error. For more information run with --debug switch. - Proje bir derleme hatası nedeniyle analiz edilemedi. Daha fazla bilgi için --debug anahtarıyla çalıştırın. + The project could not be analyzed due to a build error. See logs at {0} + The project could not be analyzed due to a build error. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf index e951aee7547..8d4b17e1288 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hans.xlf @@ -138,8 +138,8 @@ - The project could not be analyzed due to a build error. For more information run with --debug switch. - 由于生成错误,无法分析项目。有关详细信息,请使用 --debug 开关运行。 + The project could not be analyzed due to a build error. See logs at {0} + The project could not be analyzed due to a build error. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf index d624098ecb9..c238b0c9f83 100644 --- a/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/ErrorStrings.zh-Hant.xlf @@ -138,8 +138,8 @@ - The project could not be analyzed due to a build error. For more information run with --debug switch. - 因為組建錯誤,無法分析專案。如需詳細資訊,請使用 --debug 切換執行。 + The project could not be analyzed due to a build error. See logs at {0} + The project could not be analyzed due to a build error. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.cs.xlf index 0b59530b00c..ebf45f31ed4 100644 --- a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.cs.xlf @@ -98,8 +98,8 @@ - The project could not be built. For more information run with --debug switch. - Projekt se nepovedlo sestavit. Další informace získáte spuštěním s přepínačem --debug. + The project could not be built. See logs at {0} + The project could not be built. See logs at {0} @@ -127,6 +127,11 @@ Vyhledávání… + + See logs at {0} + See logs at {0} + {0} is the log file path + Select an apphost to use: Vyberte hostitele aplikací, kterého chcete použít: diff --git a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.de.xlf index b551201c693..7e6f11bf662 100644 --- a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.de.xlf @@ -98,8 +98,8 @@ - The project could not be built. For more information run with --debug switch. - Das Projekt konnte nicht erstellt werden. Weitere Informationen erhalten Sie, wenn Sie mit dem Schalter „--debug“ ausführen. + The project could not be built. See logs at {0} + The project could not be built. See logs at {0} @@ -127,6 +127,11 @@ Suche wird ausgeführt... + + See logs at {0} + See logs at {0} + {0} is the log file path + Select an apphost to use: AppHost zur Verwendung auswählen: diff --git a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.es.xlf index 99725c51e8a..f1ee5ed6723 100644 --- a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.es.xlf @@ -98,8 +98,8 @@ - The project could not be built. For more information run with --debug switch. - No se pudo compilar el proyecto. Para obtener más información, ejecute con el modificador --debug. + The project could not be built. See logs at {0} + The project could not be built. See logs at {0} @@ -127,6 +127,11 @@ Buscando... + + See logs at {0} + See logs at {0} + {0} is the log file path + Select an apphost to use: Seleccione un apphost para usar: diff --git a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.fr.xlf index 005fa1014e6..e3bcea2458b 100644 --- a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.fr.xlf @@ -98,8 +98,8 @@ - The project could not be built. For more information run with --debug switch. - Le projet n’a pas pu être généré. Pour plus d’informations, exécutez avec le commutateur --debug. + The project could not be built. See logs at {0} + The project could not be built. See logs at {0} @@ -127,6 +127,11 @@ Recherche en cours... + + See logs at {0} + See logs at {0} + {0} is the log file path + Select an apphost to use: Sélectionnez un AppHost à utiliser : diff --git a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.it.xlf index a83161430f6..fae126e4717 100644 --- a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.it.xlf @@ -98,8 +98,8 @@ - The project could not be built. For more information run with --debug switch. - Non è possibile compilare il progetto. Per altre informazioni, eseguire con l'opzione --debug. + The project could not be built. See logs at {0} + The project could not be built. See logs at {0} @@ -127,6 +127,11 @@ Ricerca in corso... + + See logs at {0} + See logs at {0} + {0} is the log file path + Select an apphost to use: Selezionare un AppHost da usare: diff --git a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.ja.xlf index e40d36a28b5..c686fa44f84 100644 --- a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.ja.xlf @@ -98,8 +98,8 @@ - The project could not be built. For more information run with --debug switch. - プロジェクトをビルドできませんでした。詳細については、--debug スイッチを使用し実行してください。 + The project could not be built. See logs at {0} + The project could not be built. See logs at {0} @@ -127,6 +127,11 @@ 検索しています... + + See logs at {0} + See logs at {0} + {0} is the log file path + Select an apphost to use: 使用する Apphost を選択してください: diff --git a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.ko.xlf index 2c7d6c1ad6b..eaa8780e51b 100644 --- a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.ko.xlf @@ -98,8 +98,8 @@ - The project could not be built. For more information run with --debug switch. - 프로젝트를 빌드할 수 없습니다. 자세한 내용을 보려면 --debug 스위치를 이용하여 실행하세요. + The project could not be built. See logs at {0} + The project could not be built. See logs at {0} @@ -127,6 +127,11 @@ 검색 중... + + See logs at {0} + See logs at {0} + {0} is the log file path + Select an apphost to use: 사용할 apphost를 선택하세요: diff --git a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.pl.xlf index 805670556a3..09e7a99c688 100644 --- a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.pl.xlf @@ -98,8 +98,8 @@ - The project could not be built. For more information run with --debug switch. - Nie można skompilować projektu. Aby uzyskać więcej informacji, uruchom polecenie przełącznika --debug. + The project could not be built. See logs at {0} + The project could not be built. See logs at {0} @@ -127,6 +127,11 @@ Trwa wyszukiwanie... + + See logs at {0} + See logs at {0} + {0} is the log file path + Select an apphost to use: Wybierz hosta AppHost do użycia: diff --git a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.pt-BR.xlf index a133546987c..fd1ad3bf382 100644 --- a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.pt-BR.xlf @@ -98,8 +98,8 @@ - The project could not be built. For more information run with --debug switch. - O projeto não pôde ser compilado. Para obter mais informações, execute com a opção --debug. + The project could not be built. See logs at {0} + The project could not be built. See logs at {0} @@ -127,6 +127,11 @@ Pesquisando... + + See logs at {0} + See logs at {0} + {0} is the log file path + Select an apphost to use: Selecione um apphost a ser usado: diff --git a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.ru.xlf index a13c58c576a..906adc491ff 100644 --- a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.ru.xlf @@ -98,8 +98,8 @@ - The project could not be built. For more information run with --debug switch. - Не удалось выполнить сборку проекта. Для получения дополнительных сведений запустите команду с параметром --debug. + The project could not be built. See logs at {0} + The project could not be built. See logs at {0} @@ -127,6 +127,11 @@ Выполняется поиск... + + See logs at {0} + See logs at {0} + {0} is the log file path + Select an apphost to use: Выберите хост приложений для использования: diff --git a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.tr.xlf index 072e897945d..ed31c07b031 100644 --- a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.tr.xlf @@ -98,8 +98,8 @@ - The project could not be built. For more information run with --debug switch. - Proje oluşturulamadı. Daha fazla bilgi için --debug anahtarıyla çalıştırın. + The project could not be built. See logs at {0} + The project could not be built. See logs at {0} @@ -127,6 +127,11 @@ Aranıyor... + + See logs at {0} + See logs at {0} + {0} is the log file path + Select an apphost to use: Kullanılacak AppHost seçeneğini belirtin: diff --git a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.zh-Hans.xlf index f70042eb4ab..f8ebdddcf8d 100644 --- a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.zh-Hans.xlf @@ -98,8 +98,8 @@ - The project could not be built. For more information run with --debug switch. - 无法生成项目。有关详细信息,请使用 --debug 开关运行。 + The project could not be built. See logs at {0} + The project could not be built. See logs at {0} @@ -127,6 +127,11 @@ 正在搜索... + + See logs at {0} + See logs at {0} + {0} is the log file path + Select an apphost to use: 选择要使用的应用主机: diff --git a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.zh-Hant.xlf index 574c51fbe8d..ec10422b7dd 100644 --- a/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/InteractionServiceStrings.zh-Hant.xlf @@ -98,8 +98,8 @@ - The project could not be built. For more information run with --debug switch. - 無法建置專案。如需詳細資訊,請使用 --debug 切換執行。 + The project could not be built. See logs at {0} + The project could not be built. See logs at {0} @@ -127,6 +127,11 @@ 正在搜尋... + + See logs at {0} + See logs at {0} + {0} is the log file path + Select an apphost to use: 選擇要使用的應用程式主機: diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf index d66eaa8362b..b6e8d1ffbcc 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf @@ -27,6 +27,11 @@ Povolte protokolování ladění do konzoly. + + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + + The Aspire CLI can be used to create, run, and publish Aspire-based applications. Aspire CLI lze použít k vytváření, spouštění a publikování aplikací založených na Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf index a84792aa3c9..83ff154d2b5 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf @@ -27,6 +27,11 @@ Aktivieren Sie die Debugprotokollierung in der Konsole. + + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + + The Aspire CLI can be used to create, run, and publish Aspire-based applications. Die Aspire CLI kann zum Erstellen, Ausführen und Veröffentlichen von Aspire-basierten Anwendungen verwendet werden. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf index 89bf02fc9db..4ee1b74a866 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf @@ -27,6 +27,11 @@ Habilite el registro de depuración en la consola. + + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + + The Aspire CLI can be used to create, run, and publish Aspire-based applications. La CLI de Aspire puede utilizarse para crear, ejecutar y publicar aplicaciones basadas en Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf index e1ca384b128..5dbb5778707 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf @@ -27,6 +27,11 @@ Activez la journalisation de débogage dans la console. + + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + + The Aspire CLI can be used to create, run, and publish Aspire-based applications. L’interface CLI Aspire peut être utilisée pour créer, exécuter et publier des applications basées sur Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf index 6e12f19d4fd..20703b1fdab 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf @@ -27,6 +27,11 @@ Abilita la registrazione di debug nella console. + + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + + The Aspire CLI can be used to create, run, and publish Aspire-based applications. L'interfaccia della riga di comando Aspire può essere usata per creare, eseguire e pubblicare applicazioni basate su Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf index 8dc9f4cfde0..9b3075e8c66 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf @@ -27,6 +27,11 @@ コンソールへのデバッグ ログを有効にします。 + + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + + The Aspire CLI can be used to create, run, and publish Aspire-based applications. Aspire CLI を使用して、Aspire ベースのアプリケーションを作成、実行、発行できます。 diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf index b6e49aec70e..d79f1b67143 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf @@ -27,6 +27,11 @@ 콘솔에 디버그 로깅을 활성화하세요. + + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + + The Aspire CLI can be used to create, run, and publish Aspire-based applications. Aspire CLI를 사용하여 Aspire 기반 애플리케이션을 만들고, 실행하고, 게시할 수 있습니다. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf index 7d9b077c9c6..6be567691f1 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf @@ -27,6 +27,11 @@ Włącz rejestrowanie debugowania w konsoli. + + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + + The Aspire CLI can be used to create, run, and publish Aspire-based applications. Interfejs wiersza polecenia platformy Aspire może służyć do tworzenia, uruchamiania i publikowania aplikacji opartych na platformie Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf index f6c37412418..87dac35c7ef 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf @@ -27,6 +27,11 @@ Habilitar o log de depuração no console. + + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + + The Aspire CLI can be used to create, run, and publish Aspire-based applications. A CLI do Aspire pode ser usada para criar, executar e publicar aplicações baseadas em Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf index 7a78e841298..4c1e6e179bb 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf @@ -27,6 +27,11 @@ Включает журналирование отладки в консоли. + + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + + The Aspire CLI can be used to create, run, and publish Aspire-based applications. CLI Aspire можно использовать для создания, запуска и публикации приложений на основе Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf index e4b8e3eca1f..b73a9067f61 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf @@ -27,6 +27,11 @@ Konsolda hata ayıklama kaydını etkinleştirin. + + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + + The Aspire CLI can be used to create, run, and publish Aspire-based applications. Aspire CLI, Aspire tabanlı uygulamalar oluşturmak, çalıştırmak ve yayımlamak için kullanılabilir. diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf index f11e90f294e..8161a5ebcbc 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf @@ -27,6 +27,11 @@ 启用控制台的调试日志记录。 + + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + + The Aspire CLI can be used to create, run, and publish Aspire-based applications. Aspire CLI 可用于创建、运行和发布基于 Aspire 的应用程序。 diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf index dde0092131d..fb18139e269 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf @@ -27,6 +27,11 @@ 啟用控制台的偵錯記錄。 + + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). + + The Aspire CLI can be used to create, run, and publish Aspire-based applications. Aspire CLI 可用來建立、執行及發佈以 Aspire 為基礎的應用程式。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf index 32c62a9d23c..0f0cec9a525 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf @@ -148,8 +148,8 @@ - The project could not be run. For more information run with --debug switch. - Projekt nelze spustit. Další informace získáte spuštěním s přepínačem --debug. + The project could not be run. See logs at {0} + The project could not be run. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf index 615ff2db354..b871e5ddc75 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf @@ -148,8 +148,8 @@ - The project could not be run. For more information run with --debug switch. - Das Projekt konnte nicht ausgeführt werden. Weitere Informationen erhalten Sie, wenn Sie mit dem Schalter „--debug“ ausführen. + The project could not be run. See logs at {0} + The project could not be run. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf index b7fa0eb6c59..5311149418b 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf @@ -148,8 +148,8 @@ - The project could not be run. For more information run with --debug switch. - No se pudo ejecutar el proyecto. Para obtener más información, ejecute con el conmutador --debug. + The project could not be run. See logs at {0} + The project could not be run. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf index 96e854bda49..35f1597ed11 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf @@ -148,8 +148,8 @@ - The project could not be run. For more information run with --debug switch. - Le projet n’a pas pu être exécuté. Pour plus d’informations, exécutez avec le commutateur --debug. + The project could not be run. See logs at {0} + The project could not be run. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf index 264b03995c4..8ad9e2f6fc0 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf @@ -148,8 +148,8 @@ - The project could not be run. For more information run with --debug switch. - Non è possibile eseguire il progetto. Per altre informazioni, eseguire con l'opzione --debug. + The project could not be run. See logs at {0} + The project could not be run. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf index d7e7353efb1..456e326c43e 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf @@ -148,8 +148,8 @@ - The project could not be run. For more information run with --debug switch. - プロジェクトを実行できませんでした。詳細情報については、--debug スイッチを使用し実行してください。 + The project could not be run. See logs at {0} + The project could not be run. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf index eb1c9d676b1..2b77fb08792 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf @@ -148,8 +148,8 @@ - The project could not be run. For more information run with --debug switch. - 프로젝트를 실행할 수 없습니다. 자세한 내용을 보려면 --debug 스위치를 이용하여 실행하세요. + The project could not be run. See logs at {0} + The project could not be run. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf index b9213a49188..32e5ee5335e 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf @@ -148,8 +148,8 @@ - The project could not be run. For more information run with --debug switch. - Nie można uruchomić projektu. Aby uzyskać więcej informacji, uruchom polecenie przełącznika --debug. + The project could not be run. See logs at {0} + The project could not be run. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf index ce5daa4faaa..51e822915e0 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf @@ -148,8 +148,8 @@ - The project could not be run. For more information run with --debug switch. - Não foi possível executar o projeto. Para obter mais informações, execute com a opção --debug. + The project could not be run. See logs at {0} + The project could not be run. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf index ddb4a96a5c9..308dfdc0a1c 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf @@ -148,8 +148,8 @@ - The project could not be run. For more information run with --debug switch. - Не удалось запустить проект. Для получения дополнительных сведений запустите команду с параметром --debug. + The project could not be run. See logs at {0} + The project could not be run. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf index adf4a40b82a..52fbacb96f3 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf @@ -148,8 +148,8 @@ - The project could not be run. For more information run with --debug switch. - Proje çalıştırılamadı. Daha fazla bilgi için --debug anahtarıyla çalıştırın. + The project could not be run. See logs at {0} + The project could not be run. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf index abbd08d1c33..6aa572623b6 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf @@ -148,8 +148,8 @@ - The project could not be run. For more information run with --debug switch. - 无法运行该项目。有关详细信息,请使用 --debug 开关运行。 + The project could not be run. See logs at {0} + The project could not be run. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf index e38e4104b48..097f37c223a 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf @@ -148,8 +148,8 @@ - The project could not be run. For more information run with --debug switch. - 無法執行專案。如需詳細資訊,請使用 --debug 切換執行。 + The project could not be run. See logs at {0} + The project could not be run. See logs at {0} diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf index fe9fe4ef3b9..63b9c92b172 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.cs.xlf @@ -123,9 +123,9 @@ {0} is a path - Project creation failed with exit code {0}. For more information run with --debug switch. - Vytvoření projektu se nezdařilo s ukončovacím kódem {0}. Další informace získáte spuštěním s přepínačem --debug. - {0} is a number, --debug should not be localized + Project creation failed with exit code {0}. See logs at {1} + Project creation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Configures whether to create a project for integration tests using MSTest, NUnit, or xUnit.net. @@ -153,9 +153,9 @@ - The template installation failed with exit code {0}. For more information run with --debug switch. - Instalace balíčku se nezdařila s ukončovacím kódem {0}. Další informace získáte spuštěním s přepínačem --debug. - {0} is a number, --debug should not be localized + The template installation failed with exit code {0}. See logs at {1} + The template installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Unknown diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf index a1e832ffd02..9a013a57b9d 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.de.xlf @@ -123,9 +123,9 @@ {0} is a path - Project creation failed with exit code {0}. For more information run with --debug switch. - Fehler bei der Projekterstellung. Exitcode {0}: Weitere Informationen erhalten Sie, wenn Sie mit dem Schalter „--debug“ ausführen. - {0} is a number, --debug should not be localized + Project creation failed with exit code {0}. See logs at {1} + Project creation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Configures whether to create a project for integration tests using MSTest, NUnit, or xUnit.net. @@ -153,9 +153,9 @@ - The template installation failed with exit code {0}. For more information run with --debug switch. - Fehler bei der Vorlageninstallation. Exitcode {0}: Weitere Informationen erhalten Sie, wenn Sie mit dem Schalter „--debug“ ausführen. - {0} is a number, --debug should not be localized + The template installation failed with exit code {0}. See logs at {1} + The template installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Unknown diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf index b607d5c69ad..93c8a62fe9b 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.es.xlf @@ -123,9 +123,9 @@ {0} is a path - Project creation failed with exit code {0}. For more information run with --debug switch. - Error al crear el proyecto con el código de salida {0}. Para obtener más información, ejecute con el modificador --debug. - {0} is a number, --debug should not be localized + Project creation failed with exit code {0}. See logs at {1} + Project creation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Configures whether to create a project for integration tests using MSTest, NUnit, or xUnit.net. @@ -153,9 +153,9 @@ - The template installation failed with exit code {0}. For more information run with --debug switch. - La instalación de la plantilla falló con el código de salida {0}. Para obtener más información, ejecute con el modificador --debug. - {0} is a number, --debug should not be localized + The template installation failed with exit code {0}. See logs at {1} + The template installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Unknown diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf index def11edc4c8..73033b28e94 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.fr.xlf @@ -123,9 +123,9 @@ {0} is a path - Project creation failed with exit code {0}. For more information run with --debug switch. - Échec de la création du projet avec le code de sortie {0}. Pour plus d’informations, exécutez avec le commutateur --debug. - {0} is a number, --debug should not be localized + Project creation failed with exit code {0}. See logs at {1} + Project creation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Configures whether to create a project for integration tests using MSTest, NUnit, or xUnit.net. @@ -153,9 +153,9 @@ - The template installation failed with exit code {0}. For more information run with --debug switch. - L’installation du modèle a échoué avec le code de sortie {0}. Pour plus d’informations, exécutez avec le commutateur --debug. - {0} is a number, --debug should not be localized + The template installation failed with exit code {0}. See logs at {1} + The template installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Unknown diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf index 820c33ce2c9..e89a0dd45af 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.it.xlf @@ -123,9 +123,9 @@ {0} is a path - Project creation failed with exit code {0}. For more information run with --debug switch. - Creazione del progetto non riuscita con codice di uscita {0}. Per altre informazioni, eseguire con l'opzione --debug. - {0} is a number, --debug should not be localized + Project creation failed with exit code {0}. See logs at {1} + Project creation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Configures whether to create a project for integration tests using MSTest, NUnit, or xUnit.net. @@ -153,9 +153,9 @@ - The template installation failed with exit code {0}. For more information run with --debug switch. - L'installazione del modello non è riuscita con codice di uscita {0}. Per altre informazioni, eseguire con l'opzione --debug. - {0} is a number, --debug should not be localized + The template installation failed with exit code {0}. See logs at {1} + The template installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Unknown diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf index fbe1a2ec522..26d80eda038 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ja.xlf @@ -123,9 +123,9 @@ {0} is a path - Project creation failed with exit code {0}. For more information run with --debug switch. - プロジェクトの作成が、次の終了コードで失敗しました: {0}。詳細情報については、--debug スイッチを使用し実行してください。 - {0} is a number, --debug should not be localized + Project creation failed with exit code {0}. See logs at {1} + Project creation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Configures whether to create a project for integration tests using MSTest, NUnit, or xUnit.net. @@ -153,9 +153,9 @@ - The template installation failed with exit code {0}. For more information run with --debug switch. - テンプレートのインストールが、次の終了コードで失敗しました: {0}。詳細情報については、--debug スイッチを使用し実行してください。 - {0} is a number, --debug should not be localized + The template installation failed with exit code {0}. See logs at {1} + The template installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Unknown diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf index 9967b7bf526..7ff2546ec35 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ko.xlf @@ -123,9 +123,9 @@ {0} is a path - Project creation failed with exit code {0}. For more information run with --debug switch. - {0} 종료 코드를 이용하여 프로젝트 만들기에 실패했습니다. 자세한 내용을 보려면 --debug 스위치를 이용하여 실행하세요. - {0} is a number, --debug should not be localized + Project creation failed with exit code {0}. See logs at {1} + Project creation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Configures whether to create a project for integration tests using MSTest, NUnit, or xUnit.net. @@ -153,9 +153,9 @@ - The template installation failed with exit code {0}. For more information run with --debug switch. - {0} 종료 코드를 이용한 템플릿 설치에 실패했습니다. 자세한 내용을 보려면 --debug 스위치를 이용하여 실행하세요. - {0} is a number, --debug should not be localized + The template installation failed with exit code {0}. See logs at {1} + The template installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Unknown diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf index b8e53fcf5b1..a8919998e7d 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pl.xlf @@ -123,9 +123,9 @@ {0} is a path - Project creation failed with exit code {0}. For more information run with --debug switch. - Tworzenie projektu nie powiodło się z kodem zakończenia {0}. Aby uzyskać więcej informacji, uruchom polecenie przełącznika --debug. - {0} is a number, --debug should not be localized + Project creation failed with exit code {0}. See logs at {1} + Project creation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Configures whether to create a project for integration tests using MSTest, NUnit, or xUnit.net. @@ -153,9 +153,9 @@ - The template installation failed with exit code {0}. For more information run with --debug switch. - Instalacja szablonu nie powiodła się z kodem zakończenia {0}. Aby uzyskać więcej informacji, uruchom polecenie przełącznika --debug. - {0} is a number, --debug should not be localized + The template installation failed with exit code {0}. See logs at {1} + The template installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Unknown diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf index b267f8d0620..58553fa8d29 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.pt-BR.xlf @@ -123,9 +123,9 @@ {0} is a path - Project creation failed with exit code {0}. For more information run with --debug switch. - Falha na criação do projeto com código de saída {0}. Para mais informações, execute com a opção --debug. - {0} is a number, --debug should not be localized + Project creation failed with exit code {0}. See logs at {1} + Project creation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Configures whether to create a project for integration tests using MSTest, NUnit, or xUnit.net. @@ -153,9 +153,9 @@ - The template installation failed with exit code {0}. For more information run with --debug switch. - A instalação do modelo falhou com o código de saída {0}. Para mais informações, execute com a opção --debug. - {0} is a number, --debug should not be localized + The template installation failed with exit code {0}. See logs at {1} + The template installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Unknown diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf index d7a11bd8ce5..07e0994760b 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.ru.xlf @@ -123,9 +123,9 @@ {0} is a path - Project creation failed with exit code {0}. For more information run with --debug switch. - Не удалось создать проект. Код завершения: {0}. Для получения дополнительных сведений запустите команду с параметром --debug. - {0} is a number, --debug should not be localized + Project creation failed with exit code {0}. See logs at {1} + Project creation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Configures whether to create a project for integration tests using MSTest, NUnit, or xUnit.net. @@ -153,9 +153,9 @@ - The template installation failed with exit code {0}. For more information run with --debug switch. - Не удалось установить шаблон. Код завершения: {0}. Для получения дополнительных сведений запустите команду с параметром --debug. - {0} is a number, --debug should not be localized + The template installation failed with exit code {0}. See logs at {1} + The template installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Unknown diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf index 7404e65a5fc..4156c1e8fea 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.tr.xlf @@ -123,9 +123,9 @@ {0} is a path - Project creation failed with exit code {0}. For more information run with --debug switch. - Proje oluşturma başarısız oldu, çıkış kodu {0}. Daha fazla bilgi için --debug anahtarıyla çalıştırın. - {0} is a number, --debug should not be localized + Project creation failed with exit code {0}. See logs at {1} + Project creation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Configures whether to create a project for integration tests using MSTest, NUnit, or xUnit.net. @@ -153,9 +153,9 @@ - The template installation failed with exit code {0}. For more information run with --debug switch. - Şablon yüklemesi, {0} çıkış koduyla başarısız oldu. Daha fazla bilgi için --debug anahtarıyla çalıştırın. - {0} is a number, --debug should not be localized + The template installation failed with exit code {0}. See logs at {1} + The template installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Unknown diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf index 83d654bbcd9..79915981c23 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hans.xlf @@ -123,9 +123,9 @@ {0} is a path - Project creation failed with exit code {0}. For more information run with --debug switch. - 项目创建失败,退出代码为 {0}。有关详细信息,请使用 --debug 开关运行。 - {0} is a number, --debug should not be localized + Project creation failed with exit code {0}. See logs at {1} + Project creation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Configures whether to create a project for integration tests using MSTest, NUnit, or xUnit.net. @@ -153,9 +153,9 @@ - The template installation failed with exit code {0}. For more information run with --debug switch. - 模板安装失败,退出代码为 {0}。有关详细信息,请使用 --debug 开关运行。 - {0} is a number, --debug should not be localized + The template installation failed with exit code {0}. See logs at {1} + The template installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Unknown diff --git a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf index faa102f5ec7..3f4e735246a 100644 --- a/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/TemplatingStrings.zh-Hant.xlf @@ -123,9 +123,9 @@ {0} is a path - Project creation failed with exit code {0}. For more information run with --debug switch. - 專案建立失敗,結束代碼為 {0}。如需詳細資訊,請使用 --debug 切換執行。 - {0} is a number, --debug should not be localized + Project creation failed with exit code {0}. See logs at {1} + Project creation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Configures whether to create a project for integration tests using MSTest, NUnit, or xUnit.net. @@ -153,9 +153,9 @@ - The template installation failed with exit code {0}. For more information run with --debug switch. - 範本安裝失敗,結束代碼為 {0}。如需詳細資訊,請使用 --debug 切換執行。 - {0} is a number, --debug should not be localized + The template installation failed with exit code {0}. See logs at {1} + The template installation failed with exit code {0}. See logs at {1} + {0} is a number, {1} is the log file path Unknown diff --git a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs index b5f4a59259f..cbb90cbd393 100644 --- a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs @@ -440,7 +440,7 @@ private async Task ApplyTemplateAsync(CallbackTemplate template, if (templateInstallResult.ExitCode != 0) { interactionService.DisplayLines(templateInstallCollector.GetLines()); - interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.TemplateInstallationFailed, templateInstallResult.ExitCode)); + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.TemplateInstallationFailed, templateInstallResult.ExitCode, executionContext.LogFilePath)); return new TemplateResult(ExitCodeConstants.FailedToInstallTemplates); } @@ -479,7 +479,7 @@ private async Task ApplyTemplateAsync(CallbackTemplate template, } interactionService.DisplayLines(newProjectCollector.GetLines()); - interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.ProjectCreationFailed, newProjectExitCode)); + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, TemplatingStrings.ProjectCreationFailed, newProjectExitCode, executionContext.LogFilePath)); return new TemplateResult(ExitCodeConstants.FailedToCreateNewProject); } diff --git a/src/Aspire.Cli/Utils/AppHostHelper.cs b/src/Aspire.Cli/Utils/AppHostHelper.cs index 3bde0f9fee2..26cb04098d8 100644 --- a/src/Aspire.Cli/Utils/AppHostHelper.cs +++ b/src/Aspire.Cli/Utils/AppHostHelper.cs @@ -14,13 +14,13 @@ namespace Aspire.Cli.Utils; internal static class AppHostHelper { - internal static async Task<(bool IsCompatibleAppHost, bool SupportsBackchannel, string? AspireHostingVersion)> CheckAppHostCompatibilityAsync(IDotNetCliRunner runner, IInteractionService interactionService, FileInfo projectFile, AspireCliTelemetry telemetry, DirectoryInfo workingDirectory, CancellationToken cancellationToken) + internal static async Task<(bool IsCompatibleAppHost, bool SupportsBackchannel, string? AspireHostingVersion)> CheckAppHostCompatibilityAsync(IDotNetCliRunner runner, IInteractionService interactionService, FileInfo projectFile, AspireCliTelemetry telemetry, DirectoryInfo workingDirectory, string logFilePath, CancellationToken cancellationToken) { var appHostInformation = await GetAppHostInformationAsync(runner, interactionService, projectFile, telemetry, workingDirectory, cancellationToken); if (appHostInformation.ExitCode != 0) { - interactionService.DisplayError(ErrorStrings.ProjectCouldNotBeAnalyzed); + interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ErrorStrings.ProjectCouldNotBeAnalyzed, logFilePath)); return (false, false, null); } @@ -135,18 +135,4 @@ internal static bool ProcessExists(int pid) /// The number of orphaned sockets deleted. internal static int CleanupOrphanedSockets(string backchannelsDirectory, string hash, int currentPid) => BackchannelConstants.CleanupOrphanedSockets(backchannelsDirectory, hash, currentPid); - - /// - /// Gets the log file path for an AppHost process. - /// - /// The process ID of the AppHost. - /// The user's home directory. - /// The time provider for timestamp generation. - /// The log file path. - internal static FileInfo GetLogFilePath(int pid, string homeDirectory, TimeProvider timeProvider) - { - var logsPath = Path.Combine(homeDirectory, ".aspire", "cli", "logs"); - var logFilePath = Path.Combine(logsPath, $"apphost-{pid}-{timeProvider.GetUtcNow():yyyy-MM-dd-HH-mm-ss}.log"); - return new FileInfo(logFilePath); - } } diff --git a/src/Aspire.Cli/Utils/OutputCollector.cs b/src/Aspire.Cli/Utils/OutputCollector.cs index 2efc5c167bb..c7b6a59c0bc 100644 --- a/src/Aspire.Cli/Utils/OutputCollector.cs +++ b/src/Aspire.Cli/Utils/OutputCollector.cs @@ -1,18 +1,41 @@ // 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.Diagnostics; + namespace Aspire.Cli.Utils; internal sealed class OutputCollector { private readonly CircularBuffer<(string Stream, string Line)> _lines = new(10000); // 10k lines. private readonly object _lock = new object(); + private readonly FileLoggerProvider? _fileLogger; + private readonly string _category; + + /// + /// Creates an OutputCollector that only buffers output in memory. + /// + public OutputCollector() : this(null, "AppHost") + { + } + + /// + /// Creates an OutputCollector that buffers output and optionally logs to disk. + /// + /// Optional file logger for writing output to disk. + /// Category for log entries (e.g., "Build", "AppHost"). + public OutputCollector(FileLoggerProvider? fileLogger, string category = "AppHost") + { + _fileLogger = fileLogger; + _category = category; + } public void AppendOutput(string line) { lock (_lock) { _lines.Add(("stdout", line)); + _fileLogger?.WriteLog(FormatLogLine("stdout", line)); } } @@ -21,6 +44,7 @@ public void AppendError(string line) lock (_lock) { _lines.Add(("stderr", line)); + _fileLogger?.WriteLog(FormatLogLine("stderr", line)); } } @@ -31,4 +55,11 @@ public void AppendError(string line) return _lines.ToArray(); } } + + private string FormatLogLine(string stream, string line) + { + var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff", System.Globalization.CultureInfo.InvariantCulture); + var level = stream == "stderr" ? "FAIL" : "INFO"; + return $"[{timestamp}] [{level}] [{_category}] {line}"; + } } \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs index e4489ecc2fc..ee693cebaa6 100644 --- a/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs @@ -201,6 +201,8 @@ private static CliExecutionContext CreateExecutionContext(DirectoryInfo workingD hivesDirectory: workingDirectory, cacheDirectory: workingDirectory, sdksDirectory: workingDirectory, + logsDirectory: workingDirectory, + logFilePath: "test.log", debugMode: false, environmentVariables: new Dictionary(), homeDirectory: workingDirectory); @@ -218,6 +220,8 @@ private static CliExecutionContext CreateExecutionContextWithVSCode(DirectoryInf hivesDirectory: workingDirectory, cacheDirectory: workingDirectory, sdksDirectory: workingDirectory, + logsDirectory: workingDirectory, + logFilePath: "test.log", debugMode: false, environmentVariables: environmentVariables, homeDirectory: workingDirectory); diff --git a/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs index bdb0b30a43e..bcb247ae7f8 100644 --- a/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs @@ -323,6 +323,8 @@ private static CliExecutionContext CreateExecutionContext(DirectoryInfo workingD hivesDirectory: workingDirectory, cacheDirectory: workingDirectory, sdksDirectory: workingDirectory, + logsDirectory: workingDirectory, + logFilePath: "test.log", debugMode: false, environmentVariables: environmentVariables, homeDirectory: homeDirectory); diff --git a/tests/Aspire.Cli.Tests/Caching/DiskCacheTests.cs b/tests/Aspire.Cli.Tests/Caching/DiskCacheTests.cs index 365384ccfa4..9604548dc7b 100644 --- a/tests/Aspire.Cli.Tests/Caching/DiskCacheTests.cs +++ b/tests/Aspire.Cli.Tests/Caching/DiskCacheTests.cs @@ -19,7 +19,7 @@ private static DiskCache CreateCache(TemporaryWorkspace workspace, Action(); return new DiskCache(logger, ctx, configuration); diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index ed971058056..2be8fa710a4 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -865,7 +865,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrue_IncludesNonInteractiv var options = new DotNetCliRunnerInvocationOptions(); var executionContext = new CliExecutionContext( - workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")) + workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), logFilePath: "test.log" ); var runner = DotNetCliRunnerTestHelper.Create( @@ -914,7 +914,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsFalse_DoesNotIncludeNonInt var options = new DotNetCliRunnerInvocationOptions(); var executionContext = new CliExecutionContext( - workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")) + workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), logFilePath: "test.log" ); var runner = DotNetCliRunnerTestHelper.Create( @@ -959,7 +959,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrueAndDebugIsTrue_Include var options = new DotNetCliRunnerInvocationOptions { Debug = true }; var executionContext = new CliExecutionContext( - workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")) + workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), logFilePath: "test.log" ); var runner = DotNetCliRunnerTestHelper.Create( @@ -1008,7 +1008,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrueAndDebugIsFalse_DoesNo var options = new DotNetCliRunnerInvocationOptions { Debug = false }; var executionContext = new CliExecutionContext( - workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")) + workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), logFilePath: "test.log" ); var runner = DotNetCliRunnerTestHelper.Create( @@ -1052,7 +1052,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsFalseAndDebugIsTrue_DoesNo var options = new DotNetCliRunnerInvocationOptions { Debug = true }; var executionContext = new CliExecutionContext( - workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")) + workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), logFilePath: "test.log" ); var runner = DotNetCliRunnerTestHelper.Create( @@ -1097,7 +1097,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrue_SetsSuppressLaunchBro var options = new DotNetCliRunnerInvocationOptions(); var executionContext = new CliExecutionContext( - workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")) + workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), logFilePath: "test.log" ); var runner = DotNetCliRunnerTestHelper.Create( @@ -1142,7 +1142,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsFalse_DoesNotSetSuppressLa var options = new DotNetCliRunnerInvocationOptions(); var executionContext = new CliExecutionContext( - workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")) + workingDirectory: workspace.WorkspaceRoot, hivesDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("hives"), cacheDirectory: workspace.WorkspaceRoot.CreateSubdirectory(".aspire").CreateSubdirectory("cache"), sdksDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory: new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), logFilePath: "test.log" ); var runner = DotNetCliRunnerTestHelper.Create( diff --git a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs index dd27c7c5041..997dc6ab6ae 100644 --- a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs @@ -22,7 +22,7 @@ private static Aspire.Cli.CliExecutionContext CreateExecutionContext(DirectoryIn var settingsDirectory = workingDirectory.CreateSubdirectory(".aspire"); var hivesDirectory = settingsDirectory.CreateSubdirectory("hives"); var cacheDirectory = new DirectoryInfo(Path.Combine(workingDirectory.FullName, ".aspire", "cache")); - return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); } [Fact] diff --git a/tests/Aspire.Cli.Tests/DotNetSdkInstallerTests.cs b/tests/Aspire.Cli.Tests/DotNetSdkInstallerTests.cs index d56ed37a41a..5df502be45e 100644 --- a/tests/Aspire.Cli.Tests/DotNetSdkInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/DotNetSdkInstallerTests.cs @@ -23,8 +23,9 @@ private static CliExecutionContext CreateTestExecutionContext() var hivesDirectory = new DirectoryInfo(Path.Combine(tempPath, "hives")); var cacheDirectory = new DirectoryInfo(Path.Combine(tempPath, "cache")); var sdksDirectory = new DirectoryInfo(Path.Combine(tempPath, "sdks")); + var logsDirectory = new DirectoryInfo(Path.Combine(tempPath, "logs")); - return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, sdksDirectory, debugMode: false); + return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, sdksDirectory, logsDirectory, "test.log", debugMode: false); } private static ILogger CreateTestLogger() diff --git a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs index 88c4c9a1658..3ee92c32353 100644 --- a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs @@ -22,7 +22,7 @@ private static ConsoleInteractionService CreateInteractionService(IAnsiConsole c public async Task PromptForSelectionAsync_EmptyChoices_ThrowsEmptyChoicesException() { // Arrange - var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var interactionService = CreateInteractionService(AnsiConsole.Console, executionContext); var choices = Array.Empty(); @@ -35,7 +35,7 @@ await Assert.ThrowsAsync(() => public async Task PromptForSelectionsAsync_EmptyChoices_ThrowsEmptyChoicesException() { // Arrange - var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var interactionService = CreateInteractionService(AnsiConsole.Console, executionContext); var choices = Array.Empty(); @@ -56,7 +56,7 @@ public void DisplayError_WithMarkupCharacters_DoesNotCauseMarkupParsingError() Out = new AnsiConsoleOutput(new StringWriter(output)) }); - var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var interactionService = CreateInteractionService(console, executionContext); var errorMessage = "The JSON value could not be converted to . Path: $.values[0].Type | LineNumber: 0 | BytePositionInLine: 121."; @@ -81,7 +81,7 @@ public void DisplaySubtleMessage_WithMarkupCharacters_DoesNotCauseMarkupParsingE Out = new AnsiConsoleOutput(new StringWriter(output)) }); - var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var interactionService = CreateInteractionService(console, executionContext); var message = "Path with and [markup] characters"; @@ -106,7 +106,7 @@ public void DisplayLines_WithMarkupCharacters_DoesNotCauseMarkupParsingError() Out = new AnsiConsoleOutput(new StringWriter(output)) }); - var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var interactionService = CreateInteractionService(console, executionContext); var lines = new[] { @@ -137,7 +137,7 @@ public void DisplayMarkdown_WithBasicMarkdown_ConvertsToSpectreMarkup() Out = new AnsiConsoleOutput(new StringWriter(output)) }); - var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var interactionService = CreateInteractionService(console, executionContext); var markdown = "# Header\nThis is **bold** and *italic* text with `code`."; @@ -164,7 +164,7 @@ public void DisplayMarkdown_WithPlainText_DoesNotThrow() Out = new AnsiConsoleOutput(new StringWriter(output)) }); - var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var interactionService = CreateInteractionService(console, executionContext); var plainText = "This is just plain text without any markdown."; @@ -189,7 +189,7 @@ public async Task ShowStatusAsync_InDebugMode_DisplaysSubtleMessageInsteadOfSpin Out = new AnsiConsoleOutput(new StringWriter(output)) }); - var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), debugMode: true); + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", debugMode: true); var interactionService = CreateInteractionService(console, executionContext); var statusText = "Processing request..."; var result = "test result"; @@ -216,7 +216,7 @@ public void ShowStatus_InDebugMode_DisplaysSubtleMessageInsteadOfSpinner() Out = new AnsiConsoleOutput(new StringWriter(output)) }); - var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), debugMode: true); + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", debugMode: true); var interactionService = CreateInteractionService(console, executionContext); var statusText = "Processing synchronous request..."; var actionCalled = false; @@ -235,7 +235,7 @@ public void ShowStatus_InDebugMode_DisplaysSubtleMessageInsteadOfSpinner() public async Task PromptForStringAsync_WhenInteractiveInputNotSupported_ThrowsInvalidOperationException() { // Arrange - var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var hostEnvironment = TestHelpers.CreateNonInteractiveHostEnvironment(); var interactionService = CreateInteractionService(AnsiConsole.Console, executionContext, hostEnvironment); @@ -249,7 +249,7 @@ public async Task PromptForStringAsync_WhenInteractiveInputNotSupported_ThrowsIn public async Task PromptForSelectionAsync_WhenInteractiveInputNotSupported_ThrowsInvalidOperationException() { // Arrange - var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var hostEnvironment = TestHelpers.CreateNonInteractiveHostEnvironment(); var interactionService = CreateInteractionService(AnsiConsole.Console, executionContext, hostEnvironment); var choices = new[] { "option1", "option2" }; @@ -264,7 +264,7 @@ public async Task PromptForSelectionAsync_WhenInteractiveInputNotSupported_Throw public async Task PromptForSelectionsAsync_WhenInteractiveInputNotSupported_ThrowsInvalidOperationException() { // Arrange - var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var hostEnvironment = TestHelpers.CreateNonInteractiveHostEnvironment(); var interactionService = CreateInteractionService(AnsiConsole.Console, executionContext, hostEnvironment); var choices = new[] { "option1", "option2" }; @@ -279,7 +279,7 @@ public async Task PromptForSelectionsAsync_WhenInteractiveInputNotSupported_Thro public async Task ConfirmAsync_WhenInteractiveInputNotSupported_ThrowsInvalidOperationException() { // Arrange - var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var hostEnvironment = TestHelpers.CreateNonInteractiveHostEnvironment(); var interactionService = CreateInteractionService(AnsiConsole.Console, executionContext, hostEnvironment); @@ -301,7 +301,7 @@ public async Task ShowStatusAsync_NestedCall_DoesNotThrowException() Out = new AnsiConsoleOutput(new StringWriter(output)) }); - var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var interactionService = CreateInteractionService(console, executionContext); var outerStatusText = "Outer operation..."; @@ -334,7 +334,7 @@ public void ShowStatus_NestedCall_DoesNotThrowException() Out = new AnsiConsoleOutput(new StringWriter(output)) }); - var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var interactionService = CreateInteractionService(console, executionContext); var outerStatusText = "Outer synchronous operation..."; diff --git a/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs b/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs index 736a02e2383..02104bee4f7 100644 --- a/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs +++ b/tests/Aspire.Cli.Tests/Mcp/ListAppHostsToolTests.cs @@ -168,7 +168,7 @@ private static CliExecutionContext CreateCliExecutionContext(DirectoryInfo worki { var hivesDirectory = new DirectoryInfo(Path.Combine(workingDirectory.FullName, ".aspire", "hives")); var cacheDirectory = new DirectoryInfo(Path.Combine(workingDirectory.FullName, ".aspire", "cache")); - return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks"))); + return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); } private static AppHostAuxiliaryBackchannel CreateAppHostConnection(string hash, string socketPath, AppHostInformation appHostInfo, bool isInScope) diff --git a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs index c1d55866cb9..27505f5e9a3 100644 --- a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs +++ b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs @@ -55,7 +55,9 @@ public static CliExecutionContext CreateTestContext() new DirectoryInfo(Path.GetTempPath()), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "hives")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "cache")), - new DirectoryInfo(Path.Combine(Path.GetTempPath(), "sdks"))); + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "sdks")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "logs")), + "test.log"); } } diff --git a/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs b/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs index cdd95194ccd..ff18dfaa44b 100644 --- a/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs +++ b/tests/Aspire.Cli.Tests/NuGet/NuGetPackagePrefetcherTests.cs @@ -15,7 +15,7 @@ public void CliExecutionContextSetsCommand() var workingDir = new DirectoryInfo(Environment.CurrentDirectory); var hivesDir = new DirectoryInfo(Path.Combine(Environment.CurrentDirectory, "hives")); var cacheDir = new DirectoryInfo(Path.Combine(workingDir.FullName, ".aspire", "cache")); - var executionContext = new CliExecutionContext(workingDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(workingDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); Assert.Null(executionContext.Command); diff --git a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs index e1fc3f227f5..aea7ee84d36 100644 --- a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs @@ -63,7 +63,7 @@ public async Task Merge_WithSimpleNuGetConfig_ProducesExpectedXml(string channel // Add a deterministic PR hive for testing realistic PR channel mappings. hivesDir.CreateSubdirectory("pr-1234"); var cacheDir = new DirectoryInfo(Path.Combine(root.FullName, ".aspire", "cache")); - var executionContext = new CliExecutionContext(root, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(root, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var packagingService = CreatePackagingService(executionContext); // Existing config purposely minimal (no packageSourceMapping yet) @@ -112,7 +112,7 @@ public async Task Merge_WithBrokenSdkState_ProducesExpectedXml(string channelNam // Add a deterministic PR hive for testing realistic PR channel mappings. hivesDir.CreateSubdirectory("pr-1234"); var cacheDir2 = new DirectoryInfo(Path.Combine(root.FullName, ".aspire", "cache")); - var executionContext = new CliExecutionContext(root, hivesDir, cacheDir2, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(root, hivesDir, cacheDir2, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var packagingService = CreatePackagingService(executionContext); // Existing config purposely minimal (no packageSourceMapping yet) @@ -174,7 +174,7 @@ public async Task Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpect // Add a deterministic PR hive for testing realistic PR channel mappings. hivesDir.CreateSubdirectory("pr-1234"); var cacheDir3 = new DirectoryInfo(Path.Combine(root.FullName, ".aspire", "cache")); - var executionContext = new CliExecutionContext(root, hivesDir, cacheDir3, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(root, hivesDir, cacheDir3, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var packagingService = CreatePackagingService(executionContext); // Existing config purposely minimal (no packageSourceMapping yet) @@ -235,7 +235,7 @@ public async Task Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedX // Add a deterministic PR hive for testing realistic PR channel mappings. hivesDir.CreateSubdirectory("pr-1234"); var cacheDir4 = new DirectoryInfo(Path.Combine(root.FullName, ".aspire", "cache")); - var executionContext = new CliExecutionContext(root, hivesDir, cacheDir4, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(root, hivesDir, cacheDir4, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var packagingService = CreatePackagingService(executionContext); // Existing config purposely minimal (no packageSourceMapping yet) @@ -294,7 +294,7 @@ public async Task Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithO // Add a deterministic PR hive for testing realistic PR channel mappings. hivesDir.CreateSubdirectory("pr-1234"); var cacheDir5 = new DirectoryInfo(Path.Combine(root.FullName, ".aspire", "cache")); - var executionContext = new CliExecutionContext(root, hivesDir, cacheDir5, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(root, hivesDir, cacheDir5, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var packagingService = CreatePackagingService(executionContext); // Existing config purposely minimal (no packageSourceMapping yet) diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index d68f78f1229..5928d77e2d5 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -45,7 +45,7 @@ public async Task GetChannelsAsync_WhenStagingChannelDisabled_DoesNotIncludeStag 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"))); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var features = new TestFeatures(); var configuration = new ConfigurationBuilder().Build(); @@ -80,7 +80,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabled_IncludesStagingChan 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"))); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var features = new TestFeatures(); features.SetFeature(KnownFeatures.StagingChannelEnabled, true); @@ -124,7 +124,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithOverrideFeed_Use 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"))); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var features = new TestFeatures(); features.SetFeature(KnownFeatures.StagingChannelEnabled, true); @@ -157,7 +157,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithAzureDevOpsFeedO 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"))); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var features = new TestFeatures(); features.SetFeature(KnownFeatures.StagingChannelEnabled, true); @@ -190,7 +190,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithInvalidOverrideF 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"))); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var features = new TestFeatures(); features.SetFeature(KnownFeatures.StagingChannelEnabled, true); @@ -222,7 +222,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithQualityOverride_ 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"))); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var features = new TestFeatures(); features.SetFeature(KnownFeatures.StagingChannelEnabled, true); @@ -253,7 +253,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithQualityBoth_Uses 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"))); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var features = new TestFeatures(); features.SetFeature(KnownFeatures.StagingChannelEnabled, true); @@ -284,7 +284,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithInvalidQuality_D 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"))); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var features = new TestFeatures(); features.SetFeature(KnownFeatures.StagingChannelEnabled, true); @@ -315,7 +315,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabledWithoutQualityOverri 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"))); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var features = new TestFeatures(); features.SetFeature(KnownFeatures.StagingChannelEnabled, true); @@ -355,7 +355,7 @@ public async Task NuGetConfigMerger_WhenChannelRequiresGlobalPackagesFolder_Adds .Build(); var packagingService = new PackagingService( - new CliExecutionContext(tempDir, tempDir, tempDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))), + new CliExecutionContext(tempDir, tempDir, tempDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"), new FakeNuGetPackageCache(), features, configuration); @@ -399,7 +399,7 @@ public async Task GetChannelsAsync_WhenStagingChannelEnabled_StagingAppearsAfter Directory.CreateDirectory(Path.Combine(hivesDir.FullName, "pr-10167")); Directory.CreateDirectory(Path.Combine(hivesDir.FullName, "pr-11832")); - var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var features = new TestFeatures(); features.SetFeature(KnownFeatures.StagingChannelEnabled, true); @@ -455,7 +455,7 @@ public async Task GetChannelsAsync_WhenStagingChannelDisabled_OrderIsDefaultStab hivesDir.Create(); Directory.CreateDirectory(Path.Combine(hivesDir.FullName, "pr-12345")); - var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var features = new TestFeatures(); // Staging disabled by default diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs index ece3256a945..0864fa78149 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectLocatorTests.cs @@ -26,7 +26,7 @@ private static Aspire.Cli.CliExecutionContext CreateExecutionContext(DirectoryIn var settingsDirectory = workingDirectory.CreateSubdirectory(".aspire"); var hivesDirectory = settingsDirectory.CreateSubdirectory("hives"); var cacheDirectory = new DirectoryInfo(Path.Combine(workingDirectory.FullName, ".aspire", "cache")); - return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs index 76f97f60422..0ccafc41816 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs @@ -893,7 +893,7 @@ private static Aspire.Cli.CliExecutionContext CreateExecutionContext(DirectoryIn var settingsDirectory = workingDirectory.CreateSubdirectory(".aspire"); var hivesDirectory = settingsDirectory.CreateSubdirectory("hives"); var cacheDirectory = new DirectoryInfo(Path.Combine(workingDirectory.FullName, ".aspire", "cache")); - return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 7193d5a2436..cb47f0170c3 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -333,7 +333,7 @@ private static DotNetTemplateFactory CreateTemplateFactory(TestFeatures features var workingDirectory = new DirectoryInfo("/tmp"); var hivesDirectory = new DirectoryInfo("/tmp/hives"); var cacheDirectory = new DirectoryInfo("/tmp/cache"); - var executionContext = new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes"))); + var executionContext = new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var configurationService = new FakeConfigurationService(); return new DotNetTemplateFactory( diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 81bf85fed3e..16e703f807c 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -32,6 +32,7 @@ using Aspire.Cli.Utils.EnvironmentChecker; using Aspire.Cli.Packaging; using Aspire.Cli.Caching; +using Aspire.Cli.Diagnostics; namespace Aspire.Cli.Tests.Utils; @@ -74,6 +75,11 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddLogging(b => b.SetMinimumLevel(LogLevel.Trace)).AddXunitLogging(outputHelper); + // Register a FileLoggerProvider that writes to a test-specific temp directory + var testLogsDirectory = Path.Combine(options.WorkingDirectory.FullName, ".aspire", "logs"); + var fileLoggerProvider = new FileLoggerProvider(testLogsDirectory, TimeProvider.System); + services.AddSingleton(fileLoggerProvider); + services.AddMemoryCache(); services.AddSingleton(options.ConsoleEnvironmentFactory); @@ -207,7 +213,9 @@ private CliExecutionContext CreateDefaultCliExecutionContextFactory(IServiceProv { var hivesDirectory = new DirectoryInfo(Path.Combine(WorkingDirectory.FullName, ".aspire", "hives")); var cacheDirectory = new DirectoryInfo(Path.Combine(WorkingDirectory.FullName, ".aspire", "cache")); - return new CliExecutionContext(WorkingDirectory, hivesDirectory, cacheDirectory, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks"))); + var logsDirectory = new DirectoryInfo(Path.Combine(WorkingDirectory.FullName, ".aspire", "logs")); + var logFilePath = Path.Combine(logsDirectory.FullName, "test.log"); + return new CliExecutionContext(WorkingDirectory, hivesDirectory, cacheDirectory, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory, logFilePath); } public DirectoryInfo WorkingDirectory { get; set; } From 26553aa589b10b5a2b2740e48a0d5f072779cb31 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 07:59:15 -0800 Subject: [PATCH 056/256] Fix CLI stop command description to include resource stopping (#14372) * Initial plan * Fix CLI stop command description to mention resource stopping Updated description from "Stop a running Aspire apphost." to "Stop a running apphost or the specified resource." in the resx file and all XLF localization files. Co-authored-by: JamesNK <303201+JamesNK@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JamesNK <303201+JamesNK@users.noreply.github.com> --- src/Aspire.Cli/Resources/StopCommandStrings.resx | 2 +- src/Aspire.Cli/Resources/xlf/StopCommandStrings.cs.xlf | 2 +- src/Aspire.Cli/Resources/xlf/StopCommandStrings.de.xlf | 2 +- src/Aspire.Cli/Resources/xlf/StopCommandStrings.es.xlf | 2 +- src/Aspire.Cli/Resources/xlf/StopCommandStrings.fr.xlf | 2 +- src/Aspire.Cli/Resources/xlf/StopCommandStrings.it.xlf | 2 +- src/Aspire.Cli/Resources/xlf/StopCommandStrings.ja.xlf | 2 +- src/Aspire.Cli/Resources/xlf/StopCommandStrings.ko.xlf | 2 +- src/Aspire.Cli/Resources/xlf/StopCommandStrings.pl.xlf | 2 +- src/Aspire.Cli/Resources/xlf/StopCommandStrings.pt-BR.xlf | 2 +- src/Aspire.Cli/Resources/xlf/StopCommandStrings.ru.xlf | 2 +- src/Aspire.Cli/Resources/xlf/StopCommandStrings.tr.xlf | 2 +- src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hans.xlf | 2 +- src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hant.xlf | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Aspire.Cli/Resources/StopCommandStrings.resx b/src/Aspire.Cli/Resources/StopCommandStrings.resx index 54a4c6a00fd..593816f742c 100644 --- a/src/Aspire.Cli/Resources/StopCommandStrings.resx +++ b/src/Aspire.Cli/Resources/StopCommandStrings.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Stop a running Aspire apphost. + Stop a running apphost or the specified resource. The path to the Aspire AppHost project file. diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.cs.xlf index 9409309fe46..1741a0e6892 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.cs.xlf @@ -8,7 +8,7 @@ - Stop a running Aspire apphost. + Stop a running apphost or the specified resource. Zastavte spuštěného hostitele aplikací Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.de.xlf index cb33afe1777..d0f298c9ad2 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.de.xlf @@ -8,7 +8,7 @@ - Stop a running Aspire apphost. + Stop a running apphost or the specified resource. Beenden Sie einen aktiven Aspire-AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.es.xlf index a575213484a..8ac6e6df44a 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.es.xlf @@ -8,7 +8,7 @@ - Stop a running Aspire apphost. + Stop a running apphost or the specified resource. Detenga un apphost en ejecución de Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.fr.xlf index cec53477604..d935d5d550b 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.fr.xlf @@ -8,7 +8,7 @@ - Stop a running Aspire apphost. + Stop a running apphost or the specified resource. Arrêtez un AppHost Aspire en cours d’exécution. diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.it.xlf index 9b68a651af2..441ca7ed325 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.it.xlf @@ -8,7 +8,7 @@ - Stop a running Aspire apphost. + Stop a running apphost or the specified resource. Arrestare un apphost Aspire in esecuzione. diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ja.xlf index d5ee3f181d2..80704e4ff39 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ja.xlf @@ -8,7 +8,7 @@ - Stop a running Aspire apphost. + Stop a running apphost or the specified resource. 実行中の Aspire apphost を停止します。 diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ko.xlf index 15f7bf46bf3..78d60c7af4f 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ko.xlf @@ -8,7 +8,7 @@ - Stop a running Aspire apphost. + Stop a running apphost or the specified resource. 실행 중인 Aspire AppHost를 중지합니다. diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pl.xlf index e6fbb9ca5a8..1d1d128ea0e 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pl.xlf @@ -8,7 +8,7 @@ - Stop a running Aspire apphost. + Stop a running apphost or the specified resource. Zatrzymaj uruchomiony host aplikacji Wyszukaj. diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pt-BR.xlf index 5430e5d8cea..2bc61e88213 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pt-BR.xlf @@ -8,7 +8,7 @@ - Stop a running Aspire apphost. + Stop a running apphost or the specified resource. Pare um AppHost Aspire em execução. diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ru.xlf index 63555ec6b88..09328de1537 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ru.xlf @@ -8,7 +8,7 @@ - Stop a running Aspire apphost. + Stop a running apphost or the specified resource. Остановить запущенный хост приложений Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.tr.xlf index a63dfb40e72..3e026afd632 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.tr.xlf @@ -8,7 +8,7 @@ - Stop a running Aspire apphost. + Stop a running apphost or the specified resource. Çalışan bir Aspire apphost'unu durdurun. diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hans.xlf index 3741fe14883..531a4c6514c 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hans.xlf @@ -8,7 +8,7 @@ - Stop a running Aspire apphost. + Stop a running apphost or the specified resource. 停止一个正在运行的 Aspire AppHost。 diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hant.xlf index 4bf3271b187..1cb21ecb1f2 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hant.xlf @@ -8,7 +8,7 @@ - Stop a running Aspire apphost. + Stop a running apphost or the specified resource. 停止正在執行的 Aspire apphost。 From d6c3890b48db17386ecf2462eb182189ebd0c4ba Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:08:05 -0800 Subject: [PATCH 057/256] Add deployed compute resources and endpoints to pipeline deployment summary (#14367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Add deployed compute resources and endpoints to pipeline deployment summary For both Azure Container Apps and Azure App Service deployments, the pipeline summary now includes each compute resource name and its public endpoint URL (or "No public endpoints" if none are configured). This makes it immediately visible to users whether their services are publicly accessible after deployment. Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Remove emoji from summary keys and add Docker Compose endpoint summary Remove the emoji prefix (🖥️) from pipeline summary keys in AzureContainerAppResource and AzureAppServiceWebSiteResource. Add ctx.Summary.Add() calls to DockerComposeServiceResource.PrintEndpointsAsync() so Docker Compose deployments also show compute resources and their endpoints (or "No public endpoints") in the deployment summary. Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Address PR review feedback: move endpoint check inside step lambda and simplify App Service Move the anyPublicEndpoints check inside the step Action lambda in AzureContainerAppResource so endpoints are evaluated at execution time, not step creation time (addresses @eerhardt's feedback). For AzureAppServiceWebSiteResource, remove the conditional entirely since App Service always has a public endpoint at its .azurewebsites.net URL. This restores the original always-show-URL behavior, avoids unnecessary async resolution when there are no public endpoints, and addresses both @eerhardt's and Copilot code review's feedback. Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../AzureContainerAppResource.cs | 11 +++-------- .../AzureAppServiceWebSiteResource.cs | 6 +----- .../DockerComposeServiceResource.cs | 3 +++ 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs index 40e5dc5f26d..b310485b85e 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppResource.cs @@ -39,13 +39,6 @@ public AzureContainerAppResource(string name, Action(); - if (!targetResource.TryGetEndpoints(out var endpoints)) - { - endpoints = []; - } - - var anyPublicEndpoints = endpoints.Any(e => e.IsExternal); - var printResourceSummary = new PipelineStep { Name = $"print-{targetResource.Name}-summary", @@ -56,15 +49,17 @@ public AzureContainerAppResource(string name, Action e.IsExternal)) { var endpoint = $"https://{targetResource.Name.ToLowerInvariant()}.{domainValue}"; ctx.ReportingStep.Log(LogLevel.Information, $"Successfully deployed **{targetResource.Name}** to [{endpoint}]({endpoint})", enableMarkdown: true); + ctx.Summary.Add(targetResource.Name, endpoint); } else { ctx.ReportingStep.Log(LogLevel.Information, $"Successfully deployed **{targetResource.Name}** to Azure Container Apps environment **{containerAppEnv.Name}**. No public endpoints were configured.", enableMarkdown: true); + ctx.Summary.Add(targetResource.Name, "No public endpoints"); } }, Tags = ["print-summary"], diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebSiteResource.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebSiteResource.cs index a939f90af76..c71e4f3312e 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebSiteResource.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceWebSiteResource.cs @@ -39,11 +39,6 @@ public AzureAppServiceWebSiteResource(string name, Action(); - if (!targetResource.TryGetEndpoints(out var endpoints)) - { - endpoints = []; - } - var printResourceSummary = new PipelineStep { Name = $"print-{targetResource.Name}-summary", @@ -63,6 +58,7 @@ public AzureAppServiceWebSiteResource(string name, Action $"[{e}]({e})")); context.ReportingStep.Log(LogLevel.Information, $"Successfully deployed **{TargetResource.Name}** to {endpointList}.", enableMarkdown: true); + context.Summary.Add(TargetResource.Name, string.Join(", ", endpoints)); } else { @@ -353,6 +355,7 @@ private async Task PrintEndpointsAsync(PipelineStepContext context, DockerCompos context.ReportingStep.Log(LogLevel.Information, $"Successfully deployed **{TargetResource.Name}** to Docker Compose environment **{environment.Name}**.", enableMarkdown: true); + context.Summary.Add(TargetResource.Name, "No public endpoints"); } } From 4be5110f14bb70bc719e83215e7f5b7489163b79 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 6 Feb 2026 13:35:56 -0600 Subject: [PATCH 058/256] Implement IAzurePrivateEndpointTarget on more Azure resources (#14360) * Implement IAzurePrivateEndpointTarget on more Azure resources Follow up to #13108 Contributes to #13750 * Fix tests * Fix Redis private dns zone name. --- .../AzureAppConfigurationExtensions.cs | 29 ++- .../AzureAppConfigurationResource.cs | 15 +- .../AzureCosmosDBExtensions.cs | 57 ++++-- .../AzureCosmosDBResource.cs | 16 +- .../AzureEventHubsExtensions.cs | 14 +- .../AzureEventHubsResource.cs | 15 +- .../AzureKeyVaultResource.cs | 15 +- .../AzureKeyVaultResourceExtensions.cs | 68 ++++--- .../AzurePostgresExtensions.cs | 88 ++++++--- .../AzurePostgresFlexibleServerResource.cs | 15 +- .../AzureManagedRedisExtensions.cs | 13 +- .../AzureManagedRedisResource.cs | 15 +- .../AzureSearchExtensions.cs | 35 +++- .../AzureSearchResource.cs | 15 +- .../AzureServiceBusExtensions.cs | 14 +- .../AzureServiceBusResource.cs | 15 +- .../AzureSignalRExtensions.cs | 53 +++-- .../AzureSignalRResource.cs | 15 +- .../AzureSqlExtensions.cs | 39 ++-- .../AzureSqlServerResource.cs | 15 +- .../AzureDataLakeStorageResource.cs | 11 +- .../AzureTableStorageResource.cs | 14 +- .../AzureWebPubSubExtensions.cs | 21 +- .../AzureWebPubSubResource.cs | 15 +- .../AzureAppConfigurationExtensionsTests.cs | 2 + .../AzurePrivateEndpointLockdownTests.cs | 185 ++++++++++++++++++ ...zureStoragePrivateEndpointLockdownTests.cs | 39 ++++ ...vironmentWorksWithSqlServer.verified.bicep | 2 + ...e_NoAccessKeyAuthentication.verified.bicep | 4 +- ...ntication_kvName=mykeyvault.verified.bicep | 4 +- ...yAuthentication_kvName=null.verified.bicep | 4 +- ...ishMode_WithDefaultAzureSku.verified.bicep | 4 +- ...e_NoAccessKeyAuthentication.verified.bicep | 4 +- ...ntication_kvName=mykeyvault.verified.bicep | 4 +- ...yAuthentication_kvName=null.verified.bicep | 4 +- ..._useAcaInfrastructure=False.verified.bicep | 4 +- ...B_useAcaInfrastructure=True.verified.bicep | 4 +- ...ccessKeyAuth_ChildResources.verified.bicep | 4 +- ...tHubAndConsumerGroupName#00.verified.bicep | 3 + ...ddKeyVaultViaPublishMode#00.verified.bicep | 4 +- ...Tests.AddKeyVaultViaRunMode.verified.bicep | 4 +- ...crets_GeneratesCorrectBicep.verified.bicep | 4 +- ...ource_GeneratesCorrectBicep.verified.bicep | 4 +- ...ssion_GeneratesCorrectBicep.verified.bicep | 4 +- ...ntication_kvName=mykeyvault.verified.bicep | 2 + ...yAuthentication_kvName=null.verified.bicep | 2 + ...eAcaInfrastructure=False#00.verified.bicep | 2 + ...seAcaInfrastructure=True#00.verified.bicep | 2 + ...eAcaInfrastructure=False#00.verified.bicep | 2 + ...seAcaInfrastructure=True#00.verified.bicep | 2 + ...eAcaInfrastructure=False#00.verified.bicep | 2 + ...ord=False_kvName=mykeyvault.verified.bicep | 2 + ...yPassword=False_kvName=null.verified.bicep | 2 + ...word=True_kvName=mykeyvault.verified.bicep | 2 + ...fyPassword=True_kvName=null.verified.bicep | 2 + ...ord=False_kvName=mykeyvault.verified.bicep | 2 + ...yPassword=False_kvName=null.verified.bicep | 2 + ...word=True_kvName=mykeyvault.verified.bicep | 2 + ...fyPassword=True_kvName=null.verified.bicep | 2 + ...point_GeneratesCorrectBicep.verified.bicep | 23 +++ ...point_GeneratesCorrectBicep.verified.bicep | 36 ++++ ...point_GeneratesCorrectBicep.verified.bicep | 27 +++ ...point_GeneratesCorrectBicep.verified.bicep | 25 +++ ...point_GeneratesCorrectBicep.verified.bicep | 31 +++ ...point_GeneratesCorrectBicep.verified.bicep | 43 ++++ ...point_GeneratesCorrectBicep.verified.bicep | 28 +++ ...point_GeneratesCorrectBicep.verified.bicep | 27 +++ ...point_GeneratesCorrectBicep.verified.bicep | 36 ++++ ...point_GeneratesCorrectBicep.verified.bicep | 35 ++++ ...point_GeneratesCorrectBicep.verified.bicep | 28 +++ ...ceOptionsCanBeConfigured#00.verified.bicep | 3 + ...ceOptionsCanBeConfigured#01.verified.bicep | 2 + ...ensionsTests.AddAzureSearch.verified.bicep | 4 +- ...us_useObsoleteMethods=False.verified.bicep | 3 + ...Bus_useObsoleteMethods=True.verified.bicep | 3 + ...anBeDifferentThanAzureNames.verified.bicep | 3 + ...24_useObsoleteMethods=False.verified.bicep | 3 + ...n24_useObsoleteMethods=True.verified.bicep | 3 + ...onsTests.AddAzureSignalR#00.verified.bicep | 4 +- ...ddServerlessAzureSignalR#00.verified.bicep | 4 +- ..._useAcaInfrastructure=False.verified.bicep | 2 + ...e_useAcaInfrastructure=True.verified.bicep | 2 + ..._useAcaInfrastructure=False.verified.bicep | 2 + ...reSqlDatabaseViaPublishMode.verified.bicep | 2 + ...sAzureSqlDatabaseViaRunMode.verified.bicep | 2 + ...point_GeneratesCorrectBicep.verified.bicep | 36 ++++ ...point_GeneratesCorrectBicep.verified.bicep | 36 ++++ ...ddAzureWebPubSubHubSettings.verified.bicep | 4 +- ...EventHandlerExpressionWorks.verified.bicep | 4 +- ...s.AddAzureWebPubSubHubWorks.verified.bicep | 4 +- ...zureWebPubSubWithParameters.verified.bicep | 4 +- ...ts.AddDefaultAzureWebPubSub.verified.bicep | 4 +- ...ddWebPubSubWithHubConfigure.verified.bicep | 4 +- ...uctOverridesAddEventHandler.verified.bicep | 4 +- ...zureServiceBusInPublishMode.verified.bicep | 2 + ...ingAzureServiceBusInRunMode.verified.bicep | 2 + ...lishAsExistingInPublishMode.verified.bicep | 3 + ...figurationWithResourceGroup.verified.bicep | 4 +- ...reCosmosDBWithResourceGroup.verified.bicep | 2 + ...BWithResourceGroupAccessKey.verified.bicep | 4 +- ...EnterpriseWithResourceGroup.verified.bicep | 2 + ...sourceGroupAndAccessKeyAuth.verified.bicep | 2 + ...zureSearchWithResourceGroup.verified.bicep | 2 + ...ureSignalRWithResourceGroup.verified.bicep | 4 +- ...tingAzureSqlServerInRunMode.verified.bicep | 2 + ...eSqlServerWithResourceGroup.verified.bicep | 2 + ...eWebPubSubWithResourceGroup.verified.bicep | 4 +- ...gEventHubsWithResourceGroup.verified.bicep | 2 + ...ngKeyVaultWithResourceGroup.verified.bicep | 4 +- ...ostgresSqlWithResourceGroup.verified.bicep | 2 + ...sourceGroupWithPasswordAuth.verified.bicep | 2 + ...sourceGroupInPublishMode#00.verified.bicep | 2 + ...rviceBusWithStaticArguments.verified.bicep | 2 + 113 files changed, 1310 insertions(+), 163 deletions(-) create mode 100644 tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointLockdownTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureAppConfiguration_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureCosmosDB_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureEventHubs_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureKeyVault_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureManagedRedis_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzurePostgresFlexibleServer_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureSearch_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureServiceBus_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureSignalR_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureSqlServer_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureWebPubSub_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithDataLakePrivateEndpoint_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithTablePrivateEndpoint_GeneratesCorrectBicep.verified.bicep diff --git a/src/Aspire.Hosting.Azure.AppConfiguration/AzureAppConfigurationExtensions.cs b/src/Aspire.Hosting.Azure.AppConfiguration/AzureAppConfigurationExtensions.cs index 9ad605bcc5f..60649e25038 100644 --- a/src/Aspire.Hosting.Azure.AppConfiguration/AzureAppConfigurationExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppConfiguration/AzureAppConfigurationExtensions.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 Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Aspire.Hosting.Azure.AppConfiguration; @@ -36,6 +38,11 @@ public static IResourceBuilder AddAzureAppConfigu var configureInfrastructure = (AzureResourceInfrastructure infrastructure) => { + var azureResource = (AzureAppConfigurationResource)infrastructure.AspireResource; + + // Check if this App Configuration has a private endpoint (via annotation) + var hasPrivateEndpoint = azureResource.HasAnnotationOfType(); + var store = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, (identifier, name) => { @@ -43,17 +50,31 @@ public static IResourceBuilder AddAzureAppConfigu resource.Name = name; return resource; }, - (infrastructure) => new AppConfigurationStore(infrastructure.AspireResource.GetBicepIdentifier()) + (infrastructure) => { - SkuName = "standard", - DisableLocalAuth = true, - Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + var appConfig = new AppConfigurationStore(infrastructure.AspireResource.GetBicepIdentifier()) + { + SkuName = "standard", + DisableLocalAuth = true, + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + + // When using private endpoints, disable public network access. + if (hasPrivateEndpoint) + { + appConfig.PublicNetworkAccess = AppConfigurationPublicNetworkAccess.Disabled; + } + + return appConfig; }); infrastructure.Add(new ProvisioningOutput("appConfigEndpoint", typeof(string)) { Value = store.Endpoint.ToBicepExpression() }); // We need to output name to externalize role assignments. infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = store.Name.ToBicepExpression() }); + + // Output the resource id for private endpoint support. + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = store.Id.ToBicepExpression() }); }; var resource = new AzureAppConfigurationResource(name, configureInfrastructure); diff --git a/src/Aspire.Hosting.Azure.AppConfiguration/AzureAppConfigurationResource.cs b/src/Aspire.Hosting.Azure.AppConfiguration/AzureAppConfigurationResource.cs index 8f18944f71f..b3e11a7c663 100644 --- a/src/Aspire.Hosting.Azure.AppConfiguration/AzureAppConfigurationResource.cs +++ b/src/Aspire.Hosting.Azure.AppConfiguration/AzureAppConfigurationResource.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 Aspire.Hosting.ApplicationModel; using Azure.Provisioning.AppConfiguration; using Azure.Provisioning.Primitives; @@ -14,7 +16,7 @@ namespace Aspire.Hosting.Azure; /// Callback to configure the Azure resources. public class AzureAppConfigurationResource(string name, Action configureInfrastructure) : AzureProvisioningResource(name, configureInfrastructure), - IResourceWithConnectionString, IResourceWithEndpoints + IResourceWithConnectionString, IResourceWithEndpoints, IAzurePrivateEndpointTarget { private EndpointReference EmulatorEndpoint => new(this, "emulator"); @@ -33,6 +35,11 @@ public class AzureAppConfigurationResource(string name, Action public BicepOutputReference NameOutputReference => new("name", this); + /// + /// Gets the "id" output reference for the resource. + /// + public BicepOutputReference Id => new("id", this); + /// /// Gets the connection string template for the manifest for the Azure App Configuration resource. /// @@ -69,4 +76,10 @@ public override ProvisionableResource AddAsExistingResource(AzureResourceInfrast infra.Add(store); return store; } + + BicepOutputReference IAzurePrivateEndpointTarget.Id => Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["configurationStores"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.azconfig.io"; } diff --git a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs index 42d1adfaa80..f67a41291d4 100644 --- a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs +++ b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.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.Diagnostics.CodeAnalysis; using System.Globalization; using Aspire.Hosting; @@ -454,6 +456,9 @@ private static void ConfigureCosmosDBInfrastructure(AzureResourceInfrastructure var azureResource = (AzureCosmosDBResource)infrastructure.AspireResource; bool disableLocalAuth = !azureResource.UseAccessKeyAuthentication; + // Check if this CosmosDB has a private endpoint (via annotation) + var hasPrivateEndpoint = azureResource.HasAnnotationOfType(); + var cosmosAccount = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, (identifier, name) => { @@ -461,28 +466,39 @@ private static void ConfigureCosmosDBInfrastructure(AzureResourceInfrastructure resource.Name = name; return resource; }, - (infrastructure) => new CosmosDBAccount(infrastructure.AspireResource.GetBicepIdentifier()) + (infrastructure) => { - Kind = CosmosDBAccountKind.GlobalDocumentDB, - Capabilities = azureResource.UseDefaultAzureSku ? [] : new BicepList - { - new CosmosDBAccountCapability { Name = CosmosConstants.EnableServerlessCapability } - }, - ConsistencyPolicy = new ConsistencyPolicy() - { - DefaultConsistencyLevel = DefaultConsistencyLevel.Session - }, - DatabaseAccountOfferType = CosmosDBAccountOfferType.Standard, - Locations = + var account = new CosmosDBAccount(infrastructure.AspireResource.GetBicepIdentifier()) { - new CosmosDBAccountLocation + Kind = CosmosDBAccountKind.GlobalDocumentDB, + Capabilities = azureResource.UseDefaultAzureSku ? [] : new BicepList { - LocationName = new IdentifierExpression("location"), - FailoverPriority = 0 - } - }, - DisableLocalAuth = disableLocalAuth, - Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + new CosmosDBAccountCapability { Name = CosmosConstants.EnableServerlessCapability } + }, + ConsistencyPolicy = new ConsistencyPolicy() + { + DefaultConsistencyLevel = DefaultConsistencyLevel.Session + }, + DatabaseAccountOfferType = CosmosDBAccountOfferType.Standard, + Locations = + { + new CosmosDBAccountLocation + { + LocationName = new IdentifierExpression("location"), + FailoverPriority = 0 + } + }, + DisableLocalAuth = disableLocalAuth, + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + + // When using private endpoints, disable public network access. + if (hasPrivateEndpoint) + { + account.PublicNetworkAccess = CosmosDBPublicNetworkAccess.Disabled; + } + + return account; }); foreach (var database in azureResource.Databases) @@ -594,6 +610,9 @@ private static void ConfigureCosmosDBInfrastructure(AzureResourceInfrastructure // We need to output name to externalize role assignments. infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = cosmosAccount.Name.ToBicepExpression() }); + + // Output the resource id for private endpoint support. + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = cosmosAccount.Id.ToBicepExpression() }); } internal static void AddContributorRoleAssignment(AzureResourceInfrastructure infra, CosmosDBAccount cosmosAccount, BicepValue principalId) diff --git a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.cs b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.cs index ca6ba6fccf8..54ecaed01a3 100644 --- a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.cs +++ b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBResource.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.Diagnostics; using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; @@ -18,7 +20,8 @@ public class AzureCosmosDBResource(string name, Action Databases { get; } = []; @@ -66,6 +69,11 @@ public class AzureCosmosDBResource(string name, Action public BicepOutputReference NameOutputReference => new("name", this); + /// + /// Gets the "id" output reference for the resource. + /// + public BicepOutputReference Id => new("id", this); + /// /// Gets a value indicating whether the resource uses access key authentication. /// @@ -251,4 +259,10 @@ IEnumerable> IResourceWithConnectionSt yield return new("ConnectionString", ConnectionStringExpression); } } + + BicepOutputReference IAzurePrivateEndpointTarget.Id => Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["Sql"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.documents.azure.com"; } diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs index 148e28546c1..a8534721741 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.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; using System.Text.Json; using System.Text.Json.Nodes; @@ -45,6 +47,11 @@ public static IResourceBuilder AddAzureEventHubs( var configureInfrastructure = static (AzureResourceInfrastructure infrastructure) => { + var azureResource = (AzureEventHubsResource)infrastructure.AspireResource; + + // Check if this Event Hubs has a private endpoint (via annotation) + var hasPrivateEndpoint = azureResource.HasAnnotationOfType(); + var eventHubsNamespace = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, (identifier, name) => { @@ -67,6 +74,10 @@ public static IResourceBuilder AddAzureEventHubs( { Name = skuParameter }, + // When using private endpoints, disable public network access. + PublicNetworkAccess = hasPrivateEndpoint + ? AzureProvisioning.EventHubsPublicNetworkAccess.Disabled + : AzureProvisioning.EventHubsPublicNetworkAccess.Enabled, Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } }; return resource; @@ -92,7 +103,8 @@ public static IResourceBuilder AddAzureEventHubs( // We need to output name to externalize role assignments. infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = eventHubsNamespace.Name.ToBicepExpression() }); - var azureResource = (AzureEventHubsResource)infrastructure.AspireResource; + // Output the resource id for private endpoint support. + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = eventHubsNamespace.Id.ToBicepExpression() }); foreach (var hub in azureResource.Hubs) { diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs index 41c6af1f7d5..d41cf08fe33 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.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 Aspire.Hosting.ApplicationModel; using Azure.Provisioning.EventHubs; using Azure.Provisioning.Primitives; @@ -13,7 +15,7 @@ namespace Aspire.Hosting.Azure; /// The name of the resource. /// Callback to configure the Azure Event Hubs resource. public class AzureEventHubsResource(string name, Action configureInfrastructure) - : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IResourceWithEndpoints, IResourceWithAzureFunctionsConfig + : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IResourceWithEndpoints, IResourceWithAzureFunctionsConfig, IAzurePrivateEndpointTarget { private static readonly string[] s_eventHubClientNames = [ @@ -43,6 +45,11 @@ public class AzureEventHubsResource(string name, Action public BicepOutputReference NameOutputReference => new("name", this); + /// + /// Gets the "id" output reference for the resource. + /// + public BicepOutputReference Id => new("id", this); + internal EndpointReference EmulatorEndpoint => new(this, "emulator"); /// @@ -205,4 +212,10 @@ IEnumerable> IResourceWithConnectionSt yield return new("ConnectionString", ReferenceExpression.Create($"Endpoint={EmulatorEndpoint.Property(EndpointProperty.HostAndPort)};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true")); } } + + BicepOutputReference IAzurePrivateEndpointTarget.Id => Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["namespace"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.servicebus.windows.net"; } diff --git a/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResource.cs b/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResource.cs index 2fc502be765..c070dd76ba0 100644 --- a/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResource.cs +++ b/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResource.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 Aspire.Hosting.ApplicationModel; using Azure.Provisioning.KeyVault; using Azure.Provisioning.Primitives; @@ -13,7 +15,7 @@ namespace Aspire.Hosting.Azure; /// The name of the resource. /// Callback to configure the Azure resources. public class AzureKeyVaultResource(string name, Action configureInfrastructure) - : AzureProvisioningResource(name, configureInfrastructure), IResourceWithEndpoints, IResourceWithConnectionString, IAzureKeyVaultResource + : AzureProvisioningResource(name, configureInfrastructure), IResourceWithEndpoints, IResourceWithConnectionString, IAzureKeyVaultResource, IAzurePrivateEndpointTarget { /// /// The secrets for this Key Vault. @@ -29,6 +31,11 @@ public class AzureKeyVaultResource(string name, Action public BicepOutputReference NameOutputReference => new("name", this); + /// + /// Gets the "id" output reference for the Azure Key Vault resource. + /// + public BicepOutputReference Id => new("id", this); + /// /// Gets a value indicating whether the Azure Key Vault resource is running in the local emulator. /// @@ -139,4 +146,10 @@ IEnumerable> IResourceWithConnectionSt { yield return new("Uri", UriExpression); } + + BicepOutputReference IAzurePrivateEndpointTarget.Id => Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["vault"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.vaultcore.azure.net"; } diff --git a/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResourceExtensions.cs b/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResourceExtensions.cs index cb30d4e06e9..b6bdd58c277 100644 --- a/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResourceExtensions.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 Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Azure.Provisioning; @@ -65,6 +67,11 @@ public static IResourceBuilder AddAzureKeyVault(this IDis var configureInfrastructure = static (AzureResourceInfrastructure infrastructure) => { + var azureResource = (AzureKeyVaultResource)infrastructure.AspireResource; + + // Check if this Key Vault has a private endpoint (via annotation) + var hasPrivateEndpoint = azureResource.HasAnnotationOfType(); + var keyVault = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, (identifier, name) => { @@ -72,19 +79,30 @@ public static IResourceBuilder AddAzureKeyVault(this IDis resource.Name = name; return resource; }, - (infrastructure) => new KeyVaultService(infrastructure.AspireResource.GetBicepIdentifier()) + (infrastructure) => { - Properties = new KeyVaultProperties() + var kv = new KeyVaultService(infrastructure.AspireResource.GetBicepIdentifier()) { - TenantId = BicepFunction.GetTenant().TenantId, - Sku = new KeyVaultSku() + Properties = new KeyVaultProperties() { - Family = KeyVaultSkuFamily.A, - Name = KeyVaultSkuName.Standard + TenantId = BicepFunction.GetTenant().TenantId, + Sku = new KeyVaultSku() + { + Family = KeyVaultSkuFamily.A, + Name = KeyVaultSkuName.Standard + }, + EnableRbacAuthorization = true, }, - EnableRbacAuthorization = true, - }, - Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + + // When using private endpoints, disable public network access. + if (hasPrivateEndpoint) + { + kv.Properties.PublicNetworkAccess = "Disabled"; + } + + return kv; }); infrastructure.Add(new ProvisioningOutput("vaultUri", typeof(string)) @@ -93,31 +111,31 @@ public static IResourceBuilder AddAzureKeyVault(this IDis }); // Process all secret resources - if (infrastructure.AspireResource is AzureKeyVaultResource kvResource) + foreach (var secretResource in azureResource.Secrets) { - foreach (var secretResource in kvResource.Secrets) - { - var value = secretResource.Value as IManifestExpressionProvider ?? throw new NotSupportedException( - $"Secret value for '{secretResource.SecretName}' is an unsupported type."); + var value = secretResource.Value as IManifestExpressionProvider ?? throw new NotSupportedException( + $"Secret value for '{secretResource.SecretName}' is an unsupported type."); - var paramValue = value.AsProvisioningParameter(infrastructure, isSecure: true); + var paramValue = value.AsProvisioningParameter(infrastructure, isSecure: true); - var secret = new KeyVaultSecret(Infrastructure.NormalizeBicepIdentifier($"secret_{secretResource.SecretName}")) + var secret = new KeyVaultSecret(Infrastructure.NormalizeBicepIdentifier($"secret_{secretResource.SecretName}")) + { + Name = secretResource.SecretName, + Properties = new SecretProperties { - Name = secretResource.SecretName, - Properties = new SecretProperties - { - Value = paramValue - }, - Parent = keyVault, - }; + Value = paramValue + }, + Parent = keyVault, + }; - infrastructure.Add(secret); - } + infrastructure.Add(secret); } // We need to output name to externalize role assignments. infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = keyVault.Name.ToBicepExpression() }); + + // Output the resource id for private endpoint support. + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = keyVault.Id.ToBicepExpression() }); }; var resource = new AzureKeyVaultResource(name, configureInfrastructure); diff --git a/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresExtensions.cs b/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresExtensions.cs index 16b581cc253..727197c8855 100644 --- a/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresExtensions.cs +++ b/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresExtensions.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.Diagnostics.CodeAnalysis; using System.Net; using Aspire.Hosting.ApplicationModel; @@ -401,6 +403,9 @@ public static IResourceBuilder With private static PostgreSqlFlexibleServer CreatePostgreSqlFlexibleServer(AzureResourceInfrastructure infrastructure, IDistributedApplicationBuilder distributedApplicationBuilder, IReadOnlyDictionary databases) { + // Check if this PostgreSQL server has a private endpoint (via annotation) + var hasPrivateEndpoint = infrastructure.AspireResource.HasAnnotationOfType(); + var postgres = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, (identifier, name) => { @@ -408,47 +413,65 @@ private static PostgreSqlFlexibleServer CreatePostgreSqlFlexibleServer(AzureReso resource.Name = name; return resource; }, - (infrastructure) => new PostgreSqlFlexibleServer(infrastructure.AspireResource.GetBicepIdentifier()) + (infrastructure) => { - StorageSizeInGB = 32, - Sku = new PostgreSqlFlexibleServerSku() - { - Name = "Standard_B1ms", - Tier = PostgreSqlFlexibleServerSkuTier.Burstable - }, - Version = new StringLiteralExpression("16"), - HighAvailability = new PostgreSqlFlexibleServerHighAvailability() + var server = new PostgreSqlFlexibleServer(infrastructure.AspireResource.GetBicepIdentifier()) { - Mode = PostgreSqlFlexibleServerHighAvailabilityMode.Disabled - }, - Backup = new PostgreSqlFlexibleServerBackupProperties() + StorageSizeInGB = 32, + Sku = new PostgreSqlFlexibleServerSku() + { + Name = "Standard_B1ms", + Tier = PostgreSqlFlexibleServerSkuTier.Burstable + }, + Version = new StringLiteralExpression("16"), + HighAvailability = new PostgreSqlFlexibleServerHighAvailability() + { + Mode = PostgreSqlFlexibleServerHighAvailabilityMode.Disabled + }, + Backup = new PostgreSqlFlexibleServerBackupProperties() + { + BackupRetentionDays = 7, + GeoRedundantBackup = PostgreSqlFlexibleServerGeoRedundantBackupEnum.Disabled + }, + AvailabilityZone = "1", + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + + // When using private endpoints, disable public network access. + if (hasPrivateEndpoint) { - BackupRetentionDays = 7, - GeoRedundantBackup = PostgreSqlFlexibleServerGeoRedundantBackupEnum.Disabled - }, - AvailabilityZone = "1", - Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } - }); + server.Network = new PostgreSqlFlexibleServerNetwork() + { + PublicNetworkAccess = PostgreSqlFlexibleServerPublicNetworkAccessState.Disabled + }; + } - // Opens access to all Azure services. - infrastructure.Add(new PostgreSqlFlexibleServerFirewallRule("postgreSqlFirewallRule_AllowAllAzureIps") - { - Parent = postgres, - Name = "AllowAllAzureIps", - StartIPAddress = new IPAddress([0, 0, 0, 0]), - EndIPAddress = new IPAddress([0, 0, 0, 0]) - }); + return server; + }); - if (distributedApplicationBuilder.ExecutionContext.IsRunMode) + // Only add firewall rules when not using private endpoints + if (!hasPrivateEndpoint) { - // Opens access to the Internet. - infrastructure.Add(new PostgreSqlFlexibleServerFirewallRule("postgreSqlFirewallRule_AllowAllIps") + // Opens access to all Azure services. + infrastructure.Add(new PostgreSqlFlexibleServerFirewallRule("postgreSqlFirewallRule_AllowAllAzureIps") { Parent = postgres, - Name = "AllowAllIps", + Name = "AllowAllAzureIps", StartIPAddress = new IPAddress([0, 0, 0, 0]), - EndIPAddress = new IPAddress([255, 255, 255, 255]) + EndIPAddress = new IPAddress([0, 0, 0, 0]) }); + + if (distributedApplicationBuilder.ExecutionContext.IsRunMode) + { + // Opens access to the Internet. + infrastructure.Add(new PostgreSqlFlexibleServerFirewallRule("postgreSqlFirewallRule_AllowAllIps") + { + Parent = postgres, + Name = "AllowAllIps", + StartIPAddress = new IPAddress([0, 0, 0, 0]), + EndIPAddress = new IPAddress([255, 255, 255, 255]) + }); + } } foreach (var databaseNames in databases) @@ -546,6 +569,9 @@ private static void ConfigurePostgreSqlInfrastructure(AzureResourceInfrastructur // We need to output name to externalize role assignments. infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = postgres.Name.ToBicepExpression() }); + // Output the resource id for private endpoint support. + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = postgres.Id.ToBicepExpression() }); + // Always output the hostName for the PostgreSQL server. infrastructure.Add(new ProvisioningOutput("hostName", typeof(string)) { Value = postgres.FullyQualifiedDomainName.ToBicepExpression() }); } diff --git a/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresFlexibleServerResource.cs b/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresFlexibleServerResource.cs index 67729fd1858..ee53b338bb7 100644 --- a/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresFlexibleServerResource.cs +++ b/src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresFlexibleServerResource.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.Diagnostics; using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; @@ -17,7 +19,7 @@ namespace Aspire.Hosting.Azure; /// The name of the resource. /// Callback to configure infrastructure. public class AzurePostgresFlexibleServerResource(string name, Action configureInfrastructure) - : AzureProvisioningResource(name, configureInfrastructure), IResourceWithEndpoints, IResourceWithConnectionString + : AzureProvisioningResource(name, configureInfrastructure), IResourceWithEndpoints, IResourceWithConnectionString, IAzurePrivateEndpointTarget { private readonly Dictionary _databases = new Dictionary(StringComparers.ResourceName); @@ -40,6 +42,11 @@ public class AzurePostgresFlexibleServerResource(string name, Action public BicepOutputReference NameOutputReference => new("name", this); + /// + /// Gets the "id" output reference for the resource. + /// + public BicepOutputReference Id => new("id", this); + /// /// Gets the "hostName" output reference from the bicep template for the Azure Postgres Flexible Server. /// @@ -306,4 +313,10 @@ IEnumerable> IResourceWithConnectionSt return propertiesDictionary; } + + BicepOutputReference IAzurePrivateEndpointTarget.Id => Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["postgresqlServer"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.postgres.database.azure.com"; } diff --git a/src/Aspire.Hosting.Azure.Redis/AzureManagedRedisExtensions.cs b/src/Aspire.Hosting.Azure.Redis/AzureManagedRedisExtensions.cs index c243fd4d5f6..b540998e385 100644 --- a/src/Aspire.Hosting.Azure.Redis/AzureManagedRedisExtensions.cs +++ b/src/Aspire.Hosting.Azure.Redis/AzureManagedRedisExtensions.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 Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Azure.Provisioning; @@ -178,6 +180,9 @@ private static void ConfigureRedisInfrastructure(AzureResourceInfrastructure inf { var redisResource = (AzureManagedRedisResource)infrastructure.AspireResource; + // Check if this Redis has a private endpoint (via annotation) + var hasPrivateEndpoint = redisResource.HasAnnotationOfType(); + var redis = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, (identifier, name) => { @@ -194,7 +199,10 @@ private static void ConfigureRedisInfrastructure(AzureResourceInfrastructure inf Name = RedisEnterpriseSkuName.BalancedB0 }, MinimumTlsVersion = RedisEnterpriseTlsVersion.Tls1_2, - PublicNetworkAccess = RedisEnterprisePublicNetworkAccess.Enabled + // When using private endpoints, disable public network access. + PublicNetworkAccess = hasPrivateEndpoint + ? RedisEnterprisePublicNetworkAccess.Disabled + : RedisEnterprisePublicNetworkAccess.Enabled }; infra.Add(cluster); @@ -267,6 +275,9 @@ private static void ConfigureRedisInfrastructure(AzureResourceInfrastructure inf // We need to output name to externalize role assignments. infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = redis.Name.ToBicepExpression() }); + // Output the resource id for private endpoint support. + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = redis.Id.ToBicepExpression() }); + // Always output the hostName for the Redis server. infrastructure.Add(new ProvisioningOutput("hostName", typeof(string)) { Value = redis.HostName.ToBicepExpression() }); } diff --git a/src/Aspire.Hosting.Azure.Redis/AzureManagedRedisResource.cs b/src/Aspire.Hosting.Azure.Redis/AzureManagedRedisResource.cs index 8cad266015b..14aa1526830 100644 --- a/src/Aspire.Hosting.Azure.Redis/AzureManagedRedisResource.cs +++ b/src/Aspire.Hosting.Azure.Redis/AzureManagedRedisResource.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.Diagnostics; using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; @@ -17,7 +19,7 @@ namespace Aspire.Hosting.Azure; /// The name of the resource. /// Callback to configure the Azure resources. public class AzureManagedRedisResource(string name, Action configureInfrastructure) - : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString + : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IAzurePrivateEndpointTarget { /// /// Gets the "connectionString" output reference from the bicep template for the Azure Managed Redis resource. @@ -45,6 +47,11 @@ public class AzureManagedRedisResource(string name, Action public BicepOutputReference NameOutputReference => new("name", this); + /// + /// Gets the "id" output reference for the resource. + /// + public BicepOutputReference Id => new("id", this); + /// /// Gets the "hostName" output reference from the bicep template for the Azure Redis resource. /// @@ -240,4 +247,10 @@ IEnumerable> IResourceWithConnectionSt yield return new("Password", Password); } } + + BicepOutputReference IAzurePrivateEndpointTarget.Id => Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["redisEnterprise"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.redis.azure.net"; } diff --git a/src/Aspire.Hosting.Azure.Search/AzureSearchExtensions.cs b/src/Aspire.Hosting.Azure.Search/AzureSearchExtensions.cs index 9f7fd41d1e8..86a688b3838 100644 --- a/src/Aspire.Hosting.Azure.Search/AzureSearchExtensions.cs +++ b/src/Aspire.Hosting.Azure.Search/AzureSearchExtensions.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 Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Azure.Provisioning; @@ -43,6 +45,11 @@ public static IResourceBuilder AddAzureSearch(this IDistrib void ConfigureSearch(AzureResourceInfrastructure infrastructure) { + var azureResource = (AzureSearchResource)infrastructure.AspireResource; + + // Check if this Search service has a private endpoint (via annotation) + var hasPrivateEndpoint = azureResource.HasAnnotationOfType(); + var search = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, (identifier, name) => { @@ -50,14 +57,25 @@ void ConfigureSearch(AzureResourceInfrastructure infrastructure) resource.Name = name; return resource; }, - (infrastructure) => new SearchService(infrastructure.AspireResource.GetBicepIdentifier()) + (infrastructure) => { - SearchSkuName = SearchServiceSkuName.Basic, - ReplicaCount = 1, - PartitionCount = 1, - HostingMode = SearchServiceHostingMode.Default, - IsLocalAuthDisabled = true, - Tags = { { "aspire-resource-name", name } } + var svc = new SearchService(infrastructure.AspireResource.GetBicepIdentifier()) + { + SearchSkuName = SearchServiceSkuName.Basic, + ReplicaCount = 1, + PartitionCount = 1, + HostingMode = SearchServiceHostingMode.Default, + IsLocalAuthDisabled = true, + Tags = { { "aspire-resource-name", name } } + }; + + // When using private endpoints, disable public network access. + if (hasPrivateEndpoint) + { + svc.PublicNetworkAccess = SearchServicePublicNetworkAccess.Disabled; + } + + return svc; }); // TODO: The endpoint format should move into Azure.Provisioning so we can maintain this @@ -75,6 +93,9 @@ void ConfigureSearch(AzureResourceInfrastructure infrastructure) // We need to output name to externalize role assignments. infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = search.Name.ToBicepExpression() }); + + // Output the resource id for private endpoint support. + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = search.Id.ToBicepExpression() }); } } diff --git a/src/Aspire.Hosting.Azure.Search/AzureSearchResource.cs b/src/Aspire.Hosting.Azure.Search/AzureSearchResource.cs index 9a439d0616a..b607946d586 100644 --- a/src/Aspire.Hosting.Azure.Search/AzureSearchResource.cs +++ b/src/Aspire.Hosting.Azure.Search/AzureSearchResource.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 Aspire.Hosting.ApplicationModel; using Azure.Provisioning.Primitives; using Azure.Provisioning.Search; @@ -13,7 +15,7 @@ namespace Aspire.Hosting.Azure; /// The name of the resource /// Callback to configure the Azure AI Search resource. public class AzureSearchResource(string name, Action configureInfrastructure) - : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString + : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IAzurePrivateEndpointTarget { /// /// Gets the "connectionString" output reference from the Azure AI Search resource. @@ -28,6 +30,11 @@ public class AzureSearchResource(string name, Action public BicepOutputReference NameOutputReference => new("name", this); + /// + /// Gets the "id" output reference for the resource. + /// + public BicepOutputReference Id => new("id", this); + /// /// Gets the service endpoint URI expression for the Azure AI Search resource. /// @@ -83,4 +90,10 @@ IEnumerable> IResourceWithConnectionSt { yield return new("Uri", UriExpression); } + + BicepOutputReference IAzurePrivateEndpointTarget.Id => Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["searchService"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.search.windows.net"; } diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index db495738f8d..9699d2668d5 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.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; using System.Text.Json; using System.Text.Json.Nodes; @@ -44,6 +46,11 @@ public static IResourceBuilder AddAzureServiceBus(this var configureInfrastructure = static (AzureResourceInfrastructure infrastructure) => { + var azureResource = (AzureServiceBusResource)infrastructure.AspireResource; + + // Check if this Service Bus has a private endpoint (via annotation) + var hasPrivateEndpoint = azureResource.HasAnnotationOfType(); + AzureProvisioning.ServiceBusNamespace serviceBusNamespace = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, (identifier, name) => { @@ -65,6 +72,10 @@ public static IResourceBuilder AddAzureServiceBus(this Name = skuParameter }, DisableLocalAuth = true, + // When using private endpoints, disable public network access. + PublicNetworkAccess = hasPrivateEndpoint + ? AzureProvisioning.ServiceBusPublicNetworkAccess.Disabled + : AzureProvisioning.ServiceBusPublicNetworkAccess.Enabled, Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } }; return resource; @@ -90,7 +101,8 @@ public static IResourceBuilder AddAzureServiceBus(this // We need to output name to externalize role assignments. infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = serviceBusNamespace.Name.ToBicepExpression() }); - var azureResource = (AzureServiceBusResource)infrastructure.AspireResource; + // Output the resource id for private endpoint support. + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = serviceBusNamespace.Id.ToBicepExpression() }); foreach (var queue in azureResource.Queues) { diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusResource.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusResource.cs index b04b9546858..332feb78b66 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusResource.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusResource.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 Aspire.Hosting.ApplicationModel; using Azure.Provisioning.Primitives; using Azure.Provisioning.ServiceBus; @@ -13,7 +15,7 @@ namespace Aspire.Hosting.Azure; /// The name of the resource. /// Callback to configure the Azure Service Bus resource. public class AzureServiceBusResource(string name, Action configureInfrastructure) - : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IResourceWithAzureFunctionsConfig, IResourceWithEndpoints + : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IResourceWithAzureFunctionsConfig, IResourceWithEndpoints, IAzurePrivateEndpointTarget { internal List Queues { get; } = []; internal List Topics { get; } = []; @@ -33,6 +35,11 @@ public class AzureServiceBusResource(string name, Action public BicepOutputReference NameOutputReference => new("name", this); + /// + /// Gets the "id" output reference for the resource. + /// + public BicepOutputReference Id => new("id", this); + internal EndpointReference EmulatorEndpoint => new(this, "emulator"); /// @@ -185,4 +192,10 @@ IEnumerable> IResourceWithConnectionSt yield return new("ConnectionString", ConnectionStringExpression); } } + + BicepOutputReference IAzurePrivateEndpointTarget.Id => Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["namespace"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.servicebus.windows.net"; } diff --git a/src/Aspire.Hosting.Azure.SignalR/AzureSignalRExtensions.cs b/src/Aspire.Hosting.Azure.SignalR/AzureSignalRExtensions.cs index 30b3118f549..ebcc31ef56c 100644 --- a/src/Aspire.Hosting.Azure.SignalR/AzureSignalRExtensions.cs +++ b/src/Aspire.Hosting.Azure.SignalR/AzureSignalRExtensions.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 Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Aspire.Hosting.Azure.SignalR; @@ -59,6 +61,11 @@ public static IResourceBuilder AddAzureSignalR(this IDistr var configureInfrastructure = (AzureResourceInfrastructure infrastructure) => { + var azureResource = (AzureSignalRResource)infrastructure.AspireResource; + + // Check if this SignalR service has a private endpoint (via annotation) + var hasPrivateEndpoint = azureResource.HasAnnotationOfType(); + var service = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, (identifier, name) => { var resource = SignalRService.FromExisting(identifier); @@ -66,31 +73,45 @@ public static IResourceBuilder AddAzureSignalR(this IDistr return resource; }, - (infrastructure) => new SignalRService(infrastructure.AspireResource.GetBicepIdentifier()) + (infrastructure) => { - Kind = SignalRServiceKind.SignalR, - Sku = new SignalRResourceSku() + var svc = new SignalRService(infrastructure.AspireResource.GetBicepIdentifier()) { - Name = "Free_F1", - Capacity = 1 - }, - Features = - [ - new SignalRFeature() + Kind = SignalRServiceKind.SignalR, + Sku = new SignalRResourceSku() { - Flag = SignalRFeatureFlag.ServiceMode, - Value = serviceMode.ToString() - } - ], - CorsAllowedOrigins = ["*"], - Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } }, - DisableLocalAuth = true, + Name = "Free_F1", + Capacity = 1 + }, + Features = + [ + new SignalRFeature() + { + Flag = SignalRFeatureFlag.ServiceMode, + Value = serviceMode.ToString() + } + ], + CorsAllowedOrigins = ["*"], + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } }, + DisableLocalAuth = true, + }; + + // When using private endpoints, disable public network access. + if (hasPrivateEndpoint) + { + svc.PublicNetworkAccess = "Disabled"; + } + + return svc; }); infrastructure.Add(new ProvisioningOutput("hostName", typeof(string)) { Value = service.HostName.ToBicepExpression() }); // We need to output name to externalize role assignments. infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = service.Name.ToBicepExpression() }); + + // Output the resource id for private endpoint support. + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = service.Id.ToBicepExpression() }); }; List defaultRoles = [SignalRBuiltInRole.SignalRAppServer]; diff --git a/src/Aspire.Hosting.Azure.SignalR/AzureSignalRResource.cs b/src/Aspire.Hosting.Azure.SignalR/AzureSignalRResource.cs index 1f4051a158d..a38431efde0 100644 --- a/src/Aspire.Hosting.Azure.SignalR/AzureSignalRResource.cs +++ b/src/Aspire.Hosting.Azure.SignalR/AzureSignalRResource.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 Aspire.Hosting.Azure; using Azure.Provisioning.Primitives; using Azure.Provisioning.SignalR; @@ -13,7 +15,7 @@ namespace Aspire.Hosting.ApplicationModel; /// The name of the resource. /// Callback to configure the Azure resources. public class AzureSignalRResource(string name, Action configureInfrastructure) - : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IResourceWithEndpoints + : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IResourceWithEndpoints, IAzurePrivateEndpointTarget { internal EndpointReference EmulatorEndpoint => new(this, "emulator"); @@ -32,6 +34,11 @@ public class AzureSignalRResource(string name, Action public BicepOutputReference NameOutputReference => new("name", this); + /// + /// Gets the "id" output reference for the resource. + /// + public BicepOutputReference Id => new("id", this); + /// /// Gets the endpoint URI expression for the SignalR service. /// @@ -86,4 +93,10 @@ IEnumerable> IResourceWithConnectionSt { yield return new("Uri", UriExpression); } + + BicepOutputReference IAzurePrivateEndpointTarget.Id => Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["signalr"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.service.signalr.net"; } diff --git a/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.cs b/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.cs index 6015a2478bb..02860e5ea08 100644 --- a/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.cs +++ b/src/Aspire.Hosting.Azure.Sql/AzureSqlExtensions.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 Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Azure.Provisioning; @@ -271,6 +273,9 @@ private static SqlServer CreateSqlServerResourceOnly(AzureResourceInfrastructure { var azureResource = (AzureSqlServerResource)infrastructure.AspireResource; + // Check if this SQL Server has a private endpoint (via annotation) + var hasPrivateEndpoint = azureResource.HasAnnotationOfType(); + var sqlServer = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, (identifier, name) => @@ -302,29 +307,34 @@ private static SqlServer CreateSqlServerResourceOnly(AzureResourceInfrastructure TenantId = BicepFunction.GetSubscription().TenantId }, Version = "12.0", - PublicNetworkAccess = ServerNetworkAccessFlag.Enabled, + // When using private endpoints, disable public network access. + PublicNetworkAccess = hasPrivateEndpoint ? ServerNetworkAccessFlag.Disabled : ServerNetworkAccessFlag.Enabled, MinTlsVersion = SqlMinimalTlsVersion.Tls1_2, Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } }; }); - infrastructure.Add(new SqlFirewallRule("sqlFirewallRule_AllowAllAzureIps") - { - Parent = sqlServer, - Name = "AllowAllAzureIps", - StartIPAddress = "0.0.0.0", - EndIPAddress = "0.0.0.0" - }); - - if (distributedApplicationBuilder.ExecutionContext.IsRunMode) + // Only add firewall rules when not using private endpoints + if (!hasPrivateEndpoint) { - infrastructure.Add(new SqlFirewallRule("sqlFirewallRule_AllowAllIps") + infrastructure.Add(new SqlFirewallRule("sqlFirewallRule_AllowAllAzureIps") { Parent = sqlServer, - Name = "AllowAllIps", + Name = "AllowAllAzureIps", StartIPAddress = "0.0.0.0", - EndIPAddress = "255.255.255.255" + EndIPAddress = "0.0.0.0" }); + + if (distributedApplicationBuilder.ExecutionContext.IsRunMode) + { + infrastructure.Add(new SqlFirewallRule("sqlFirewallRule_AllowAllIps") + { + Parent = sqlServer, + Name = "AllowAllIps", + StartIPAddress = "0.0.0.0", + EndIPAddress = "255.255.255.255" + }); + } } infrastructure.Add(new ProvisioningOutput("sqlServerFqdn", typeof(string)) { Value = sqlServer.FullyQualifiedDomainName.ToBicepExpression() }); @@ -332,6 +342,9 @@ private static SqlServer CreateSqlServerResourceOnly(AzureResourceInfrastructure // We need to output name to externalize role assignments. infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = sqlServer.Name.ToBicepExpression() }); + // Output the resource id for private endpoint support. + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = sqlServer.Id.ToBicepExpression() }); + infrastructure.Add(new ProvisioningOutput("sqlServerAdminName", typeof(string)) { Value = sqlServer.Administrators.Login.ToBicepExpression() }); return sqlServer; diff --git a/src/Aspire.Hosting.Azure.Sql/AzureSqlServerResource.cs b/src/Aspire.Hosting.Azure.Sql/AzureSqlServerResource.cs index 94d578ed391..baf22e07682 100644 --- a/src/Aspire.Hosting.Azure.Sql/AzureSqlServerResource.cs +++ b/src/Aspire.Hosting.Azure.Sql/AzureSqlServerResource.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.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Azure.Provisioning; @@ -15,7 +17,7 @@ namespace Aspire.Hosting.Azure; /// /// Represents an Azure Sql Server resource. /// -public class AzureSqlServerResource : AzureProvisioningResource, IResourceWithConnectionString +public class AzureSqlServerResource : AzureProvisioningResource, IResourceWithConnectionString, IAzurePrivateEndpointTarget { private readonly Dictionary _databases = new Dictionary(StringComparers.ResourceName); private readonly bool _createdWithInnerResource; @@ -51,6 +53,11 @@ public AzureSqlServerResource(SqlServerServerResource innerResource, Action public BicepOutputReference NameOutputReference => new("name", this); + /// + /// Gets the "id" output reference for the resource. + /// + public BicepOutputReference Id => new("id", this); + private BicepOutputReference AdminName => new("sqlServerAdminName", this); /// @@ -324,4 +331,10 @@ IEnumerable> IResourceWithConnectionSt return result; } + + BicepOutputReference IAzurePrivateEndpointTarget.Id => Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["sqlServer"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.database.windows.net"; } diff --git a/src/Aspire.Hosting.Azure.Storage/AzureDataLakeStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureDataLakeStorageResource.cs index 43608d76979..e632379e651 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureDataLakeStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureDataLakeStorageResource.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 Aspire.Hosting.ApplicationModel; namespace Aspire.Hosting.Azure; @@ -11,7 +13,8 @@ namespace Aspire.Hosting.Azure; public class AzureDataLakeStorageResource(string name, AzureStorageResource storage) : Resource(name), IResourceWithConnectionString, IResourceWithParent, - IResourceWithAzureFunctionsConfig + IResourceWithAzureFunctionsConfig, + IAzurePrivateEndpointTarget { /// /// Gets the parent AzureStorageResource of this AzureDataLakeResource. @@ -76,4 +79,10 @@ IEnumerable> IResourceWithConnectionSt yield return new("Uri", UriExpression); } + + BicepOutputReference IAzurePrivateEndpointTarget.Id => Parent.Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["dfs"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.dfs.core.windows.net"; } diff --git a/src/Aspire.Hosting.Azure.Storage/AzureTableStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureTableStorageResource.cs index 568764bf8f9..b31aa83a7e1 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureTableStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureTableStorageResource.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 Aspire.Hosting.ApplicationModel; using Azure.Provisioning; @@ -12,7 +14,11 @@ namespace Aspire.Hosting.Azure; /// The name of the resource. /// The that the resource is stored in. public class AzureTableStorageResource(string name, AzureStorageResource storage) - : Resource(name), IResourceWithConnectionString, IResourceWithParent, IResourceWithAzureFunctionsConfig + : Resource(name), + IResourceWithConnectionString, + IResourceWithParent, + IResourceWithAzureFunctionsConfig, + IAzurePrivateEndpointTarget { /// /// Gets the parent AzureStorageResource of this AzureTableStorageResource. @@ -69,4 +75,10 @@ IEnumerable> IResourceWithConnectionSt yield return new("ConnectionString", ConnectionStringExpression); } } + + BicepOutputReference IAzurePrivateEndpointTarget.Id => Parent.Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["table"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.table.core.windows.net"; } diff --git a/src/Aspire.Hosting.Azure.WebPubSub/AzureWebPubSubExtensions.cs b/src/Aspire.Hosting.Azure.WebPubSub/AzureWebPubSubExtensions.cs index 07d3150960e..0761d3d3952 100644 --- a/src/Aspire.Hosting.Azure.WebPubSub/AzureWebPubSubExtensions.cs +++ b/src/Aspire.Hosting.Azure.WebPubSub/AzureWebPubSubExtensions.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 Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Azure.Provisioning; @@ -38,6 +40,11 @@ public static IResourceBuilder AddAzureWebPubSub(this ID var configureInfrastructure = (AzureResourceInfrastructure infrastructure) => { + var azureResource = (AzureWebPubSubResource)infrastructure.AspireResource; + + // Check if this Web PubSub service has a private endpoint (via annotation) + var hasPrivateEndpoint = azureResource.HasAnnotationOfType(); + var service = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure, (identifier, name) => { @@ -61,7 +68,7 @@ public static IResourceBuilder AddAzureWebPubSub(this ID }; infrastructure.Add(capacityParameter); - var service = new WebPubSubService(infrastructure.AspireResource.GetBicepIdentifier()) + var svc = new WebPubSubService(infrastructure.AspireResource.GetBicepIdentifier()) { IsLocalAuthDisabled = true, Sku = new BillingInfoSku() @@ -71,7 +78,14 @@ public static IResourceBuilder AddAzureWebPubSub(this ID }, Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } }; - return service; + + // When using private endpoints, disable public network access. + if (hasPrivateEndpoint) + { + svc.PublicNetworkAccess = "Disabled"; + } + + return svc; } ); @@ -80,6 +94,9 @@ public static IResourceBuilder AddAzureWebPubSub(this ID // We need to output name to externalize role assignments. infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = service.Name.ToBicepExpression() }); + // Output the resource id for private endpoint support. + infrastructure.Add(new ProvisioningOutput("id", typeof(string)) { Value = service.Id.ToBicepExpression() }); + var resource = (AzureWebPubSubResource)infrastructure.AspireResource; foreach (var setting in resource.Hubs) { diff --git a/src/Aspire.Hosting.Azure.WebPubSub/AzureWebPubSubResource.cs b/src/Aspire.Hosting.Azure.WebPubSub/AzureWebPubSubResource.cs index afa75ac95d0..6869a8b0cc6 100644 --- a/src/Aspire.Hosting.Azure.WebPubSub/AzureWebPubSubResource.cs +++ b/src/Aspire.Hosting.Azure.WebPubSub/AzureWebPubSubResource.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 Aspire.Hosting.Azure; using Azure.Provisioning.Primitives; using Azure.Provisioning.WebPubSub; @@ -13,7 +15,7 @@ namespace Aspire.Hosting.ApplicationModel; /// The name of the resource. /// Callback to configure the Azure resources. public class AzureWebPubSubResource(string name, Action configureInfrastructure) - : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString + : AzureProvisioningResource(name, configureInfrastructure), IResourceWithConnectionString, IAzurePrivateEndpointTarget { internal Dictionary Hubs { get; } = new(StringComparer.OrdinalIgnoreCase); @@ -27,6 +29,11 @@ public class AzureWebPubSubResource(string name, Action public BicepOutputReference NameOutputReference => new("name", this); + /// + /// Gets the "id" output reference for the resource. + /// + public BicepOutputReference Id => new("id", this); + /// /// Gets the connection string template for the manifest for Azure Web PubSub. /// @@ -73,4 +80,10 @@ IEnumerable> IResourceWithConnectionSt { yield return new("Uri", UriExpression); } + + BicepOutputReference IAzurePrivateEndpointTarget.Id => Id; + + IEnumerable IAzurePrivateEndpointTarget.GetPrivateLinkGroupIds() => ["webpubsub"]; + + string IAzurePrivateEndpointTarget.GetPrivateDnsZoneName() => "privatelink.webpubsub.azure.com"; } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureAppConfigurationExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureAppConfigurationExtensionsTests.cs index a4d84ae222d..ee4ed55ad9a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureAppConfigurationExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureAppConfigurationExtensionsTests.cs @@ -57,6 +57,8 @@ public async Task AddAzureAppConfiguration() output appConfigEndpoint string = appConfig.properties.endpoint output name string = appConfig.name + + output id string = appConfig.id """; output.WriteLine(manifest.BicepText); Assert.Equal(expectedBicep, manifest.BicepText); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointLockdownTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointLockdownTests.cs new file mode 100644 index 00000000000..2dc611cc090 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzurePrivateEndpointLockdownTests.cs @@ -0,0 +1,185 @@ +// 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.Hosting.Azure.Tests; + +public class AzurePrivateEndpointLockdownTests +{ + [Fact] + public async Task AddAzureCosmosDB_WithPrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var cosmos = builder.AddAzureCosmosDB("cosmos"); + + subnet.AddPrivateEndpoint(cosmos); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(cosmos.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddAzureSqlServer_WithPrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var sql = builder.AddAzureSqlServer("sql"); + + subnet.AddPrivateEndpoint(sql); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(sql.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddAzurePostgresFlexibleServer_WithPrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var postgres = builder.AddAzurePostgresFlexibleServer("postgres"); + + subnet.AddPrivateEndpoint(postgres); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(postgres.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddAzureManagedRedis_WithPrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var redis = builder.AddAzureManagedRedis("redis"); + + subnet.AddPrivateEndpoint(redis); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(redis.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddAzureServiceBus_WithPrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var serviceBus = builder.AddAzureServiceBus("servicebus"); + + subnet.AddPrivateEndpoint(serviceBus); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(serviceBus.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddAzureEventHubs_WithPrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var eventHubs = builder.AddAzureEventHubs("eventhubs"); + + subnet.AddPrivateEndpoint(eventHubs); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(eventHubs.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddAzureKeyVault_WithPrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var keyVault = builder.AddAzureKeyVault("keyvault"); + + subnet.AddPrivateEndpoint(keyVault); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(keyVault.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddAzureAppConfiguration_WithPrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var appConfig = builder.AddAzureAppConfiguration("appconfig"); + + subnet.AddPrivateEndpoint(appConfig); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(appConfig.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddAzureSearch_WithPrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var search = builder.AddAzureSearch("search"); + + subnet.AddPrivateEndpoint(search); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(search.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddAzureSignalR_WithPrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var signalR = builder.AddAzureSignalR("signalr"); + + subnet.AddPrivateEndpoint(signalR); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(signalR.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddAzureWebPubSub_WithPrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var webPubSub = builder.AddAzureWebPubSub("webpubsub"); + + subnet.AddPrivateEndpoint(webPubSub); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(webPubSub.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs index e5b8bfcba07..65bc24e123c 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStoragePrivateEndpointLockdownTests.cs @@ -51,4 +51,43 @@ public async Task AddAzureStorage_WithPrivateEndpoint_GeneratesCorrectBicep() await Verify(manifest.BicepText, extension: "bicep"); } + + [Fact] + public async Task AddAzureStorage_WithTablePrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage"); + var tables = storage.AddTables("tables"); + + subnet.AddPrivateEndpoint(tables); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(storage.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddAzureStorage_WithDataLakePrivateEndpoint_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("pesubnet", "10.0.1.0/24"); + var storage = builder.AddAzureStorage("storage").ConfigureInfrastructure(infra => + { + // Need to enable HNS for DataLake + var storageAccount = infra.GetProvisionableResources().OfType().Single(); + storageAccount.IsHnsEnabled = true; + }); + var dataLake = storage.AddDataLake("datalake"); + + subnet.AddPrivateEndpoint(dataLake); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(storage.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWorksWithSqlServer.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWorksWithSqlServer.verified.bicep index 0fa2c7ad436..9dd82de2d2b 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWorksWithSqlServer.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWorksWithSqlServer.verified.bicep @@ -45,4 +45,6 @@ output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName output name string = sql.name +output id string = sql.id + output sqlServerAdminName string = sql.properties.administrators.login \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_NoAccessKeyAuthentication.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_NoAccessKeyAuthentication.verified.bicep index cc352c76a17..a2a49b3a54b 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_NoAccessKeyAuthentication.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_NoAccessKeyAuthentication.verified.bicep @@ -58,4 +58,6 @@ resource mycontainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/contain output connectionString string = cosmos.properties.documentEndpoint -output name string = cosmos.name \ No newline at end of file +output name string = cosmos.name + +output id string = cosmos.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_WithAccessKeyAuthentication_kvName=mykeyvault.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_WithAccessKeyAuthentication_kvName=mykeyvault.verified.bicep index 2428383be1e..af53e0f8af2 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_WithAccessKeyAuthentication_kvName=mykeyvault.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_WithAccessKeyAuthentication_kvName=mykeyvault.verified.bicep @@ -94,4 +94,6 @@ resource mycontainer_connectionString 'Microsoft.KeyVault/vaults/secrets@2024-11 parent: keyVault } -output name string = cosmos.name \ No newline at end of file +output name string = cosmos.name + +output id string = cosmos.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_WithAccessKeyAuthentication_kvName=null.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_WithAccessKeyAuthentication_kvName=null.verified.bicep index cb1e26fc2ba..b2306c81154 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_WithAccessKeyAuthentication_kvName=null.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_WithAccessKeyAuthentication_kvName=null.verified.bicep @@ -94,4 +94,6 @@ resource mycontainer_connectionString 'Microsoft.KeyVault/vaults/secrets@2024-11 parent: keyVault } -output name string = cosmos.name \ No newline at end of file +output name string = cosmos.name + +output id string = cosmos.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_WithDefaultAzureSku.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_WithDefaultAzureSku.verified.bicep index 754aa4d3bd2..148ac47e17b 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_WithDefaultAzureSku.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaPublishMode_WithDefaultAzureSku.verified.bicep @@ -26,4 +26,6 @@ resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { output connectionString string = cosmos.properties.documentEndpoint -output name string = cosmos.name \ No newline at end of file +output name string = cosmos.name + +output id string = cosmos.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaRunMode_NoAccessKeyAuthentication.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaRunMode_NoAccessKeyAuthentication.verified.bicep index cc352c76a17..a2a49b3a54b 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaRunMode_NoAccessKeyAuthentication.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaRunMode_NoAccessKeyAuthentication.verified.bicep @@ -58,4 +58,6 @@ resource mycontainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/contain output connectionString string = cosmos.properties.documentEndpoint -output name string = cosmos.name \ No newline at end of file +output name string = cosmos.name + +output id string = cosmos.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaRunMode_WithAccessKeyAuthentication_kvName=mykeyvault.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaRunMode_WithAccessKeyAuthentication_kvName=mykeyvault.verified.bicep index e973f9850c9..036ef583769 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaRunMode_WithAccessKeyAuthentication_kvName=mykeyvault.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaRunMode_WithAccessKeyAuthentication_kvName=mykeyvault.verified.bicep @@ -94,4 +94,6 @@ resource container_connectionString 'Microsoft.KeyVault/vaults/secrets@2024-11-0 parent: keyVault } -output name string = cosmos.name \ No newline at end of file +output name string = cosmos.name + +output id string = cosmos.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaRunMode_WithAccessKeyAuthentication_kvName=null.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaRunMode_WithAccessKeyAuthentication_kvName=null.verified.bicep index c31338c3410..ab0097a0549 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaRunMode_WithAccessKeyAuthentication_kvName=null.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDBViaRunMode_WithAccessKeyAuthentication_kvName=null.verified.bicep @@ -94,4 +94,6 @@ resource container_connectionString 'Microsoft.KeyVault/vaults/secrets@2024-11-0 parent: keyVault } -output name string = cosmos.name \ No newline at end of file +output name string = cosmos.name + +output id string = cosmos.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDB_useAcaInfrastructure=False.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDB_useAcaInfrastructure=False.verified.bicep index dfd64f03eb9..ddbe6722954 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDB_useAcaInfrastructure=False.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDB_useAcaInfrastructure=False.verified.bicep @@ -30,4 +30,6 @@ resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { output connectionString string = cosmos.properties.documentEndpoint -output name string = cosmos.name \ No newline at end of file +output name string = cosmos.name + +output id string = cosmos.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDB_useAcaInfrastructure=True.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDB_useAcaInfrastructure=True.verified.bicep index dfd64f03eb9..ddbe6722954 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDB_useAcaInfrastructure=True.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDB_useAcaInfrastructure=True.verified.bicep @@ -30,4 +30,6 @@ resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { output connectionString string = cosmos.properties.documentEndpoint -output name string = cosmos.name \ No newline at end of file +output name string = cosmos.name + +output id string = cosmos.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDatabase_WorksWithAccessKeyAuth_ChildResources.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDatabase_WorksWithAccessKeyAuth_ChildResources.verified.bicep index aafb6a09e69..f34303d2ca2 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDatabase_WorksWithAccessKeyAuth_ChildResources.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureCosmosDBExtensionsTests.AddAzureCosmosDatabase_WorksWithAccessKeyAuth_ChildResources.verified.bicep @@ -94,4 +94,6 @@ resource container1_connectionString 'Microsoft.KeyVault/vaults/secrets@2024-11- parent: keyVault } -output name string = cosmos.name \ No newline at end of file +output name string = cosmos.name + +output id string = cosmos.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEventHubsExtensionsTests.CanSetHubAndConsumerGroupName#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEventHubsExtensionsTests.CanSetHubAndConsumerGroupName#00.verified.bicep index 6a3316b493b..4aa1564bf2a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEventHubsExtensionsTests.CanSetHubAndConsumerGroupName#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEventHubsExtensionsTests.CanSetHubAndConsumerGroupName#00.verified.bicep @@ -8,6 +8,7 @@ resource eh 'Microsoft.EventHub/namespaces@2024-01-01' = { location: location properties: { disableLocalAuth: true + publicNetworkAccess: 'Enabled' } sku: { name: sku @@ -35,3 +36,5 @@ output eventHubsEndpoint string = eh.properties.serviceBusEndpoint output eventHubsHostName string = split(replace(eh.properties.serviceBusEndpoint, 'https://', ''), ':')[0] output name string = eh.name + +output id string = eh.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddKeyVaultViaPublishMode#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddKeyVaultViaPublishMode#00.verified.bicep index 4c5b6449df8..2574f26b95d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddKeyVaultViaPublishMode#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddKeyVaultViaPublishMode#00.verified.bicep @@ -19,4 +19,6 @@ resource mykv 'Microsoft.KeyVault/vaults@2024-11-01' = { output vaultUri string = mykv.properties.vaultUri -output name string = mykv.name \ No newline at end of file +output name string = mykv.name + +output id string = mykv.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddKeyVaultViaRunMode.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddKeyVaultViaRunMode.verified.bicep index 4c5b6449df8..2574f26b95d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddKeyVaultViaRunMode.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddKeyVaultViaRunMode.verified.bicep @@ -19,4 +19,6 @@ resource mykv 'Microsoft.KeyVault/vaults@2024-11-01' = { output vaultUri string = mykv.properties.vaultUri -output name string = mykv.name \ No newline at end of file +output name string = mykv.name + +output id string = mykv.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddSecret_WithMultipleSecrets_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddSecret_WithMultipleSecrets_GeneratesCorrectBicep.verified.bicep index e6f68bbf613..4dfc77de442 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddSecret_WithMultipleSecrets_GeneratesCorrectBicep.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddSecret_WithMultipleSecrets_GeneratesCorrectBicep.verified.bicep @@ -52,4 +52,6 @@ resource secret_connection_string 'Microsoft.KeyVault/vaults/secrets@2024-11-01' output vaultUri string = mykv.properties.vaultUri -output name string = mykv.name \ No newline at end of file +output name string = mykv.name + +output id string = mykv.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddSecret_WithParameterResource_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddSecret_WithParameterResource_GeneratesCorrectBicep.verified.bicep index a581e7e529b..c092396a5cb 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddSecret_WithParameterResource_GeneratesCorrectBicep.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddSecret_WithParameterResource_GeneratesCorrectBicep.verified.bicep @@ -30,4 +30,6 @@ resource secret_my_secret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { output vaultUri string = mykv.properties.vaultUri -output name string = mykv.name \ No newline at end of file +output name string = mykv.name + +output id string = mykv.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddSecret_WithReferenceExpression_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddSecret_WithReferenceExpression_GeneratesCorrectBicep.verified.bicep index 4d9e0409be1..dd54523bb9d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddSecret_WithReferenceExpression_GeneratesCorrectBicep.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureKeyVaultTests.AddSecret_WithReferenceExpression_GeneratesCorrectBicep.verified.bicep @@ -30,4 +30,6 @@ resource secret_connection_string 'Microsoft.KeyVault/vaults/secrets@2024-11-01' output vaultUri string = mykv.properties.vaultUri -output name string = mykv.name \ No newline at end of file +output name string = mykv.name + +output id string = mykv.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedisWithAccessKeyAuthentication_kvName=mykeyvault.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedisWithAccessKeyAuthentication_kvName=mykeyvault.verified.bicep index 11fbde83504..0a5acc18646 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedisWithAccessKeyAuthentication_kvName=mykeyvault.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedisWithAccessKeyAuthentication_kvName=mykeyvault.verified.bicep @@ -46,4 +46,6 @@ resource primaryAccessKey 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { output name string = redis_cache.name +output id string = redis_cache.id + output hostName string = redis_cache.properties.hostName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedisWithAccessKeyAuthentication_kvName=null.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedisWithAccessKeyAuthentication_kvName=null.verified.bicep index f02b16b90cb..ae2d0fdc3bf 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedisWithAccessKeyAuthentication_kvName=null.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedisWithAccessKeyAuthentication_kvName=null.verified.bicep @@ -46,4 +46,6 @@ resource primaryAccessKey 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { output name string = redis_cache.name +output id string = redis_cache.id + output hostName string = redis_cache.properties.hostName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedis_useAcaInfrastructure=False#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedis_useAcaInfrastructure=False#00.verified.bicep index 939fdb3c2f2..71ed3138d28 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedis_useAcaInfrastructure=False#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedis_useAcaInfrastructure=False#00.verified.bicep @@ -26,4 +26,6 @@ output connectionString string = '${redis_cache.properties.hostName}:10000,ssl=t output name string = redis_cache.name +output id string = redis_cache.id + output hostName string = redis_cache.properties.hostName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedis_useAcaInfrastructure=True#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedis_useAcaInfrastructure=True#00.verified.bicep index 939fdb3c2f2..71ed3138d28 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedis_useAcaInfrastructure=True#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureManagedRedisExtensionsTests.AddAzureManagedRedis_useAcaInfrastructure=True#00.verified.bicep @@ -26,4 +26,6 @@ output connectionString string = '${redis_cache.properties.hostName}:10000,ssl=t output name string = redis_cache.name +output id string = redis_cache.id + output hostName string = redis_cache.properties.hostName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresFlexibleServer_publishMode=False_useAcaInfrastructure=False#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresFlexibleServer_publishMode=False_useAcaInfrastructure=False#00.verified.bicep index 74088ce383f..79f8b981ce9 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresFlexibleServer_publishMode=False_useAcaInfrastructure=False#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresFlexibleServer_publishMode=False_useAcaInfrastructure=False#00.verified.bicep @@ -53,4 +53,6 @@ output connectionString string = 'Host=${postgres_data.properties.fullyQualified output name string = postgres_data.name +output id string = postgres_data.id + output hostName string = postgres_data.properties.fullyQualifiedDomainName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresFlexibleServer_publishMode=False_useAcaInfrastructure=True#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresFlexibleServer_publishMode=False_useAcaInfrastructure=True#00.verified.bicep index 74088ce383f..79f8b981ce9 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresFlexibleServer_publishMode=False_useAcaInfrastructure=True#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresFlexibleServer_publishMode=False_useAcaInfrastructure=True#00.verified.bicep @@ -53,4 +53,6 @@ output connectionString string = 'Host=${postgres_data.properties.fullyQualified output name string = postgres_data.name +output id string = postgres_data.id + output hostName string = postgres_data.properties.fullyQualifiedDomainName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresFlexibleServer_publishMode=True_useAcaInfrastructure=False#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresFlexibleServer_publishMode=True_useAcaInfrastructure=False#00.verified.bicep index e6febd9521f..0c4a5833ca7 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresFlexibleServer_publishMode=True_useAcaInfrastructure=False#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresFlexibleServer_publishMode=True_useAcaInfrastructure=False#00.verified.bicep @@ -44,4 +44,6 @@ output connectionString string = 'Host=${postgres_data.properties.fullyQualified output name string = postgres_data.name +output id string = postgres_data.id + output hostName string = postgres_data.properties.fullyQualifiedDomainName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=False_kvName=mykeyvault.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=False_kvName=mykeyvault.verified.bicep index 436c615bb0a..a062f09759a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=False_kvName=mykeyvault.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=False_kvName=mykeyvault.verified.bicep @@ -85,4 +85,6 @@ resource db1_connectionString 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { output name string = postgres_data.name +output id string = postgres_data.id + output hostName string = postgres_data.properties.fullyQualifiedDomainName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=False_kvName=null.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=False_kvName=null.verified.bicep index 09303c6281e..d928f1ca208 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=False_kvName=null.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=False_kvName=null.verified.bicep @@ -85,4 +85,6 @@ resource db1_connectionString 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { output name string = postgres_data.name +output id string = postgres_data.id + output hostName string = postgres_data.properties.fullyQualifiedDomainName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=True_kvName=mykeyvault.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=True_kvName=mykeyvault.verified.bicep index 436c615bb0a..a062f09759a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=True_kvName=mykeyvault.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=True_kvName=mykeyvault.verified.bicep @@ -85,4 +85,6 @@ resource db1_connectionString 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { output name string = postgres_data.name +output id string = postgres_data.id + output hostName string = postgres_data.properties.fullyQualifiedDomainName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=True_kvName=null.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=True_kvName=null.verified.bicep index 09303c6281e..d928f1ca208 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=True_kvName=null.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=False_specifyPassword=True_kvName=null.verified.bicep @@ -85,4 +85,6 @@ resource db1_connectionString 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { output name string = postgres_data.name +output id string = postgres_data.id + output hostName string = postgres_data.properties.fullyQualifiedDomainName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=False_kvName=mykeyvault.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=False_kvName=mykeyvault.verified.bicep index 436c615bb0a..a062f09759a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=False_kvName=mykeyvault.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=False_kvName=mykeyvault.verified.bicep @@ -85,4 +85,6 @@ resource db1_connectionString 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { output name string = postgres_data.name +output id string = postgres_data.id + output hostName string = postgres_data.properties.fullyQualifiedDomainName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=False_kvName=null.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=False_kvName=null.verified.bicep index 09303c6281e..d928f1ca208 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=False_kvName=null.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=False_kvName=null.verified.bicep @@ -85,4 +85,6 @@ resource db1_connectionString 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { output name string = postgres_data.name +output id string = postgres_data.id + output hostName string = postgres_data.properties.fullyQualifiedDomainName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=True_kvName=mykeyvault.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=True_kvName=mykeyvault.verified.bicep index 436c615bb0a..a062f09759a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=True_kvName=mykeyvault.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=True_kvName=mykeyvault.verified.bicep @@ -85,4 +85,6 @@ resource db1_connectionString 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { output name string = postgres_data.name +output id string = postgres_data.id + output hostName string = postgres_data.properties.fullyQualifiedDomainName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=True_kvName=null.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=True_kvName=null.verified.bicep index 09303c6281e..d928f1ca208 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=True_kvName=null.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePostgresExtensionsTests.AddAzurePostgresWithPasswordAuth_specifyUserName=True_specifyPassword=True_kvName=null.verified.bicep @@ -85,4 +85,6 @@ resource db1_connectionString 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { output name string = postgres_data.name +output id string = postgres_data.id + output hostName string = postgres_data.properties.fullyQualifiedDomainName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureAppConfiguration_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureAppConfiguration_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..cbdce230099 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureAppConfiguration_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,23 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource appconfig 'Microsoft.AppConfiguration/configurationStores@2024-06-01' = { + name: take('appconfig-${uniqueString(resourceGroup().id)}', 50) + location: location + properties: { + disableLocalAuth: true + publicNetworkAccess: 'Disabled' + } + sku: { + name: 'standard' + } + tags: { + 'aspire-resource-name': 'appconfig' + } +} + +output appConfigEndpoint string = appconfig.properties.endpoint + +output name string = appconfig.name + +output id string = appconfig.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureCosmosDB_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureCosmosDB_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..64733bba6cd --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureCosmosDB_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,36 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource cosmos 'Microsoft.DocumentDB/databaseAccounts@2024-08-15' = { + name: take('cosmos-${uniqueString(resourceGroup().id)}', 44) + location: location + properties: { + locations: [ + { + locationName: location + failoverPriority: 0 + } + ] + capabilities: [ + { + name: 'EnableServerless' + } + ] + consistencyPolicy: { + defaultConsistencyLevel: 'Session' + } + databaseAccountOfferType: 'Standard' + disableLocalAuth: true + publicNetworkAccess: 'Disabled' + } + kind: 'GlobalDocumentDB' + tags: { + 'aspire-resource-name': 'cosmos' + } +} + +output connectionString string = cosmos.properties.documentEndpoint + +output name string = cosmos.name + +output id string = cosmos.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureEventHubs_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureEventHubs_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..9b9fdfd591c --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureEventHubs_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,27 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sku string = 'Standard' + +resource eventhubs 'Microsoft.EventHub/namespaces@2024-01-01' = { + name: take('eventhubs-${uniqueString(resourceGroup().id)}', 256) + location: location + properties: { + disableLocalAuth: true + publicNetworkAccess: 'Disabled' + } + sku: { + name: sku + } + tags: { + 'aspire-resource-name': 'eventhubs' + } +} + +output eventHubsEndpoint string = eventhubs.properties.serviceBusEndpoint + +output eventHubsHostName string = split(replace(eventhubs.properties.serviceBusEndpoint, 'https://', ''), ':')[0] + +output name string = eventhubs.name + +output id string = eventhubs.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureKeyVault_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureKeyVault_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..c9876426360 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureKeyVault_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,25 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource keyvault 'Microsoft.KeyVault/vaults@2024-11-01' = { + name: take('keyvault-${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + tenantId: tenant().tenantId + sku: { + family: 'A' + name: 'standard' + } + enableRbacAuthorization: true + publicNetworkAccess: 'Disabled' + } + tags: { + 'aspire-resource-name': 'keyvault' + } +} + +output vaultUri string = keyvault.properties.vaultUri + +output name string = keyvault.name + +output id string = keyvault.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureManagedRedis_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureManagedRedis_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..2e4c21f4ed1 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureManagedRedis_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,31 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource redis 'Microsoft.Cache/redisEnterprise@2025-07-01' = { + name: take('redis-${uniqueString(resourceGroup().id)}', 60) + location: location + sku: { + name: 'Balanced_B0' + } + properties: { + minimumTlsVersion: '1.2' + publicNetworkAccess: 'Disabled' + } +} + +resource redis_default 'Microsoft.Cache/redisEnterprise/databases@2025-07-01' = { + name: 'default' + properties: { + accessKeysAuthentication: 'Disabled' + port: 10000 + } + parent: redis +} + +output connectionString string = '${redis.properties.hostName}:10000,ssl=true' + +output name string = redis.name + +output id string = redis.id + +output hostName string = redis.properties.hostName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzurePostgresFlexibleServer_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzurePostgresFlexibleServer_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..4ddca74fcf3 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzurePostgresFlexibleServer_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,43 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource postgres 'Microsoft.DBforPostgreSQL/flexibleServers@2024-08-01' = { + name: take('postgres-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + authConfig: { + activeDirectoryAuth: 'Enabled' + passwordAuth: 'Disabled' + } + availabilityZone: '1' + backup: { + backupRetentionDays: 7 + geoRedundantBackup: 'Disabled' + } + highAvailability: { + mode: 'Disabled' + } + network: { + publicNetworkAccess: 'Disabled' + } + storage: { + storageSizeGB: 32 + } + version: '16' + } + sku: { + name: 'Standard_B1ms' + tier: 'Burstable' + } + tags: { + 'aspire-resource-name': 'postgres' + } +} + +output connectionString string = 'Host=${postgres.properties.fullyQualifiedDomainName}' + +output name string = postgres.name + +output id string = postgres.id + +output hostName string = postgres.properties.fullyQualifiedDomainName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureSearch_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureSearch_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..941ff9917b4 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureSearch_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,28 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource search 'Microsoft.Search/searchServices@2023-11-01' = { + name: take('search-${uniqueString(resourceGroup().id)}', 60) + location: location + properties: { + hostingMode: 'default' + disableLocalAuth: true + partitionCount: 1 + publicNetworkAccess: 'disabled' + replicaCount: 1 + } + sku: { + name: 'basic' + } + tags: { + 'aspire-resource-name': 'search' + } +} + +output connectionString string = 'Endpoint=https://${search.name}.search.windows.net' + +output endpoint string = 'https://${search.name}.search.windows.net' + +output name string = search.name + +output id string = search.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureServiceBus_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureServiceBus_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..83542945cf7 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureServiceBus_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,27 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sku string = 'Standard' + +resource servicebus 'Microsoft.ServiceBus/namespaces@2024-01-01' = { + name: take('servicebus-${uniqueString(resourceGroup().id)}', 50) + location: location + properties: { + disableLocalAuth: true + publicNetworkAccess: 'Disabled' + } + sku: { + name: sku + } + tags: { + 'aspire-resource-name': 'servicebus' + } +} + +output serviceBusEndpoint string = servicebus.properties.serviceBusEndpoint + +output serviceBusHostName string = split(replace(servicebus.properties.serviceBusEndpoint, 'https://', ''), ':')[0] + +output name string = servicebus.name + +output id string = servicebus.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureSignalR_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureSignalR_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..7b5cd7f9950 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureSignalR_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,36 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource signalr 'Microsoft.SignalRService/signalR@2024-03-01' = { + name: take('signalr-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + cors: { + allowedOrigins: [ + '*' + ] + } + disableLocalAuth: true + features: [ + { + flag: 'ServiceMode' + value: 'Default' + } + ] + publicNetworkAccess: 'Disabled' + } + kind: 'SignalR' + sku: { + name: 'Free_F1' + capacity: 1 + } + tags: { + 'aspire-resource-name': 'signalr' + } +} + +output hostName string = signalr.properties.hostName + +output name string = signalr.name + +output id string = signalr.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureSqlServer_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureSqlServer_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..741375c5385 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureSqlServer_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,35 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource sqlServerAdminManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('sql-admin-${uniqueString(resourceGroup().id)}', 63) + location: location +} + +resource sql 'Microsoft.Sql/servers@2023-08-01' = { + name: take('sql-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + administrators: { + administratorType: 'ActiveDirectory' + login: sqlServerAdminManagedIdentity.name + sid: sqlServerAdminManagedIdentity.properties.principalId + tenantId: subscription().tenantId + azureADOnlyAuthentication: true + } + minimalTlsVersion: '1.2' + publicNetworkAccess: 'Disabled' + version: '12.0' + } + tags: { + 'aspire-resource-name': 'sql' + } +} + +output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName + +output name string = sql.name + +output id string = sql.id + +output sqlServerAdminName string = sql.properties.administrators.login \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureWebPubSub_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureWebPubSub_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..3724c9b1baa --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureWebPubSub_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,28 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param sku string = 'Free_F1' + +param capacity int = 1 + +resource webpubsub 'Microsoft.SignalRService/webPubSub@2024-03-01' = { + name: take('webpubsub-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + disableLocalAuth: true + publicNetworkAccess: 'Disabled' + } + sku: { + name: sku + capacity: capacity + } + tags: { + 'aspire-resource-name': 'webpubsub' + } +} + +output endpoint string = 'https://${webpubsub.properties.hostName}' + +output name string = webpubsub.name + +output id string = webpubsub.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureResourceOptionsTests.AzureResourceOptionsCanBeConfigured#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureResourceOptionsTests.AzureResourceOptionsCanBeConfigured#00.verified.bicep index 0c17943411e..45a5d7c6d0d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureResourceOptionsTests.AzureResourceOptionsCanBeConfigured#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureResourceOptionsTests.AzureResourceOptionsCanBeConfigured#00.verified.bicep @@ -8,6 +8,7 @@ resource sb 'Microsoft.ServiceBus/namespaces@2024-01-01' = { location: location properties: { disableLocalAuth: true + publicNetworkAccess: 'Enabled' } sku: { name: sku @@ -22,3 +23,5 @@ output serviceBusEndpoint string = sb.properties.serviceBusEndpoint output serviceBusHostName string = split(replace(sb.properties.serviceBusEndpoint, 'https://', ''), ':')[0] output name string = sb.name + +output id string = sb.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureResourceOptionsTests.AzureResourceOptionsCanBeConfigured#01.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureResourceOptionsTests.AzureResourceOptionsCanBeConfigured#01.verified.bicep index 8df7d4966e3..66ab6ca8194 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureResourceOptionsTests.AzureResourceOptionsCanBeConfigured#01.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureResourceOptionsTests.AzureResourceOptionsCanBeConfigured#01.verified.bicep @@ -45,4 +45,6 @@ output sqlServerFqdn string = sql_server.properties.fullyQualifiedDomainName output name string = sql_server.name +output id string = sql_server.id + output sqlServerAdminName string = sql_server.properties.administrators.login \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSearchExtensionsTests.AddAzureSearch.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSearchExtensionsTests.AddAzureSearch.verified.bicep index 018ad7333bf..865f0cbeccb 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSearchExtensionsTests.AddAzureSearch.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSearchExtensionsTests.AddAzureSearch.verified.bicep @@ -24,4 +24,6 @@ output connectionString string = 'Endpoint=https://${search.name}.search.windows output endpoint string = 'https://${search.name}.search.windows.net' -output name string = search.name \ No newline at end of file +output name string = search.name + +output id string = search.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.AddAzureServiceBus_useObsoleteMethods=False.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.AddAzureServiceBus_useObsoleteMethods=False.verified.bicep index fbafbafca6f..2acba3843ba 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.AddAzureServiceBus_useObsoleteMethods=False.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.AddAzureServiceBus_useObsoleteMethods=False.verified.bicep @@ -8,6 +8,7 @@ resource sb 'Microsoft.ServiceBus/namespaces@2024-01-01' = { location: location properties: { disableLocalAuth: true + publicNetworkAccess: 'Enabled' } sku: { name: sku @@ -47,3 +48,5 @@ output serviceBusEndpoint string = sb.properties.serviceBusEndpoint output serviceBusHostName string = split(replace(sb.properties.serviceBusEndpoint, 'https://', ''), ':')[0] output name string = sb.name + +output id string = sb.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.AddAzureServiceBus_useObsoleteMethods=True.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.AddAzureServiceBus_useObsoleteMethods=True.verified.bicep index fbafbafca6f..2acba3843ba 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.AddAzureServiceBus_useObsoleteMethods=True.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.AddAzureServiceBus_useObsoleteMethods=True.verified.bicep @@ -8,6 +8,7 @@ resource sb 'Microsoft.ServiceBus/namespaces@2024-01-01' = { location: location properties: { disableLocalAuth: true + publicNetworkAccess: 'Enabled' } sku: { name: sku @@ -47,3 +48,5 @@ output serviceBusEndpoint string = sb.properties.serviceBusEndpoint output serviceBusHostName string = split(replace(sb.properties.serviceBusEndpoint, 'https://', ''), ':')[0] output name string = sb.name + +output id string = sb.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.ResourceNamesCanBeDifferentThanAzureNames.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.ResourceNamesCanBeDifferentThanAzureNames.verified.bicep index 48994c06128..81690a1c446 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.ResourceNamesCanBeDifferentThanAzureNames.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.ResourceNamesCanBeDifferentThanAzureNames.verified.bicep @@ -8,6 +8,7 @@ resource sb 'Microsoft.ServiceBus/namespaces@2024-01-01' = { location: location properties: { disableLocalAuth: true + publicNetworkAccess: 'Enabled' } sku: { name: sku @@ -51,3 +52,5 @@ output serviceBusEndpoint string = sb.properties.serviceBusEndpoint output serviceBusHostName string = split(replace(sb.properties.serviceBusEndpoint, 'https://', ''), ':')[0] output name string = sb.name + +output id string = sb.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.TopicNamesCanBeLongerThan24_useObsoleteMethods=False.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.TopicNamesCanBeLongerThan24_useObsoleteMethods=False.verified.bicep index 9b4d6debd4d..1f1355e5079 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.TopicNamesCanBeLongerThan24_useObsoleteMethods=False.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.TopicNamesCanBeLongerThan24_useObsoleteMethods=False.verified.bicep @@ -8,6 +8,7 @@ resource sb 'Microsoft.ServiceBus/namespaces@2024-01-01' = { location: location properties: { disableLocalAuth: true + publicNetworkAccess: 'Enabled' } sku: { name: sku @@ -27,3 +28,5 @@ output serviceBusEndpoint string = sb.properties.serviceBusEndpoint output serviceBusHostName string = split(replace(sb.properties.serviceBusEndpoint, 'https://', ''), ':')[0] output name string = sb.name + +output id string = sb.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.TopicNamesCanBeLongerThan24_useObsoleteMethods=True.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.TopicNamesCanBeLongerThan24_useObsoleteMethods=True.verified.bicep index 9b4d6debd4d..1f1355e5079 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.TopicNamesCanBeLongerThan24_useObsoleteMethods=True.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureServiceBusExtensionsTests.TopicNamesCanBeLongerThan24_useObsoleteMethods=True.verified.bicep @@ -8,6 +8,7 @@ resource sb 'Microsoft.ServiceBus/namespaces@2024-01-01' = { location: location properties: { disableLocalAuth: true + publicNetworkAccess: 'Enabled' } sku: { name: sku @@ -27,3 +28,5 @@ output serviceBusEndpoint string = sb.properties.serviceBusEndpoint output serviceBusHostName string = split(replace(sb.properties.serviceBusEndpoint, 'https://', ''), ':')[0] output name string = sb.name + +output id string = sb.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSignalRExtensionsTests.AddAzureSignalR#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSignalRExtensionsTests.AddAzureSignalR#00.verified.bicep index 79e66c65e22..16287b9afdd 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSignalRExtensionsTests.AddAzureSignalR#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSignalRExtensionsTests.AddAzureSignalR#00.verified.bicep @@ -1,4 +1,4 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location resource signalr 'Microsoft.SignalRService/signalR@2024-03-01' = { @@ -31,3 +31,5 @@ resource signalr 'Microsoft.SignalRService/signalR@2024-03-01' = { output hostName string = signalr.properties.hostName output name string = signalr.name + +output id string = signalr.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSignalRExtensionsTests.AddServerlessAzureSignalR#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSignalRExtensionsTests.AddServerlessAzureSignalR#00.verified.bicep index f6aed77e3a5..755a53a3cc2 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSignalRExtensionsTests.AddServerlessAzureSignalR#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSignalRExtensionsTests.AddServerlessAzureSignalR#00.verified.bicep @@ -1,4 +1,4 @@ -@description('The location for the resource(s) to be deployed.') +@description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location resource signalr 'Microsoft.SignalRService/signalR@2024-03-01' = { @@ -31,3 +31,5 @@ resource signalr 'Microsoft.SignalRService/signalR@2024-03-01' = { output hostName string = signalr.properties.hostName output name string = signalr.name + +output id string = signalr.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AddAzureSqlServer_publishMode=False_useAcaInfrastructure=False.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AddAzureSqlServer_publishMode=False_useAcaInfrastructure=False.verified.bicep index 841de050e2e..9327ead500a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AddAzureSqlServer_publishMode=False_useAcaInfrastructure=False.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AddAzureSqlServer_publishMode=False_useAcaInfrastructure=False.verified.bicep @@ -80,4 +80,6 @@ output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName output name string = sql.name +output id string = sql.id + output sqlServerAdminName string = sql.properties.administrators.login \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AddAzureSqlServer_publishMode=False_useAcaInfrastructure=True.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AddAzureSqlServer_publishMode=False_useAcaInfrastructure=True.verified.bicep index 841de050e2e..9327ead500a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AddAzureSqlServer_publishMode=False_useAcaInfrastructure=True.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AddAzureSqlServer_publishMode=False_useAcaInfrastructure=True.verified.bicep @@ -80,4 +80,6 @@ output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName output name string = sql.name +output id string = sql.id + output sqlServerAdminName string = sql.properties.administrators.login \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AddAzureSqlServer_publishMode=True_useAcaInfrastructure=False.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AddAzureSqlServer_publishMode=True_useAcaInfrastructure=False.verified.bicep index 925724b2e9c..23a6451a309 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AddAzureSqlServer_publishMode=True_useAcaInfrastructure=False.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AddAzureSqlServer_publishMode=True_useAcaInfrastructure=False.verified.bicep @@ -71,4 +71,6 @@ output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName output name string = sql.name +output id string = sql.id + output sqlServerAdminName string = sql.properties.administrators.login \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AsAzureSqlDatabaseViaPublishMode.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AsAzureSqlDatabaseViaPublishMode.verified.bicep index b48e7f9f901..4c6a552b4df 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AsAzureSqlDatabaseViaPublishMode.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AsAzureSqlDatabaseViaPublishMode.verified.bicep @@ -45,4 +45,6 @@ output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName output name string = sql.name +output id string = sql.id + output sqlServerAdminName string = sql.properties.administrators.login \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AsAzureSqlDatabaseViaRunMode.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AsAzureSqlDatabaseViaRunMode.verified.bicep index b8e89040352..a21e86f62ea 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AsAzureSqlDatabaseViaRunMode.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureSqlExtensionsTests.AsAzureSqlDatabaseViaRunMode.verified.bicep @@ -54,4 +54,6 @@ output sqlServerFqdn string = sql.properties.fullyQualifiedDomainName output name string = sql.name +output id string = sql.id + output sqlServerAdminName string = sql.properties.administrators.login \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithDataLakePrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithDataLakePrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..d208a7b5445 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithDataLakePrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,36 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('storage${uniqueString(resourceGroup().id)}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_GRS' + } + properties: { + accessTier: 'Hot' + allowSharedKeyAccess: false + isHnsEnabled: true + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Deny' + } + publicNetworkAccess: 'Disabled' + } + tags: { + 'aspire-resource-name': 'storage' + } +} + +output blobEndpoint string = storage.properties.primaryEndpoints.blob + +output dataLakeEndpoint string = storage.properties.primaryEndpoints.dfs + +output queueEndpoint string = storage.properties.primaryEndpoints.queue + +output tableEndpoint string = storage.properties.primaryEndpoints.table + +output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithTablePrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithTablePrivateEndpoint_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..84e807f8a64 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStoragePrivateEndpointLockdownTests.AddAzureStorage_WithTablePrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,36 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('storage${uniqueString(resourceGroup().id)}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_GRS' + } + properties: { + accessTier: 'Hot' + allowSharedKeyAccess: false + isHnsEnabled: false + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Deny' + } + publicNetworkAccess: 'Disabled' + } + tags: { + 'aspire-resource-name': 'storage' + } +} + +output blobEndpoint string = storage.properties.primaryEndpoints.blob + +output dataLakeEndpoint string = storage.properties.primaryEndpoints.dfs + +output queueEndpoint string = storage.properties.primaryEndpoints.queue + +output tableEndpoint string = storage.properties.primaryEndpoints.table + +output name string = storage.name + +output id string = storage.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubHubSettings.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubHubSettings.verified.bicep index a6478f1fbef..81e8eb3c38a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubHubSettings.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubHubSettings.verified.bicep @@ -83,4 +83,6 @@ resource hub2 'Microsoft.SignalRService/webPubSub/hubs@2024-03-01' = { output endpoint string = 'https://${wps1.properties.hostName}' -output name string = wps1.name \ No newline at end of file +output name string = wps1.name + +output id string = wps1.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubHubWithEventHandlerExpressionWorks.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubHubWithEventHandlerExpressionWorks.verified.bicep index e1612c6b1b6..78d08f34d7a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubHubWithEventHandlerExpressionWorks.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubHubWithEventHandlerExpressionWorks.verified.bicep @@ -37,4 +37,6 @@ resource abc 'Microsoft.SignalRService/webPubSub/hubs@2024-03-01' = { output endpoint string = 'https://${wps1.properties.hostName}' -output name string = wps1.name \ No newline at end of file +output name string = wps1.name + +output id string = wps1.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubHubWorks.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubHubWorks.verified.bicep index 0dcba14366c..fde7670ec5c 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubHubWorks.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubHubWorks.verified.bicep @@ -27,4 +27,6 @@ resource abc 'Microsoft.SignalRService/webPubSub/hubs@2024-03-01' = { output endpoint string = 'https://${wps1.properties.hostName}' -output name string = wps1.name \ No newline at end of file +output name string = wps1.name + +output id string = wps1.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubWithParameters.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubWithParameters.verified.bicep index cb27f57f530..eddd7caac0f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubWithParameters.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddAzureWebPubSubWithParameters.verified.bicep @@ -22,4 +22,6 @@ resource wps1 'Microsoft.SignalRService/webPubSub@2024-03-01' = { output endpoint string = 'https://${wps1.properties.hostName}' -output name string = wps1.name \ No newline at end of file +output name string = wps1.name + +output id string = wps1.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddDefaultAzureWebPubSub.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddDefaultAzureWebPubSub.verified.bicep index cb27f57f530..eddd7caac0f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddDefaultAzureWebPubSub.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddDefaultAzureWebPubSub.verified.bicep @@ -22,4 +22,6 @@ resource wps1 'Microsoft.SignalRService/webPubSub@2024-03-01' = { output endpoint string = 'https://${wps1.properties.hostName}' -output name string = wps1.name \ No newline at end of file +output name string = wps1.name + +output id string = wps1.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddWebPubSubWithHubConfigure.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddWebPubSubWithHubConfigure.verified.bicep index 491853b7731..2778cf77763 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddWebPubSubWithHubConfigure.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.AddWebPubSubWithHubConfigure.verified.bicep @@ -30,4 +30,6 @@ resource abc 'Microsoft.SignalRService/webPubSub/hubs@2024-03-01' = { output endpoint string = 'https://${wps1.properties.hostName}' -output name string = wps1.name \ No newline at end of file +output name string = wps1.name + +output id string = wps1.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.ConfigureConstructOverridesAddEventHandler.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.ConfigureConstructOverridesAddEventHandler.verified.bicep index ee68936f233..9006aa5d428 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.ConfigureConstructOverridesAddEventHandler.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureWebPubSubExtensionsTests.ConfigureConstructOverridesAddEventHandler.verified.bicep @@ -38,4 +38,6 @@ resource ABC 'Microsoft.SignalRService/webPubSub/hubs@2024-03-01' = { output endpoint string = 'https://${wps1.properties.hostName}' -output name string = wps1.name \ No newline at end of file +output name string = wps1.name + +output id string = wps1.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.AddExistingAzureServiceBusInPublishMode.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.AddExistingAzureServiceBusInPublishMode.verified.bicep index ff96148d214..2272633771f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.AddExistingAzureServiceBusInPublishMode.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.AddExistingAzureServiceBusInPublishMode.verified.bicep @@ -17,3 +17,5 @@ output serviceBusEndpoint string = messaging.properties.serviceBusEndpoint output serviceBusHostName string = split(replace(messaging.properties.serviceBusEndpoint, 'https://', ''), ':')[0] output name string = messaging.name + +output id string = messaging.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.AddExistingAzureServiceBusInRunMode.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.AddExistingAzureServiceBusInRunMode.verified.bicep index ff96148d214..2272633771f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.AddExistingAzureServiceBusInRunMode.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.AddExistingAzureServiceBusInRunMode.verified.bicep @@ -17,3 +17,5 @@ output serviceBusEndpoint string = messaging.properties.serviceBusEndpoint output serviceBusHostName string = split(replace(messaging.properties.serviceBusEndpoint, 'https://', ''), ':')[0] output name string = messaging.name + +output id string = messaging.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.RequiresPublishAsExistingInPublishMode.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.RequiresPublishAsExistingInPublishMode.verified.bicep index 03c728144fa..db315246ee7 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.RequiresPublishAsExistingInPublishMode.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.RequiresPublishAsExistingInPublishMode.verified.bicep @@ -8,6 +8,7 @@ resource messaging 'Microsoft.ServiceBus/namespaces@2024-01-01' = { location: location properties: { disableLocalAuth: true + publicNetworkAccess: 'Enabled' } sku: { name: sku @@ -27,3 +28,5 @@ output serviceBusEndpoint string = messaging.properties.serviceBusEndpoint output serviceBusHostName string = split(replace(messaging.properties.serviceBusEndpoint, 'https://', ''), ':')[0] output name string = messaging.name + +output id string = messaging.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAppConfigurationWithResourceGroup.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAppConfigurationWithResourceGroup.verified.bicep index bd20e1b25de..9d006b923ad 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAppConfigurationWithResourceGroup.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAppConfigurationWithResourceGroup.verified.bicep @@ -9,4 +9,6 @@ resource appConfig 'Microsoft.AppConfiguration/configurationStores@2024-06-01' e output appConfigEndpoint string = appConfig.properties.endpoint -output name string = appConfig.name \ No newline at end of file +output name string = appConfig.name + +output id string = appConfig.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureCosmosDBWithResourceGroup.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureCosmosDBWithResourceGroup.verified.bicep index fd18a8c0fdc..7ce69082e7b 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureCosmosDBWithResourceGroup.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureCosmosDBWithResourceGroup.verified.bicep @@ -38,3 +38,5 @@ resource container 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/container output connectionString string = cosmos.properties.documentEndpoint output name string = cosmos.name + +output id string = cosmos.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureCosmosDBWithResourceGroupAccessKey.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureCosmosDBWithResourceGroupAccessKey.verified.bicep index 7345e3c05fa..9197becec11 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureCosmosDBWithResourceGroupAccessKey.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureCosmosDBWithResourceGroupAccessKey.verified.bicep @@ -73,4 +73,6 @@ resource container_connectionString 'Microsoft.KeyVault/vaults/secrets@2024-11-0 parent: keyVault } -output name string = cosmos.name \ No newline at end of file +output name string = cosmos.name + +output id string = cosmos.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureRedisEnterpriseWithResourceGroup.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureRedisEnterpriseWithResourceGroup.verified.bicep index 7b541468742..0cc56f81350 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureRedisEnterpriseWithResourceGroup.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureRedisEnterpriseWithResourceGroup.verified.bicep @@ -11,4 +11,6 @@ output connectionString string = '${redis.properties.hostName}:10000,ssl=true' output name string = redis.name +output id string = redis.id + output hostName string = redis.properties.hostName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureRedisEnterpriseWithResourceGroupAndAccessKeyAuth.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureRedisEnterpriseWithResourceGroupAndAccessKeyAuth.verified.bicep index 67aaa1e62f5..7dbd3c92b72 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureRedisEnterpriseWithResourceGroupAndAccessKeyAuth.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureRedisEnterpriseWithResourceGroupAndAccessKeyAuth.verified.bicep @@ -37,4 +37,6 @@ resource primaryAccessKey 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { output name string = redis.name +output id string = redis.id + output hostName string = redis.properties.hostName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSearchWithResourceGroup.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSearchWithResourceGroup.verified.bicep index 6a248463328..fb1c1e62140 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSearchWithResourceGroup.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSearchWithResourceGroup.verified.bicep @@ -12,3 +12,5 @@ output connectionString string = 'Endpoint=https://${existingResourceName}.searc output endpoint string = 'https://${existingResourceName}.search.windows.net' output name string = search.name + +output id string = search.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSignalRWithResourceGroup.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSignalRWithResourceGroup.verified.bicep index 47ea51b2770..83fdbd8c601 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSignalRWithResourceGroup.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSignalRWithResourceGroup.verified.bicep @@ -9,4 +9,6 @@ resource signalR 'Microsoft.SignalRService/signalR@2024-03-01' existing = { output hostName string = signalR.properties.hostName -output name string = signalR.name \ No newline at end of file +output name string = signalR.name + +output id string = signalR.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSqlServerInRunMode.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSqlServerInRunMode.verified.bicep index 42faef05e83..c97cf9bbf36 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSqlServerInRunMode.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSqlServerInRunMode.verified.bicep @@ -29,4 +29,6 @@ output sqlServerFqdn string = sqlServer.properties.fullyQualifiedDomainName output name string = sqlServer.name +output id string = sqlServer.id + output sqlServerAdminName string = sqlServer.properties.administrators.login \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSqlServerWithResourceGroup.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSqlServerWithResourceGroup.verified.bicep index b5a8aba35ca..0ee9866be57 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSqlServerWithResourceGroup.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureSqlServerWithResourceGroup.verified.bicep @@ -20,4 +20,6 @@ output sqlServerFqdn string = sqlServer.properties.fullyQualifiedDomainName output name string = sqlServer.name +output id string = sqlServer.id + output sqlServerAdminName string = sqlServer.properties.administrators.login \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureWebPubSubWithResourceGroup.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureWebPubSubWithResourceGroup.verified.bicep index eff38edfb13..1e31241d498 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureWebPubSubWithResourceGroup.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingAzureWebPubSubWithResourceGroup.verified.bicep @@ -9,4 +9,6 @@ resource webPubSub 'Microsoft.SignalRService/webPubSub@2024-03-01' existing = { output endpoint string = 'https://${webPubSub.properties.hostName}' -output name string = webPubSub.name \ No newline at end of file +output name string = webPubSub.name + +output id string = webPubSub.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingEventHubsWithResourceGroup.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingEventHubsWithResourceGroup.verified.bicep index fbfb14e9387..13f7f0c4129 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingEventHubsWithResourceGroup.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingEventHubsWithResourceGroup.verified.bicep @@ -12,3 +12,5 @@ output eventHubsEndpoint string = eventHubs.properties.serviceBusEndpoint output eventHubsHostName string = split(replace(eventHubs.properties.serviceBusEndpoint, 'https://', ''), ':')[0] output name string = eventHubs.name + +output id string = eventHubs.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingKeyVaultWithResourceGroup.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingKeyVaultWithResourceGroup.verified.bicep index e19f847a774..5e728ee6fa7 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingKeyVaultWithResourceGroup.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingKeyVaultWithResourceGroup.verified.bicep @@ -9,4 +9,6 @@ resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' existing = { output vaultUri string = keyVault.properties.vaultUri -output name string = keyVault.name \ No newline at end of file +output name string = keyVault.name + +output id string = keyVault.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingPostgresSqlWithResourceGroup.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingPostgresSqlWithResourceGroup.verified.bicep index 5edf04c650c..738ff1bc5c0 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingPostgresSqlWithResourceGroup.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingPostgresSqlWithResourceGroup.verified.bicep @@ -20,4 +20,6 @@ output connectionString string = 'Host=${postgresSql.properties.fullyQualifiedDo output name string = postgresSql.name +output id string = postgresSql.id + output hostName string = postgresSql.properties.fullyQualifiedDomainName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingPostgresSqlWithResourceGroupWithPasswordAuth.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingPostgresSqlWithResourceGroupWithPasswordAuth.verified.bicep index b0ad0df8db3..4b27a3862c4 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingPostgresSqlWithResourceGroupWithPasswordAuth.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingPostgresSqlWithResourceGroupWithPasswordAuth.verified.bicep @@ -37,4 +37,6 @@ resource connectionString 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = { output name string = postgresSql.name +output id string = postgresSql.id + output hostName string = postgresSql.properties.fullyQualifiedDomainName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingServiceBusWithResourceGroupInPublishMode#00.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingServiceBusWithResourceGroupInPublishMode#00.verified.bicep index ff96148d214..2272633771f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingServiceBusWithResourceGroupInPublishMode#00.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingServiceBusWithResourceGroupInPublishMode#00.verified.bicep @@ -17,3 +17,5 @@ output serviceBusEndpoint string = messaging.properties.serviceBusEndpoint output serviceBusHostName string = split(replace(messaging.properties.serviceBusEndpoint, 'https://', ''), ':')[0] output name string = messaging.name + +output id string = messaging.id \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingServiceBusWithStaticArguments.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingServiceBusWithStaticArguments.verified.bicep index 65ac1ae9599..2d0fd5e0ccc 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingServiceBusWithStaticArguments.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingServiceBusWithStaticArguments.verified.bicep @@ -15,3 +15,5 @@ output serviceBusEndpoint string = messaging.properties.serviceBusEndpoint output serviceBusHostName string = split(replace(messaging.properties.serviceBusEndpoint, 'https://', ''), ':')[0] output name string = messaging.name + +output id string = messaging.id \ No newline at end of file From f5a3e9f60b24ee5dd9e10892e8a2f3e89220f629 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 6 Feb 2026 13:36:04 -0800 Subject: [PATCH 059/256] Fixing package signature verification (#14380) * Fix package signature verification and publishing commands to use dotnet.cmd * Add .NET 10 SDK installation and update package verification commands * Improve error handling in NuGet package verification and publishing steps --- eng/pipelines/release-publish-nuget.yml | 29 ++++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/eng/pipelines/release-publish-nuget.yml b/eng/pipelines/release-publish-nuget.yml index c4688ec3345..f2e25aefaa5 100644 --- a/eng/pipelines/release-publish-nuget.yml +++ b/eng/pipelines/release-publish-nuget.yml @@ -182,6 +182,12 @@ extends: downloadPath: '$(Pipeline.Workspace)/packages' checkDownloadedFiles: true + - task: UseDotNet@2 + displayName: 'Install .NET 10 SDK' + inputs: + packageType: 'sdk' + version: '10.0.x' + - powershell: | $packagesPath = "$(Pipeline.Workspace)/packages" Write-Host "=== Package Inventory ===" @@ -212,9 +218,14 @@ extends: foreach ($package in $packages) { Write-Host "Verifying: $($package.Name)" - $result = & dotnet nuget verify $package.FullName 2>&1 + # Use $ErrorActionPreference to prevent PowerShell from treating stderr as terminating error + $originalErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $result = dotnet nuget verify $package.FullName 2>&1 + $verifyExitCode = $LASTEXITCODE + $ErrorActionPreference = $originalErrorActionPreference - if ($LASTEXITCODE -ne 0) { + if ($verifyExitCode -ne 0) { Write-Host " ❌ Signature verification FAILED" Write-Host $result $failedVerification += $package.Name @@ -274,13 +285,18 @@ extends: do { try { - $result = & dotnet nuget push $package.FullName ` + # Use $ErrorActionPreference to prevent PowerShell from treating stderr as terminating error + $originalErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $result = dotnet nuget push $package.FullName ` --api-key $apiKey ` --source $source ` --skip-duplicate ` --timeout 300 + $pushExitCode = $LASTEXITCODE + $ErrorActionPreference = $originalErrorActionPreference - if ($LASTEXITCODE -eq 0) { + if ($pushExitCode -eq 0) { # Check if it was skipped (already exists) if ($result -match "already exists|skipping") { Write-Host " ⏭️ Skipped (already exists)" @@ -359,9 +375,10 @@ extends: fetchDepth: 1 - task: UseDotNet@2 - displayName: 'Install .NET SDK' + displayName: 'Install .NET 10 SDK' inputs: - useGlobalJson: true + packageType: 'sdk' + version: '10.0.x' - powershell: | Write-Host "Installing darc CLI..." From a0c059f6bb4882f4663e1a36f84fe44484f93248 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 00:27:09 +0000 Subject: [PATCH 060/256] Update dependencies from https://github.com/microsoft/dcp build 0.22.4 (#14381) On relative base path root Microsoft.DeveloperControlPlane.darwin-amd64 , Microsoft.DeveloperControlPlane.darwin-arm64 , Microsoft.DeveloperControlPlane.linux-amd64 , Microsoft.DeveloperControlPlane.linux-arm64 , Microsoft.DeveloperControlPlane.linux-musl-amd64 , Microsoft.DeveloperControlPlane.windows-amd64 , Microsoft.DeveloperControlPlane.windows-arm64 From Version 0.22.3 -> To Version 0.22.4 Co-authored-by: dotnet-maestro[bot] --- eng/Version.Details.xml | 28 ++++++++++++++-------------- eng/Versions.props | 14 +++++++------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 5821c84095a..ea697099b37 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,33 +1,33 @@ - + https://github.com/microsoft/dcp - cf09d5dd3b0c3229f220944f0391e857dab0049b + f1dbae0486549c8e25572c82524e533ce40e3bc1 - + https://github.com/microsoft/dcp - cf09d5dd3b0c3229f220944f0391e857dab0049b + f1dbae0486549c8e25572c82524e533ce40e3bc1 - + https://github.com/microsoft/dcp - cf09d5dd3b0c3229f220944f0391e857dab0049b + f1dbae0486549c8e25572c82524e533ce40e3bc1 - + https://github.com/microsoft/dcp - cf09d5dd3b0c3229f220944f0391e857dab0049b + f1dbae0486549c8e25572c82524e533ce40e3bc1 - + https://github.com/microsoft/dcp - cf09d5dd3b0c3229f220944f0391e857dab0049b + f1dbae0486549c8e25572c82524e533ce40e3bc1 - + https://github.com/microsoft/dcp - cf09d5dd3b0c3229f220944f0391e857dab0049b + f1dbae0486549c8e25572c82524e533ce40e3bc1 - + https://github.com/microsoft/dcp - cf09d5dd3b0c3229f220944f0391e857dab0049b + f1dbae0486549c8e25572c82524e533ce40e3bc1 https://dev.azure.com/dnceng/internal/_git/dotnet-extensions diff --git a/eng/Versions.props b/eng/Versions.props index a4816ceb51a..a11eff37881 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -28,13 +28,13 @@ 8.0.100-rtm.23512.16 - 0.22.3 - 0.22.3 - 0.22.3 - 0.22.3 - 0.22.3 - 0.22.3 - 0.22.3 + 0.22.4 + 0.22.4 + 0.22.4 + 0.22.4 + 0.22.4 + 0.22.4 + 0.22.4 11.0.0-beta.25610.3 11.0.0-beta.25610.3 From 063a66e8affcd01850b8556a1cb16e92fd25a218 Mon Sep 17 00:00:00 2001 From: Damian Edwards Date: Fri, 6 Feb 2026 17:05:37 -0800 Subject: [PATCH 061/256] Fix aspire doctor to only warn about multiple dev certs when any is untrusted (#14379) * Fix aspire doctor to only warn about multiple dev certs when any is untrusted Previously, aspire doctor always showed a warning when multiple dev certificates were found, even if all were trusted. This changes the behavior so that: - Multiple certs, all trusted: Pass (no warning) - Multiple certs, some untrusted: Warning - Multiple certs, none trusted: Warning The certificate evaluation logic was extracted into a testable static method (EvaluateCertificateResults) and 10 unit tests were added to cover all scenarios. * Address PR review feedback - Remove unreachable else-if branch (trustedCount > 1 when certInfos.Count <= 1 is impossible; this was pre-existing dead code) - Use MinimumCertificateVersionSupportingContainerTrust constant in tests instead of hard-coded version numbers --- .../Utils/EnvironmentChecker/DevCertsCheck.cs | 216 ++++++++++-------- .../Utils/DevCertsCheckTests.cs | 172 ++++++++++++++ 2 files changed, 291 insertions(+), 97 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs index 9a755d595d1..971f27a8eca 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DevCertsCheck.cs @@ -53,105 +53,93 @@ public Task> CheckAsync(CancellationToken } // Check trust level for each certificate - var certTrustLevels = devCertificates.Select(c => (Certificate: c, TrustLevel: GetCertificateTrustLevel(c))).ToList(); - var trustedCerts = certTrustLevels.Where(c => c.TrustLevel != CertificateTrustLevel.None).Select(c => c.Certificate).ToList(); - var fullyTrustedCerts = certTrustLevels.Where(c => c.TrustLevel == CertificateTrustLevel.Full).Select(c => c.Certificate).ToList(); - var partiallyTrustedCerts = certTrustLevels.Where(c => c.TrustLevel == CertificateTrustLevel.Partial).Select(c => c.Certificate).ToList(); - - // Check for old certificate versions among trusted certificates - var oldTrustedCerts = trustedCerts.Where(c => c.GetCertificateVersion() < X509Certificate2Extensions.MinimumCertificateVersionSupportingContainerTrust).ToList(); + var certInfos = devCertificates.Select(c => + { + var trustLevel = GetCertificateTrustLevel(c); + return new CertificateInfo(trustLevel, c.Thumbprint, c.GetCertificateVersion()); + }).ToList(); - var results = new List(); + var results = EvaluateCertificateResults(certInfos); - // Check for multiple dev certificates (in My store) - if (devCertificates.Count > 1) + return Task.FromResult>(results); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Error checking dev-certs"); + return Task.FromResult>([new EnvironmentCheckResult { - var certDetails = string.Join(", ", certTrustLevels.Select(c => - { - var version = c.Certificate.GetCertificateVersion(); - var trustLabel = c.TrustLevel switch - { - CertificateTrustLevel.Full => " [trusted]", - CertificateTrustLevel.Partial => " [partial]", - _ => "" - }; - return $"v{version} ({c.Certificate.Thumbprint[..8]}...){trustLabel}"; - })); - - if (trustedCerts.Count == 0) - { - results.Add(new EnvironmentCheckResult - { - Category = "sdk", - Name = "dev-certs", - Status = EnvironmentCheckStatus.Warning, - Message = $"Multiple HTTPS development certificates found ({devCertificates.Count} certificates), but none are trusted", - Details = $"Found certificates: {certDetails}. Having multiple certificates can cause confusion.", - Fix = "Run 'dotnet dev-certs https --clean' to remove all certificates, then run 'dotnet dev-certs https --trust' to create a new one.", - Link = "https://aka.ms/aspire-prerequisites#dev-certs" - }); - } - else - { - results.Add(new EnvironmentCheckResult - { - Category = "sdk", - Name = "dev-certs", - Status = EnvironmentCheckStatus.Warning, - Message = $"Multiple HTTPS development certificates found ({devCertificates.Count} certificates)", - Details = $"Found certificates: {certDetails}. Having multiple certificates can cause confusion when selecting which one to use.", - Fix = "Run 'dotnet dev-certs https --clean' to remove all certificates, then run 'dotnet dev-certs https --trust' to create a new one.", - Link = "https://aka.ms/aspire-prerequisites#dev-certs" - }); - } - } - // Check for orphaned trusted certificates (in Root store but not in My store, or multiple certs in Root store for single cert in My store) - // This can happen when old certificates were trusted but the certificate in My store was regenerated - else if (trustedCerts.Count > 1) + Category = "sdk", + Name = "dev-certs", + Status = EnvironmentCheckStatus.Warning, + Message = "Unable to check HTTPS development certificate", + Details = ex.Message + }]); + } + } + + /// + /// Evaluates certificate information and produces the appropriate check results. + /// + /// Pre-computed certificate information including trust level, thumbprint, and version. + /// The list of environment check results. + internal static List EvaluateCertificateResults( + List certInfos) + { + var trustedCount = certInfos.Count(c => c.TrustLevel != CertificateTrustLevel.None); + var fullyTrustedCount = certInfos.Count(c => c.TrustLevel == CertificateTrustLevel.Full); + var partiallyTrustedCount = certInfos.Count(c => c.TrustLevel == CertificateTrustLevel.Partial); + + // Check for old certificate versions among trusted certificates + var oldTrustedVersions = certInfos + .Where(c => c.TrustLevel != CertificateTrustLevel.None && c.Version < X509Certificate2Extensions.MinimumCertificateVersionSupportingContainerTrust) + .Select(c => c.Version) + .ToList(); + + var results = new List(); + + // Check for multiple dev certificates (in My store) + if (certInfos.Count > 1) + { + var certDetails = string.Join(", ", certInfos.Select(c => { - results.Add(new EnvironmentCheckResult + var trustLabel = c.TrustLevel switch { - Category = "sdk", - Name = "dev-certs", - Status = EnvironmentCheckStatus.Pass, - Message = $"HTTPS development certificate is trusted ({trustedCerts.Count} trusted certificates found)", - Details = "Having multiple trusted development certificates in the root store is unusual. You may want to clean up old certificates by running 'dotnet dev-certs https --clean'.", - Link = "https://aka.ms/aspire-prerequisites#dev-certs" - }); - } - else if (trustedCerts.Count == 0) + CertificateTrustLevel.Full => " [trusted]", + CertificateTrustLevel.Partial => " [partial]", + _ => "" + }; + return $"v{c.Version} ({c.Thumbprint[..8]}...){trustLabel}"; + })); + + if (trustedCount == 0) { - // Single certificate that's not trusted - provide diagnostic info - var cert = devCertificates[0]; results.Add(new EnvironmentCheckResult { Category = "sdk", Name = "dev-certs", Status = EnvironmentCheckStatus.Warning, - Message = "HTTPS development certificate is not trusted", - Details = $"Certificate {cert.Thumbprint} exists in the personal store but was not found in the trusted root store.", - Fix = "Run: dotnet dev-certs https --trust", + Message = $"Multiple HTTPS development certificates found ({certInfos.Count} certificates), but none are trusted", + Details = $"Found certificates: {certDetails}. Having multiple certificates can cause confusion.", + Fix = "Run 'dotnet dev-certs https --clean' to remove all certificates, then run 'dotnet dev-certs https --trust' to create a new one.", Link = "https://aka.ms/aspire-prerequisites#dev-certs" }); } - else if (partiallyTrustedCerts.Count > 0 && fullyTrustedCerts.Count == 0) + else if (trustedCount < certInfos.Count) { - // Certificate is partially trusted (Linux with SSL_CERT_DIR not configured) - var devCertsTrustPath = GetDevCertsTrustPath(); results.Add(new EnvironmentCheckResult { Category = "sdk", Name = "dev-certs", Status = EnvironmentCheckStatus.Warning, - Message = "HTTPS development certificate is only partially trusted", - Details = $"The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{devCertsTrustPath}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically.", - Fix = $"Set SSL_CERT_DIR in your shell profile: export SSL_CERT_DIR=\"/etc/ssl/certs:{devCertsTrustPath}\"", + Message = $"Multiple HTTPS development certificates found ({certInfos.Count} certificates)", + Details = $"Found certificates: {certDetails}. Having multiple certificates can cause confusion when selecting which one to use.", + Fix = "Run 'dotnet dev-certs https --clean' to remove all certificates, then run 'dotnet dev-certs https --trust' to create a new one.", Link = "https://aka.ms/aspire-prerequisites#dev-certs" }); } + // else: all certificates are trusted — no warning needed else { - // Single trusted certificate - success case results.Add(new EnvironmentCheckResult { Category = "sdk", @@ -160,37 +148,66 @@ public Task> CheckAsync(CancellationToken Message = "HTTPS development certificate is trusted" }); } - - // Warn about old certificate versions - if (oldTrustedCerts.Count > 0) + } + else if (trustedCount == 0) + { + // Single certificate that's not trusted - provide diagnostic info + var cert = certInfos[0]; + results.Add(new EnvironmentCheckResult { - var versions = string.Join(", ", oldTrustedCerts.Select(c => $"v{c.GetCertificateVersion()}")); - results.Add(new EnvironmentCheckResult - { - Category = "sdk", - Name = "dev-certs-version", - Status = EnvironmentCheckStatus.Warning, - Message = $"HTTPS development certificate has an older version ({versions})", - Details = $"Older certificate versions (< v{X509Certificate2Extensions.MinimumCertificateVersionSupportingContainerTrust}) may not support container trust scenarios. Consider regenerating your development certificate. For best compatibility, use .NET SDK 10.0.101 or later.", - Fix = "Run 'dotnet dev-certs https --clean' to remove all certificates, then run 'dotnet dev-certs https --trust' to create a new one.", - Link = "https://aka.ms/aspire-prerequisites#dev-certs" - }); - } - - return Task.FromResult>(results); + Category = "sdk", + Name = "dev-certs", + Status = EnvironmentCheckStatus.Warning, + Message = "HTTPS development certificate is not trusted", + Details = $"Certificate {cert.Thumbprint} exists in the personal store but was not found in the trusted root store.", + Fix = "Run: dotnet dev-certs https --trust", + Link = "https://aka.ms/aspire-prerequisites#dev-certs" + }); } - catch (Exception ex) + else if (partiallyTrustedCount > 0 && fullyTrustedCount == 0) { - logger.LogDebug(ex, "Error checking dev-certs"); - return Task.FromResult>([new EnvironmentCheckResult + // Certificate is partially trusted (Linux with SSL_CERT_DIR not configured) + var devCertsTrustPath = GetDevCertsTrustPath(); + results.Add(new EnvironmentCheckResult { Category = "sdk", Name = "dev-certs", Status = EnvironmentCheckStatus.Warning, - Message = "Unable to check HTTPS development certificate", - Details = ex.Message - }]); + Message = "HTTPS development certificate is only partially trusted", + Details = $"The certificate is in the trusted store, but SSL_CERT_DIR is not configured to include '{devCertsTrustPath}'. Some applications may not trust the certificate. 'aspire run' will configure this automatically.", + Fix = $"Set SSL_CERT_DIR in your shell profile: export SSL_CERT_DIR=\"/etc/ssl/certs:{devCertsTrustPath}\"", + Link = "https://aka.ms/aspire-prerequisites#dev-certs" + }); + } + else + { + // Trusted certificate - success case + results.Add(new EnvironmentCheckResult + { + Category = "sdk", + Name = "dev-certs", + Status = EnvironmentCheckStatus.Pass, + Message = "HTTPS development certificate is trusted" + }); } + + // Warn about old certificate versions + if (oldTrustedVersions.Count > 0) + { + var versions = string.Join(", ", oldTrustedVersions.Select(v => $"v{v}")); + results.Add(new EnvironmentCheckResult + { + Category = "sdk", + Name = "dev-certs-version", + Status = EnvironmentCheckStatus.Warning, + Message = $"HTTPS development certificate has an older version ({versions})", + Details = $"Older certificate versions (< v{X509Certificate2Extensions.MinimumCertificateVersionSupportingContainerTrust}) may not support container trust scenarios. Consider regenerating your development certificate. For best compatibility, use .NET SDK 10.0.101 or later.", + Fix = "Run 'dotnet dev-certs https --clean' to remove all certificates, then run 'dotnet dev-certs https --trust' to create a new one.", + Link = "https://aka.ms/aspire-prerequisites#dev-certs" + }); + } + + return results; } /// @@ -379,3 +396,8 @@ private bool IsCertificateInRootStore(X509Certificate2 certificate) return false; } } + +/// +/// Pre-computed certificate information for evaluation without accessing the certificate store. +/// +internal sealed record CertificateInfo(CertificateTrustLevel TrustLevel, string Thumbprint, int Version); diff --git a/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs new file mode 100644 index 00000000000..255a2cda4e5 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/DevCertsCheckTests.cs @@ -0,0 +1,172 @@ +// 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.Utils.EnvironmentChecker; +using Aspire.Hosting.Utils; + +namespace Aspire.Cli.Tests.Utils; + +public class DevCertsCheckTests +{ + private const int MinVersion = X509Certificate2Extensions.MinimumCertificateVersionSupportingContainerTrust; + + [Fact] + public void EvaluateCertificateResults_MultipleCerts_AllTrusted_ReturnsPass() + { + var certs = new List + { + new(CertificateTrustLevel.Full, "AAAA1111BBBB2222", MinVersion), + new(CertificateTrustLevel.Full, "CCCC3333DDDD4444", MinVersion), + }; + + var results = DevCertsCheck.EvaluateCertificateResults(certs); + + var devCertsResult = Assert.Single(results, r => r.Name == "dev-certs"); + Assert.Equal(EnvironmentCheckStatus.Pass, devCertsResult.Status); + Assert.Contains("trusted", devCertsResult.Message); + } + + [Fact] + public void EvaluateCertificateResults_MultipleCerts_NoneTrusted_ReturnsWarning() + { + var certs = new List + { + new(CertificateTrustLevel.None, "AAAA1111BBBB2222", MinVersion), + new(CertificateTrustLevel.None, "CCCC3333DDDD4444", MinVersion), + }; + + var results = DevCertsCheck.EvaluateCertificateResults(certs); + + var devCertsResult = Assert.Single(results, r => r.Name == "dev-certs"); + Assert.Equal(EnvironmentCheckStatus.Warning, devCertsResult.Status); + Assert.Contains("none are trusted", devCertsResult.Message); + } + + [Fact] + public void EvaluateCertificateResults_MultipleCerts_SomeUntrusted_ReturnsWarning() + { + var certs = new List + { + new(CertificateTrustLevel.Full, "AAAA1111BBBB2222", MinVersion), + new(CertificateTrustLevel.None, "CCCC3333DDDD4444", MinVersion), + }; + + var results = DevCertsCheck.EvaluateCertificateResults(certs); + + var devCertsResult = Assert.Single(results, r => r.Name == "dev-certs"); + Assert.Equal(EnvironmentCheckStatus.Warning, devCertsResult.Status); + Assert.Contains("Multiple HTTPS development certificates found", devCertsResult.Message); + } + + [Fact] + public void EvaluateCertificateResults_SingleCert_Trusted_ReturnsPass() + { + var certs = new List + { + new(CertificateTrustLevel.Full, "AAAA1111BBBB2222", MinVersion), + }; + + var results = DevCertsCheck.EvaluateCertificateResults(certs); + + var devCertsResult = Assert.Single(results, r => r.Name == "dev-certs"); + Assert.Equal(EnvironmentCheckStatus.Pass, devCertsResult.Status); + Assert.Contains("trusted", devCertsResult.Message); + } + + [Fact] + public void EvaluateCertificateResults_SingleCert_Untrusted_ReturnsWarning() + { + var certs = new List + { + new(CertificateTrustLevel.None, "AAAA1111BBBB2222", MinVersion), + }; + + var results = DevCertsCheck.EvaluateCertificateResults(certs); + + var devCertsResult = Assert.Single(results, r => r.Name == "dev-certs"); + Assert.Equal(EnvironmentCheckStatus.Warning, devCertsResult.Status); + Assert.Contains("not trusted", devCertsResult.Message); + } + + [Fact] + public void EvaluateCertificateResults_SingleCert_PartiallyTrusted_ReturnsWarning() + { + var certs = new List + { + new(CertificateTrustLevel.Partial, "AAAA1111BBBB2222", MinVersion), + }; + + var results = DevCertsCheck.EvaluateCertificateResults(certs); + + var devCertsResult = Assert.Single(results, r => r.Name == "dev-certs"); + Assert.Equal(EnvironmentCheckStatus.Warning, devCertsResult.Status); + Assert.Contains("partially trusted", devCertsResult.Message); + } + + [Fact] + public void EvaluateCertificateResults_OldTrustedCert_ReturnsVersionWarning() + { + var certs = new List + { + new(CertificateTrustLevel.Full, "AAAA1111BBBB2222", MinVersion - 1), + }; + + var results = DevCertsCheck.EvaluateCertificateResults(certs); + + Assert.Equal(2, results.Count); + var versionResult = Assert.Single(results, r => r.Name == "dev-certs-version"); + Assert.Equal(EnvironmentCheckStatus.Warning, versionResult.Status); + Assert.Contains("older version", versionResult.Message); + } + + [Fact] + public void EvaluateCertificateResults_MultipleCerts_AllTrusted_NoVersionWarning() + { + var certs = new List + { + new(CertificateTrustLevel.Full, "AAAA1111BBBB2222", MinVersion), + new(CertificateTrustLevel.Full, "CCCC3333DDDD4444", MinVersion + 1), + }; + + var results = DevCertsCheck.EvaluateCertificateResults(certs); + + // Should only have the pass result, no version warning + var devCertsResult = Assert.Single(results); + Assert.Equal("dev-certs", devCertsResult.Name); + Assert.Equal(EnvironmentCheckStatus.Pass, devCertsResult.Status); + } + + [Fact] + public void EvaluateCertificateResults_MultipleCerts_AllPartiallyTrusted_ReturnsPass() + { + // Partially trusted counts as trusted (not None), so all certs are "trusted" + var certs = new List + { + new(CertificateTrustLevel.Partial, "AAAA1111BBBB2222", MinVersion), + new(CertificateTrustLevel.Partial, "CCCC3333DDDD4444", MinVersion), + }; + + var results = DevCertsCheck.EvaluateCertificateResults(certs); + + // Should not have a "Multiple certs" warning since all are trusted + var devCertsResult = Assert.Single(results, r => r.Name == "dev-certs"); + Assert.NotEqual(EnvironmentCheckStatus.Warning, devCertsResult.Status); + } + + [Fact] + public void EvaluateCertificateResults_ThreeCerts_TwoTrustedOneNot_ReturnsWarning() + { + var certs = new List + { + new(CertificateTrustLevel.Full, "AAAA1111BBBB2222", MinVersion), + new(CertificateTrustLevel.Full, "CCCC3333DDDD4444", MinVersion), + new(CertificateTrustLevel.None, "EEEE5555FFFF6666", MinVersion), + }; + + var results = DevCertsCheck.EvaluateCertificateResults(certs); + + var devCertsResult = Assert.Single(results, r => r.Name == "dev-certs"); + Assert.Equal(EnvironmentCheckStatus.Warning, devCertsResult.Status); + Assert.Contains("3 certificates", devCertsResult.Message); + } +} From 159854be1f22de159f6a0ab5ea68d36862af3bb9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:28:39 -0800 Subject: [PATCH 062/256] Add dashboard URL to pipeline summary for ACA and App Service deployments (#14377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Add ACA and AppService dashboard summary links to pipeline summary Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> * Change dashboard summary emoji from 🔗 to 📊 Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --- .../AzureContainerAppEnvironmentResource.cs | 2 ++ .../AzureAppServiceEnvironmentResource.cs | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs index 1ed6696850f..99f2124b0ca 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs @@ -132,6 +132,8 @@ private async Task PrintDashboardUrlAsync(PipelineStepContext context) var dashboardUrl = $"https://aspire-dashboard.ext.{domainValue}"; + context.Summary.Add("📊 Dashboard", dashboardUrl); + await context.ReportingStep.CompleteAsync( $"Dashboard available at [{dashboardUrl}]({dashboardUrl})", CompletionState.Completed, diff --git a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs index 59a433772f6..df69ee2cbb9 100644 --- a/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppService/AzureAppServiceEnvironmentResource.cs @@ -145,6 +145,11 @@ private async Task PrintDashboardUrlAsync(PipelineStepContext context) { var dashboardUri = await DashboardUriReference.GetValueAsync(context.CancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(dashboardUri)) + { + context.Summary.Add("📊 Dashboard", dashboardUri); + } + await context.ReportingStep.CompleteAsync( $"Dashboard available at [{dashboardUri}]({dashboardUri})", CompletionState.Completed, From a6b68c75e6a789e938c9e6630073e27231fb06f9 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sun, 8 Feb 2026 09:05:50 -0800 Subject: [PATCH 063/256] Add self-contained bundle infrastructure for polyglot apphost (#14105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add self-contained bundle infrastructure for polyglot AppHosts * Add --debug flag to failing E2E tests for diagnostics * Retry CI - PDB file lock was transient * Make localhive install aspire.exe * Fix local hive packages location * Hande previous local hive layout * Update clie2e test * Fix LogsCommandTests: output to terminal, add ps verification Root cause: aspire logs apiservice > logs.txt 2>&1 captured stderr (dotnet runtime warnings) and Spectre.Console spinner output instead of actual log content. The grep for [apiservice] found nothing, timing out after 10 seconds. Changes: - Add aspire ps step to verify AppHost is discoverable before logs - Output aspire logs directly to terminal instead of file redirect - Search for [apiservice] in terminal buffer (30s timeout) - Search for 'logs' key in JSON output instead of 'resourceName' - Remove intermediate file-based verification steps (wc/head/grep) - Align with PsCommandTests pattern (proven to work in CI) * Fix LogsCommandTests: don't assert on log content in snapshot mode The aspire logs command in snapshot mode (without --follow) returns empty output even when the AppHost is running and resources are producing logs. This is a known limitation of the current implementation where GetResourceLogsAsync/GetAllAsync returns no buffered logs. The test now verifies: - aspire ps finds the running AppHost (content check) - aspire logs apiservice completes successfully (exit code check) - aspire logs --format json returns valid JSON with 'logs' key (content check) - aspire stop completes successfully (content check) * Add CLI and AppHost SDK version logging to LogsCommandTests Log aspire --version and the AppHost csproj SDK version during the test for diagnostics. This helps verify whether a version mismatch between the CLI and AppHost is causing empty logs output. * Add diagnostic logging to aspire logs pipeline and E2E test * Upgrade diagnostic logging to Information level for AppHost log visibility * Add stderr diagnostics in ResourceLoggerState.GetAllAsync to trace console logs path * Fix LogsCommandTests: increase template search timeout, remove diagnostic logging * Add file-based diagnostics to trace empty aspire logs root cause * Fix aspire logs returning empty output DCP-sourced logs were only stored in the backlog when Dashboard subscribers were actively watching the resource. Without subscribers, AddLog silently dropped DCP logs, and GetAllAsync could not retrieve them since DCP snapshot streams return empty when a follow-mode stream is already consuming logs. Fix: Always store all log entries (both DCP and in-memory) in the backlog regardless of subscriber status. This ensures aspire logs can retrieve accumulated logs at any time. The WatchAsync replay of in-memory entries on first subscription is no longer needed since the backlog already contains everything. * Restore log content assertions in LogsCommandTests * Add diagnostic logging to trace empty aspire logs on CI TEMP: File-based diagnostic logging at three key points: - RPC target: resource name, resolved names, logger keys - ResourceLoggerState.GetAllAsync: backlog count, in-memory count - DcpExecutor.GetAllLogsAsync: resource map keys, lookup result The test dumps these diag files from /tmp after running aspire logs. This logging will be removed once the root cause is identified. * TEMP: Disable unrelated CI jobs, keep only CLI E2E tests Speeds up inner loop for debugging LogsCommandTests. Disabled: integrations, templates, endtoend, polyglot, extension tests. Disabled: macOS and Windows setup jobs. Will be reverted once LogsCommandTests is fixed. * Fix aspire logs: temporarily subscribe to trigger DCP log stream When GetAllAsync finds an empty backlog, it means no subscriber has triggered StartLogStream in DCP yet. Fix by temporarily subscribing to the OnNewLog event (which triggers SubscribersChanged -> StartLogStream), waiting 5s for log entries to arrive, then returning collected entries. This ensures 'aspire logs' works in snapshot mode even when no dashboard or other viewer has opened the resource's console logs. * Add more diag logging, wait 30s before logs, add unfiltered logs call, WatchAsync fallback fix * Switch logs test to webfrontend (apiservice has no console output), keep diag logging * Add direct dotnet run diag + ResourceLogSource stream-level diag for apiservice * Add fd comparison and DCP log dir diagnostics for apiservice vs webfrontend * Fix aspire logs for executables: always start log streams, revert diagnostics DCP does not reliably serve executable logs via snapshot (follow=false) requests. Fix by always starting follow-mode log streams for executables when logs become available, regardless of subscriber state. This populates the backlog so snapshot reads return data. Also add a WatchAsync fallback in GetAllAsync as a safety net: if no logs are found from the backlog or DCP snapshot, temporarily subscribe to trigger the log stream and collect whatever arrives within 10 seconds. Revert all temporary CI workflow changes and diagnostic logging. * Add resource status and dashboard diagnostics to LogsCommandTests * Fix dashboard binary name mismatch, revert DcpExecutor/ResourceLoggerService workarounds The AssemblyName change to 'aspire-dashboard' in Aspire.Dashboard.csproj renamed the output binary, but three MSBuild files still referenced the old name 'Aspire.Dashboard'. On Linux (case-sensitive), the dashboard failed to launch, which prevented DCP from capturing executable logs. Updated: - eng/dashboardpack/Sdk.targets - eng/dashboardpack/UnixFilePermissions.xml - src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets Reverted the DcpExecutor and ResourceLoggerService workarounds as they are no longer needed with the dashboard launching correctly. * Restore build_bundle job in tests.yml for polyglot validation The build_bundle job was lost when tests.yml was reverted to main during debugging. The polyglot validation workflow requires the aspire-bundle artifact which is produced by build-bundle.yml. * Centralize dashboard binary name into single MSBuild property Define AspireDashboardBinaryName in Directory.Build.props as the single source of truth. Use it in the Dashboard csproj and AppHost.in.targets. Add comments to NuGet-shipped files (Sdk.targets, UnixFilePermissions.xml) and BundleDiscovery.cs pointing to the source of truth. Add EnsureDashboardBinaryNameIsConsistentAcrossFiles test that validates all 5 files reference the same binary name, preventing the silent name mismatch that caused the LogsCommandTests CI failure. * Fix EnsureDashboardBinaryNameIsConsistentAcrossFiles assertion The DoesNotContain assertion was too broad - searching for 'aspire-dashboard') matched unrelated content in the targets file. Narrowed it to check specifically that NormalizePath doesn't use a hardcoded name. * Remove DotNetSdkBasedAppHostServerProject and merge DevAppHostServerProject into base class - Delete DotNetSdkBasedAppHostServerProject (SDK-based package reference mode) - Merge DevAppHostServerProject into DotNetBasedAppHostServerProject (now sealed) - Update AppHostServerProjectFactory to remove SDK fallback path - Remove SDK-specific tests and snapshot - Update stale comments in PrebuiltAppHostServer * Remove unused sdkVersion parameter from CreateProjectFilesAsync * Auto-detect Aspire repo root and improve factory fallback - Priority 1: Dev mode if ASPIRE_REPO_ROOT is set or CLI runs from Aspire source repo (detected via .git + Aspire.slnx) - Priority 2: PrebuiltAppHostServer if bundle layout is available - Fallback: Throw explaining both options are needed * Import repo Directory.Packages.props and fix dashboard binary name - Generate Directory.Packages.props in project model path that imports the repo's central package management, enabling version-less PackageReferences - Remove hardcoded StreamJsonRpc/Google.Protobuf versions (now from CPM) - Add AspireDashboardBinaryName property to fix empty dashboard DLL path * Revert dashboard assembly name changes, set ASPNETCORE_ENVIRONMENT=Development in dev mode Revert all changes that renamed the dashboard binary from Aspire.Dashboard to aspire-dashboard. The assembly name returns to its default (project name). Set ASPNETCORE_ENVIRONMENT=Development on the AppHost server process in dev mode so the dashboard can resolve static web assets from the debug build via the .staticwebassets.runtime.json manifest. * Fix dashboard startup in bundle mode for .NET csproj app hosts Remove ConfigureLayoutEnvironment from DotNetAppHostProject. .NET csproj app hosts resolve DCP and Dashboard paths through NuGet assembly metadata, so setting ASPIRE_DASHBOARD_PATH/ASPIRE_DCP_PATH env vars from the bundle layout is unnecessary and was causing the dashboard to fail (the env var pointed to a directory, but the hosting code expected an executable path). Polyglot/guest app hosts (PrebuiltAppHostServer) still correctly set these env vars since they don't have NuGet packages. Add BundleSmokeTests E2E test that creates a starter app with the full bundle installed and verifies the dashboard is actually reachable via curl (not just that the URL appears on screen). * Install bundle to ~/.aspire instead of ~/.aspire/bundle Align PR bundle install scripts with the release install script layout. The CLI binary and all components (runtime, dashboard, dcp, aspire-server) are siblings in ~/.aspire/, so auto-discovery works without needing ASPIRE_LAYOUT_PATH. This also avoids conflicts with the CLI-only install at ~/.aspire/bin/. * Use aspire CLI commands in BundleSmokeTests instead of grep - Use 'aspire ps --format json' for dashboard URL extraction instead of grep -oP which is not available on macOS (BSD grep lacks -P flag) - Use 'aspire stop' for graceful cleanup instead of manual PID management - Add -L to curl to follow 302 redirect from dashboard base URL * Fix NullReferenceException in ResourceSnapshotMapper when collection properties are null When resource snapshots are deserialized from the backchannel RPC, collection properties (Urls, Volumes, HealthReports, EnvironmentVariables, Properties, Relationships, Commands) can be null despite having default values in the class definition. This caused 'aspire resources --format Json' to throw a NullReferenceException. Add null-coalescing (?? []) to all collection property accesses in MapToResourceJson() and a null-check before calling ResourceSource.GetSourceModel() with Properties. * Fix 'aspire new' with --non-interactive failing for templates with prompts GetTemplates() did not pass the nonInteractive flag to GetTemplatesCore(), so templates like aspire-apphost-singlefile always used the interactive code path with prompts. When --non-interactive was set, the prompt methods threw InvalidOperationException ('Interactive input is not supported'). Inject ICliHostEnvironment into DotNetTemplateFactory and derive the GetTemplates(). * Move bundle CLI to bin/ subdir for consistent install path - LayoutDiscovery.TryDiscoverRelativeLayout() now checks parent dir for bundle components (CLI at bin/aspire, components at ../) - Bundle install scripts move CLI to bin/ after extraction - PATH uses ~/.aspire/bin (same as CLI-only install) * Fix bundle script CDN caching: use commit SHA instead of branch ref The install script is fetched from raw.githubusercontent.com which has CDN caching. Using the branch ref can serve stale content after a push. Use .head.sha instead of .head.ref to ensure the latest script is used. Also add both ~/.aspire/bin and ~/.aspire to PATH for backwards compatibility during the transition. * Add diagnostics to BundleSmokeTests for dashboardUrl:null debugging * Fix bundle install: set PR channel for template version compatibility The bundle install script was not setting the global channel to 'pr-{N}', unlike the CLI-only install script. This caused 'aspire new' to use stable 13.1.0 templates which lack GetDashboardUrlsAsync RPC method, resulting in dashboardUrl:null in detach mode. Also removes diagnostic output from BundleSmokeTests. * Add NuGet hive install to bundle scripts for PR template compatibility The bundle install scripts were missing NuGet hive package installation, which the CLI-only install scripts already do. Without the hive, setting channel to 'pr-{N}' fails because the PR channel packages don't exist. Now both bash and PowerShell bundle scripts download built-nugets and built-nugets-for-{rid} artifacts and install them to the hive directory at ~/.aspire/hives/pr-{N}/packages, matching the CLI-only behavior. * Extract XML doc files alongside DLLs in NuGetHelper LayoutCommand When creating the flat DLL layout for the AppHost server, also copy the .xml documentation file for each runtime assembly if it exists. This enables IntelliSense and MCP context for polyglot AppHosts. * Unquarantine TypeScriptPolyglotTests * Update bundle spec and fix TypeScriptPolyglotTests version selection - Update docs/specs/bundle.md to reflect current install layout (~/.aspire/bin/ + sibling components) - Fix TypeScriptPolyglotTests: navigate down to pr-{N} channel in aspire add version prompt * Clean up: use IConfiguration in AssemblyLoader, remove IL3000 suppression, remove debug flags from E2E tests * Restore IL3000 suppression - needed for single-file bundle publish * Make PrebuiltAppHostServer channel-aware - Resolve configured channel (local settings.json → global config fallback) - Filter NuGet sources to resolved channel instead of all explicit channels - Return channel name in PrepareResult so it flows to settings.json - Remove DownArrow workaround in TypeScriptPolyglotTests (channel auto-resolves) * Fix TypeScriptPolyglotTests: accept version prompt after channel auto-resolve * Fix TypeScriptPolyglotTests: wait for version string, not prompt title Spectre.Console briefly renders the channel name before redrawing with the actual version. Waiting for the prompt title caused Enter to be sent during the redraw window, where it was swallowed. Instead, wait for the version string (e.g. 'pr.14105') which only appears after the prompt is fully rendered. * Add delay before Enter on version prompt in TypeScriptPolyglotTests Spectre.Console selection prompts need a brief delay after rendering before they accept input. Without this, Enter is sent before the prompt's input handler is ready and gets discarded. This follows the same pattern used in AgentCommandTests. * Auto-select single-item version prompts in aspire add When the version selection has only one channel or one version, auto-select it instead of showing a single-item Spectre prompt. This improves UX and fixes the flaky TypeScript E2E test where two back-to-back single-item prompts caused Enter to be lost. * Remove accidentally committed cast file * Remove debug artifacts from CI investigation * Fix inaccurate README content for CreateLayout and install scripts - Fix option name: --version → --bundle-version - Fix example version: 9.2.0 → 13.2.0 - Fix build script syntax: build.sh --restore --build -bundle → build.sh -bundle - Fix package sizes: CLI ~25 MB, bundle ~200 MB compressed - Fix runtime download: actually downloads SDK, extracts runtimes + dev-certs - Fix output structure: remove incorrect DLL names, add dotnet muxer - Remove overly specific internal file names from layout diagram --------- Co-authored-by: Sebastien Ros --- .github/skills/ci-test-failures/SKILL.md | 277 ++++ .github/workflows/build-bundle.yml | 110 ++ .../workflows/build-cli-native-archives.yml | 29 +- .github/workflows/polyglot-validation.yml | 185 ++- .../polyglot-validation/Dockerfile.go | 26 +- .../polyglot-validation/Dockerfile.java | 26 +- .../polyglot-validation/Dockerfile.python | 26 +- .../polyglot-validation/Dockerfile.rust | 26 +- .../polyglot-validation/Dockerfile.typescript | 26 +- .../polyglot-validation/setup-local-cli.sh | 205 ++- .../workflows/polyglot-validation/test-go.sh | 4 +- .../polyglot-validation/test-java.sh | 4 +- .../polyglot-validation/test-python.sh | 4 +- .../polyglot-validation/test-rust.sh | 4 +- .../polyglot-validation/test-typescript.sh | 4 +- .github/workflows/tests.yml | 12 +- Aspire.slnx | 2 + Directory.Packages.props | 3 + docs/specs/bundle.md | 1282 +++++++++++++++++ eng/Bundle.proj | 128 ++ eng/Versions.props | 2 + eng/build.ps1 | 69 +- eng/build.sh | 103 +- eng/clipack/Common.projitems | 3 + eng/scripts/README.md | 116 ++ eng/scripts/get-aspire-cli-bundle-pr.ps1 | 631 ++++++++ eng/scripts/get-aspire-cli-bundle-pr.sh | 842 +++++++++++ eng/scripts/install-aspire-bundle.ps1 | 414 ++++++ eng/scripts/install-aspire-bundle.sh | 609 ++++++++ localhive.ps1 | 90 +- localhive.sh | 71 +- .../Aspire.Cli.NuGetHelper.csproj | 24 + .../Commands/LayoutCommand.cs | 244 ++++ .../Commands/RestoreCommand.cs | 390 +++++ .../Commands/SearchCommand.cs | 349 +++++ src/Aspire.Cli.NuGetHelper/NuGetLogger.cs | 43 + src/Aspire.Cli.NuGetHelper/Program.cs | 31 + src/Aspire.Cli/Aspire.Cli.csproj | 1 + .../Backchannel/ResourceSnapshotMapper.cs | 18 +- .../BundleCertificateToolRunner.cs | 186 +++ .../Certificates/CertificateService.cs | 21 +- .../Certificates/ICertificateToolRunner.cs | 26 + .../Certificates/SdkCertificateToolRunner.cs | 155 ++ src/Aspire.Cli/Commands/AddCommand.cs | 12 + src/Aspire.Cli/Commands/DeployCommand.cs | 4 +- src/Aspire.Cli/Commands/DoCommand.cs | 4 +- src/Aspire.Cli/Commands/InitCommand.cs | 4 +- .../Commands/PipelineCommandBase.cs | 25 +- src/Aspire.Cli/Commands/PublishCommand.cs | 4 +- src/Aspire.Cli/Commands/RunCommand.cs | 12 - src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs | 14 +- .../Commands/Sdk/SdkGenerateCommand.cs | 16 +- src/Aspire.Cli/Commands/UpdateCommand.cs | 94 ++ .../Configuration/AspireJsonConfiguration.cs | 13 +- .../Configuration/ConfigurationService.cs | 50 +- src/Aspire.Cli/Configuration/Features.cs | 11 +- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 2 - .../DotNetSdkNotInstalledException.cs | 25 + src/Aspire.Cli/Layout/LayoutConfiguration.cs | 199 +++ src/Aspire.Cli/Layout/LayoutDiscovery.cs | 283 ++++ src/Aspire.Cli/Layout/LayoutProcessRunner.cs | 146 ++ .../NuGet/BundleNuGetPackageCache.cs | 280 ++++ src/Aspire.Cli/NuGet/BundleNuGetService.cs | 209 +++ src/Aspire.Cli/Packaging/PackagingService.cs | 5 +- src/Aspire.Cli/Program.cs | 46 +- .../Projects/AppHostProjectFactory.cs | 26 +- .../Projects/AppHostServerProject.cs | 794 +--------- .../Projects/AppHostServerSession.cs | 21 +- .../Projects/DotNetAppHostProject.cs | 22 +- .../DotNetBasedAppHostServerProject.cs | 639 ++++++++ .../Projects/GuestAppHostProject.cs | 111 +- .../Projects/IAppHostServerProject.cs | 70 + .../Projects/IAppHostServerSession.cs | 4 +- .../Projects/PrebuiltAppHostServer.cs | 383 +++++ src/Aspire.Cli/Projects/ProjectUpdater.cs | 2 +- .../Scaffolding/ScaffoldingService.cs | 46 +- .../Templating/DotNetTemplateFactory.cs | 8 +- src/Aspire.Cli/Utils/BundleDownloader.cs | 705 +++++++++ src/Aspire.Cli/Utils/FileAccessRetrier.cs | 200 +++ src/Aspire.Cli/Utils/TransactionalAction.cs | 151 ++ src/Aspire.Dashboard/Aspire.Dashboard.csproj | 3 + .../Aspire.Hosting.RemoteHost.csproj | 31 +- .../AssemblyLoader.cs | 28 + src/Aspire.Hosting.RemoteHost/Program.cs | 8 + .../appsettings.json | 12 + src/Aspire.Hosting/Aspire.Hosting.csproj | 4 +- .../Dashboard/DashboardEventHandlers.cs | 125 +- src/Aspire.Hosting/Dcp/DcpOptions.cs | 27 +- src/Shared/BundleDiscovery.cs | 501 +++++++ .../BundleSmokeTests.cs | 121 ++ .../Helpers/CliE2ETestHelpers.cs | 79 +- .../TypeScriptPolyglotTests.cs | 37 +- .../Aspire.Cli.Tests/Aspire.Cli.Tests.csproj | 1 + .../ResourceSnapshotMapperTests.cs | 130 ++ .../Certificates/CertificateServiceTests.cs | 199 +-- .../Commands/NewCommandTests.cs | 51 +- .../Commands/RunCommandTests.cs | 2 +- .../Commands/SdkInstallerTests.cs | 48 + ...s_ProducesExpectedXml.pr-1234.verified.xml | 6 +- ...e_ProducesExpectedXml.pr-1234.verified.xml | 6 +- ...d_ProducesExpectedXml.pr-1234.verified.xml | 6 +- ...d_ProducesExpectedXml.pr-1234.verified.xml | 6 +- ...g_ProducesExpectedXml.pr-1234.verified.xml | 6 +- .../NuGetConfigMergerSnapshotTests.cs | 15 +- .../Projects/AppHostServerProjectTests.cs | 286 +--- .../Projects/GuestAppHostProjectTests.cs | 11 +- ...erverProject_ProductionCsproj.verified.xml | 23 - ...s_ProducesExpectedXml.pr-1234.verified.xml | 6 +- ...e_ProducesExpectedXml.pr-1234.verified.xml | 6 +- ...d_ProducesExpectedXml.pr-1234.verified.xml | 6 +- ...d_ProducesExpectedXml.pr-1234.verified.xml | 6 +- ...g_ProducesExpectedXml.pr-1234.verified.xml | 6 +- .../Templating/DotNetTemplateFactoryTests.cs | 22 +- .../TestServices/TestCertificateToolRunner.cs | 41 + .../TestServices/TestDotNetCliRunner.cs | 27 - tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 68 +- .../Aspire.Hosting.RemoteHost.Tests.csproj | 3 +- tools/CreateLayout/CreateLayout.csproj | 21 + tools/CreateLayout/Program.cs | 870 +++++++++++ tools/CreateLayout/README.md | 150 ++ tools/scripts/.gitignore | 6 + 121 files changed, 12880 insertions(+), 1591 deletions(-) create mode 100644 .github/skills/ci-test-failures/SKILL.md create mode 100644 .github/workflows/build-bundle.yml create mode 100644 docs/specs/bundle.md create mode 100644 eng/Bundle.proj create mode 100644 eng/scripts/get-aspire-cli-bundle-pr.ps1 create mode 100644 eng/scripts/get-aspire-cli-bundle-pr.sh create mode 100644 eng/scripts/install-aspire-bundle.ps1 create mode 100644 eng/scripts/install-aspire-bundle.sh create mode 100644 src/Aspire.Cli.NuGetHelper/Aspire.Cli.NuGetHelper.csproj create mode 100644 src/Aspire.Cli.NuGetHelper/Commands/LayoutCommand.cs create mode 100644 src/Aspire.Cli.NuGetHelper/Commands/RestoreCommand.cs create mode 100644 src/Aspire.Cli.NuGetHelper/Commands/SearchCommand.cs create mode 100644 src/Aspire.Cli.NuGetHelper/NuGetLogger.cs create mode 100644 src/Aspire.Cli.NuGetHelper/Program.cs create mode 100644 src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs create mode 100644 src/Aspire.Cli/Certificates/ICertificateToolRunner.cs create mode 100644 src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs create mode 100644 src/Aspire.Cli/Exceptions/DotNetSdkNotInstalledException.cs create mode 100644 src/Aspire.Cli/Layout/LayoutConfiguration.cs create mode 100644 src/Aspire.Cli/Layout/LayoutDiscovery.cs create mode 100644 src/Aspire.Cli/Layout/LayoutProcessRunner.cs create mode 100644 src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs create mode 100644 src/Aspire.Cli/NuGet/BundleNuGetService.cs create mode 100644 src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs create mode 100644 src/Aspire.Cli/Projects/IAppHostServerProject.cs create mode 100644 src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs create mode 100644 src/Aspire.Cli/Utils/BundleDownloader.cs create mode 100644 src/Aspire.Cli/Utils/FileAccessRetrier.cs create mode 100644 src/Aspire.Cli/Utils/TransactionalAction.cs create mode 100644 src/Aspire.Hosting.RemoteHost/Program.cs create mode 100644 src/Aspire.Hosting.RemoteHost/appsettings.json create mode 100644 src/Shared/BundleDiscovery.cs create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs create mode 100644 tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs delete mode 100644 tests/Aspire.Cli.Tests/Snapshots/AppHostServerProject_ProductionCsproj.verified.xml create mode 100644 tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs create mode 100644 tools/CreateLayout/CreateLayout.csproj create mode 100644 tools/CreateLayout/Program.cs create mode 100644 tools/CreateLayout/README.md create mode 100644 tools/scripts/.gitignore diff --git a/.github/skills/ci-test-failures/SKILL.md b/.github/skills/ci-test-failures/SKILL.md new file mode 100644 index 00000000000..e5176423a61 --- /dev/null +++ b/.github/skills/ci-test-failures/SKILL.md @@ -0,0 +1,277 @@ +--- +name: ci-test-failures +description: Guide for diagnosing and fixing CI test failures using the DownloadFailingJobLogs tool. Use this when asked to investigate GitHub Actions test failures, download failure logs, or debug CI issues. +--- + +# CI Test Failure Diagnosis + +This skill provides patterns and practices for diagnosing GitHub Actions test failures in the Aspire repository using the `DownloadFailingJobLogs` tool. + +## Overview + +When CI tests fail, use the `DownloadFailingJobLogs.cs` tool to automatically download logs and artifacts for failed jobs. This tool eliminates manual log hunting and provides structured access to test failures. + +**Location**: `tools/scripts/DownloadFailingJobLogs.cs` + +## Quick Start + +### Step 1: Find the Run ID + +Get the run ID from the GitHub Actions URL or use the `gh` CLI: + +```bash +# From URL: https://github.com/dotnet/aspire/actions/runs/19846215629 +# ^^^^^^^^^^ +# run ID + +# Or find the latest run on a branch +gh run list --repo dotnet/aspire --branch --limit 1 --json databaseId --jq '.[0].databaseId' + +# Or for a PR +gh pr checks --repo dotnet/aspire +``` + +### Step 2: Run the Tool + +```bash +cd tools/scripts +dotnet run DownloadFailingJobLogs.cs -- +``` + +**Example:** +```bash +dotnet run DownloadFailingJobLogs.cs -- 19846215629 +``` + +### Step 3: Analyze Output + +The tool creates files in your current directory: + +| File Pattern | Contents | +|--------------|----------| +| `failed_job__.log` | Raw job logs from GitHub Actions | +| `artifact___.zip` | Downloaded artifact zip files | +| `artifact___/` | Extracted directory with .trx files, logs, binlogs | + +## What the Tool Does + +1. **Finds all failed jobs** in a GitHub Actions workflow run +2. **Downloads job logs** for each failed job +3. **Extracts test failures and errors** from logs using regex patterns +4. **Determines artifact names** from job names (pattern: `logs-{testShortName}-{os}`) +5. **Downloads test artifacts** containing .trx files and test logs +6. **Extracts artifacts** to local directories for inspection + +## Example Workflow + +```bash +# 1. Check failed jobs on a PR +gh pr checks 14105 --repo dotnet/aspire 2>&1 | Where-Object { $_ -match "fail" } + +# 2. Get the run ID +$runId = gh run list --repo dotnet/aspire --branch davidfowl/my-branch --limit 1 --json databaseId --jq '.[0].databaseId' + +# 3. Download failure logs +cd tools/scripts +dotnet run DownloadFailingJobLogs.cs -- $runId + +# 4. Search for errors in downloaded logs +Get-Content "failed_job_0_*.log" | Select-String -Pattern "error|Error:" -Context 2,3 | Select-Object -First 20 + +# 5. Check .trx files for test failures +Get-ChildItem -Recurse -Filter "*.trx" | ForEach-Object { + [xml]$xml = Get-Content $_.FullName + $xml.TestRun.Results.UnitTestResult | Where-Object { $_.outcome -eq "Failed" } +} +``` + +## Understanding Job Log Output + +The tool prints a summary for each failed job: + +``` +=== Failed Job 1/1 === +Name: Tests / Integrations macos (Hosting.Azure) / Hosting.Azure (macos-latest) +ID: 56864254427 +URL: https://github.com/dotnet/aspire/actions/runs/19846215629/job/56864254427 +Downloading job logs... +Saved job logs to: failed_job_0_Tests___Integrations_macos__Hosting_Azure____Hosting_Azure__macos-latest_.log + +Errors found (2): + - System.InvalidOperationException: Step 'provision-api-service' failed... +``` + +## Searching Downloaded Logs + +### Find Errors in Job Logs + +```powershell +# PowerShell +Get-Content "failed_job_*.log" | Select-String -Pattern "error|Error:" -Context 2,3 + +# Bash +grep -i "error" failed_job_*.log | head -50 +``` + +### Find Build Failures + +```powershell +Get-Content "failed_job_*.log" | Select-String -Pattern "Build FAILED|error MSB|error CS" +``` + +### Find Test Failures + +```powershell +Get-Content "failed_job_*.log" | Select-String -Pattern "Failed!" -Context 5,0 +``` + +### Check for Disk Space Issues + +```powershell +Get-Content "failed_job_*.log" | Select-String -Pattern "No space left|disk space" +``` + +### Check for Timeout Issues + +```powershell +Get-Content "failed_job_*.log" | Select-String -Pattern "timeout|timed out|Timeout" +``` + +## Using GitHub API for Annotations + +Sometimes job logs aren't available (404). Use annotations instead: + +```bash +gh api repos/dotnet/aspire/check-runs//annotations +``` + +This returns structured error information even when full logs aren't downloadable. + +## Common Failure Patterns + +### Disk Space Exhaustion + +**Symptom:** `No space left on device` in annotations or logs + +**Diagnosis:** +```powershell +gh api repos/dotnet/aspire/check-runs//annotations 2>&1 +``` + +**Common fixes:** +- Add disk cleanup step before build +- Use larger runner (e.g., `8-core-ubuntu-latest`) +- Skip unnecessary build steps (e.g., `/p:BuildTests=false`) + +### Command Not Found + +**Symptom:** `exit code 127` or `command not found` + +**Diagnosis:** +```powershell +Get-Content "failed_job_*.log" | Select-String -Pattern "command not found|exit code 127" -Context 3,1 +``` + +**Common fixes:** +- Ensure PATH includes required tools +- Use full path to executables +- Install missing dependencies + +### Test Timeout + +**Symptom:** Test hangs, then fails with timeout + +**Diagnosis:** +```powershell +Get-Content "failed_job_*.log" | Select-String -Pattern "Test host process exited|Timeout|timed out" +``` + +**Common fixes:** +- Increase test timeout +- Check for deadlocks in test code +- Review Heartbeat.cs output for resource exhaustion + +### Build Failure + +**Symptom:** `Build FAILED` or MSBuild errors + +**Diagnosis:** +```powershell +Get-Content "failed_job_*.log" | Select-String -Pattern "error CS|error MSB|Build FAILED" -Context 0,3 +``` + +**Common fixes:** +- Check for missing project references +- Verify package versions +- Download and analyze .binlog from artifacts + +## Artifact Contents + +Downloaded artifacts typically contain: + +``` +artifact_0_TestName_os/ +├── testresults/ +│ ├── TestName_net10.0_timestamp.trx # Test results XML +│ ├── Aspire.*.Tests_*.log # Console output +│ └── recordings/ # Asciinema recordings (CLI E2E tests) +├── *.crash.dmp # Crash dump (if test crashed) +└── test.binlog # MSBuild binary log +``` + +## Parsing .trx Files + +```powershell +# Find all failed tests in .trx files +Get-ChildItem -Path "artifact_*" -Recurse -Filter "*.trx" | ForEach-Object { + Write-Host "=== $($_.Name) ===" + [xml]$xml = Get-Content $_.FullName + $xml.TestRun.Results.UnitTestResult | Where-Object { $_.outcome -eq "Failed" } | ForEach-Object { + Write-Host "FAILED: $($_.testName)" + Write-Host $_.Output.ErrorInfo.Message + Write-Host "---" + } +} +``` + +## Tips + +### Clean Up Before Running + +```powershell +Remove-Item *.log -Force -ErrorAction SilentlyContinue +Remove-Item *.zip -Force -ErrorAction SilentlyContinue +Remove-Item -Recurse artifact_* -Force -ErrorAction SilentlyContinue +``` + +### Run From tools/scripts Directory + +The tool creates files in the current directory, so run it from `tools/scripts` to keep things organized: + +```bash +cd tools/scripts +dotnet run DownloadFailingJobLogs.cs -- +``` + +### Don't Commit Log Files + +The downloaded log files can be large. Don't commit them to the repository: + +```bash +# Before committing +rm tools/scripts/*.log +rm tools/scripts/*.zip +rm -rf tools/scripts/artifact_* +``` + +## Prerequisites + +- .NET 10 SDK or later +- GitHub CLI (`gh`) installed and authenticated +- Access to the dotnet/aspire repository + +## See Also + +- `tools/scripts/README.md` - Full documentation +- `tools/scripts/Heartbeat.cs` - System monitoring tool for diagnosing hangs +- `.github/skills/cli-e2e-testing/SKILL.md` - CLI E2E test troubleshooting diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml new file mode 100644 index 00000000000..44250cf4deb --- /dev/null +++ b/.github/workflows/build-bundle.yml @@ -0,0 +1,110 @@ +name: Build Bundle (Reusable) + +# This workflow creates the Aspire CLI bundle by: +# 1. Downloading the native CLI from the CLI archives workflow +# 2. Building/publishing managed components (Dashboard, NuGetHelper, AppHostServer) +# 3. Assembling everything into a bundle using CreateLayout + +on: + workflow_call: + inputs: + versionOverrideArg: + required: false + type: string + +jobs: + build_bundle: + name: Build bundle (${{ matrix.targets.rid }}) + runs-on: ${{ matrix.targets.runner }} + strategy: + matrix: + targets: + - rid: linux-x64 + runner: 8-core-ubuntu-latest # Larger runner for bundle disk space + archive_ext: tar.gz + - rid: win-x64 + runner: windows-latest + archive_ext: zip + - rid: osx-arm64 + runner: macos-latest + archive_ext: tar.gz + + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + # Download CLI archive from previous workflow - this avoids rebuilding native CLI + - name: Download CLI archive + uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 + with: + name: cli-native-archives-${{ matrix.targets.rid }} + path: artifacts/packages + + # Download RID-specific NuGet packages (for DCP) + - name: Download RID-specific NuGets + uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 + with: + name: built-nugets-for-${{ matrix.targets.rid }} + path: artifacts/packages/Release/Shipping + + # Extract CLI archive to expected location so CreateLayout can find it + - name: Extract CLI archive + shell: pwsh + run: | + $rid = "${{ matrix.targets.rid }}" + $ext = if ($rid -eq "win-x64") { "zip" } else { "tar.gz" } + $destDir = "artifacts/bin/Aspire.Cli/Release/net10.0/$rid/native" + New-Item -ItemType Directory -Path $destDir -Force | Out-Null + + $archive = Get-ChildItem -Path artifacts/packages -Recurse -Filter "aspire-cli-$rid-*.$ext" | Select-Object -First 1 + if (-not $archive) { + Write-Error "CLI archive not found for $rid" + exit 1 + } + Write-Host "Extracting $($archive.FullName) to $destDir" + + if ($ext -eq "zip") { + Expand-Archive -Path $archive.FullName -DestinationPath $destDir -Force + } else { + tar -xzf $archive.FullName -C $destDir + } + Get-ChildItem $destDir + + # Build bundle directly via MSBuild - skips native CLI build, uses pre-extracted CLI + - name: Build bundle + shell: pwsh + run: | + $dotnetCmd = if ($IsWindows) { "./dotnet.cmd" } else { "./dotnet.sh" } + & $dotnetCmd msbuild eng/Bundle.proj ` + /restore ` + /p:Configuration=Release ` + /p:TargetRid=${{ matrix.targets.rid }} ` + /p:SkipNativeBuild=true ` + /p:ContinuousIntegrationBuild=true ` + /bl:${{ github.workspace }}/artifacts/log/Release/Bundle.binlog ` + ${{ inputs.versionOverrideArg }} + + - name: Upload bundle + if: success() + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: aspire-bundle-${{ matrix.targets.rid }} + path: artifacts/bundle/${{ matrix.targets.rid }} + retention-days: 15 + if-no-files-found: error + + - name: Upload bundle archive + if: success() + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: aspire-bundle-archive-${{ matrix.targets.rid }} + path: artifacts/bundle/*.${{ matrix.targets.archive_ext }} + retention-days: 15 + if-no-files-found: warn + + - name: Upload logs + if: always() + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: bundle-logs-${{ matrix.targets.rid }} + path: artifacts/log/** diff --git a/.github/workflows/build-cli-native-archives.yml b/.github/workflows/build-cli-native-archives.yml index 5217991c5db..06b7b8c09e7 100644 --- a/.github/workflows/build-cli-native-archives.yml +++ b/.github/workflows/build-cli-native-archives.yml @@ -6,20 +6,27 @@ on: versionOverrideArg: required: false type: string + configuration: + required: false + type: string + default: Debug jobs: build_cli_archives: name: Build CLI (${{ matrix.targets.os }}) - runs-on: ${{ matrix.targets.os }} + runs-on: ${{ matrix.targets.runner }} strategy: matrix: targets: - os: ubuntu-latest + runner: ubuntu-latest rids: linux-x64 - os: windows-latest + runner: windows-latest rids: win-x64 - os: macos-latest + runner: macos-latest rids: osx-arm64 steps: @@ -34,7 +41,8 @@ jobs: -ci -build -restore - /bl:${{ github.workspace }}/artifacts/log/Debug/BuildCli.binlog + -configuration ${{ inputs.configuration }} + /bl:${{ github.workspace }}/artifacts/log/${{ inputs.configuration }}/BuildCli.binlog /p:ContinuousIntegrationBuild=true /p:SkipManagedBuild=true /p:TargetRids=${{ matrix.targets.rids }} @@ -48,19 +56,13 @@ jobs: --ci --build --restore - /bl:${{ github.workspace }}/artifacts/log/Debug/BuildCli.binlog + --configuration ${{ inputs.configuration }} + /bl:${{ github.workspace }}/artifacts/log/${{ inputs.configuration }}/BuildCli.binlog /p:ContinuousIntegrationBuild=true /p:SkipManagedBuild=true /p:TargetRids=${{ matrix.targets.rids }} ${{ inputs.versionOverrideArg }} - - name: Upload logs - if: always() - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: cli-native-logs-${{ matrix.targets.rids }} - path: artifacts/log/** - - name: Upload CLI archives if: success() uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 @@ -69,3 +71,10 @@ jobs: path: artifacts/packages/**/aspire-cli* retention-days: 15 if-no-files-found: error + + - name: Upload logs + if: always() + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: cli-native-logs-${{ matrix.targets.rids }} + path: artifacts/log/** diff --git a/.github/workflows/polyglot-validation.yml b/.github/workflows/polyglot-validation.yml index bdc6d843066..977b5e7e957 100644 --- a/.github/workflows/polyglot-validation.yml +++ b/.github/workflows/polyglot-validation.yml @@ -1,7 +1,7 @@ # Polyglot SDK Validation Tests (Reusable) # Validates Python, Go, Java, Rust, and TypeScript AppHost SDKs with Redis integration # Uses Dockerfiles from .github/workflows/polyglot-validation/ for reproducible environments -# Uses locally built CLI and NuGet packages from the workflow artifacts +# Uses locally built bundle and NuGet packages from the workflow artifacts name: Polyglot SDK Validation on: @@ -19,11 +19,11 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Download CLI archive + - name: Download bundle uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 with: - name: cli-native-archives-linux-x64 - path: ${{ github.workspace }}/artifacts/cli + name: aspire-bundle-linux-x64 + path: ${{ github.workspace }}/artifacts/bundle - name: Download NuGet packages uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 @@ -37,6 +37,55 @@ jobs: name: built-nugets-for-linux-x64 path: ${{ github.workspace }}/artifacts/nugets-rid + - name: Debug - List all downloaded artifacts + run: | + echo "=== DEBUG: Full artifact tree ===" + echo "Working directory: $(pwd)" + echo "GITHUB_WORKSPACE: ${{ github.workspace }}" + echo "" + echo "=== Setting execute permissions on bundle executables ===" + chmod +x ${{ github.workspace }}/artifacts/bundle/aspire || echo "aspire not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/runtime/dotnet || echo "dotnet not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/tools/aspire-nuget/aspire-nuget || echo "aspire-nuget not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/tools/dev-certs/aspire-dev-certs || echo "aspire-dev-certs not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/dashboard/aspire-dashboard || echo "Dashboard not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/aspire-server/aspire-server || echo "RemoteHost not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/dcp/dcp || echo "dcp not found" + echo "" + echo "=== artifacts/ directory ===" + ls -la ${{ github.workspace }}/artifacts/ || echo "artifacts/ does not exist" + echo "" + echo "=== artifacts/bundle/ directory ===" + ls -la ${{ github.workspace }}/artifacts/bundle/ || echo "artifacts/bundle/ does not exist" + echo "" + echo "=== artifacts/bundle/ recursive (first 3 levels) ===" + find ${{ github.workspace }}/artifacts/bundle -maxdepth 3 -type f 2>/dev/null | head -50 || echo "No files found" + find ${{ github.workspace }}/artifacts/bundle -maxdepth 3 -type d 2>/dev/null || echo "No directories found" + echo "" + echo "=== Check for aspire CLI ===" + ls -la ${{ github.workspace }}/artifacts/bundle/aspire 2>/dev/null || echo "aspire CLI not found at expected path" + find ${{ github.workspace }}/artifacts -name "aspire" -type f 2>/dev/null || echo "aspire CLI not found anywhere" + echo "" + echo "=== Check for runtime/dotnet ===" + ls -la ${{ github.workspace }}/artifacts/bundle/runtime/dotnet 2>/dev/null || echo "runtime/dotnet not found at expected path" + find ${{ github.workspace }}/artifacts -name "dotnet" -type f 2>/dev/null || echo "dotnet not found anywhere" + echo "" + echo "=== Check for key directories ===" + for dir in runtime dashboard dcp aspire-server tools; do + if [ -d "${{ github.workspace }}/artifacts/bundle/$dir" ]; then + echo "✓ $dir/ exists" + ls -la "${{ github.workspace }}/artifacts/bundle/$dir" | head -5 + else + echo "✗ $dir/ MISSING" + fi + done + echo "" + echo "=== artifacts/nugets/ sample ===" + find ${{ github.workspace }}/artifacts/nugets -name "*.nupkg" 2>/dev/null | head -10 || echo "No nupkg files found" + echo "" + echo "=== artifacts/nugets-rid/ sample ===" + find ${{ github.workspace }}/artifacts/nugets-rid -name "*.nupkg" 2>/dev/null | head -10 || echo "No nupkg files found" + - name: Build Python validation image run: | docker build \ @@ -58,11 +107,11 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Download CLI archive + - name: Download bundle uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 with: - name: cli-native-archives-linux-x64 - path: ${{ github.workspace }}/artifacts/cli + name: aspire-bundle-linux-x64 + path: ${{ github.workspace }}/artifacts/bundle - name: Download NuGet packages uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 @@ -76,6 +125,32 @@ jobs: name: built-nugets-for-linux-x64 path: ${{ github.workspace }}/artifacts/nugets-rid + - name: Debug - List all downloaded artifacts + run: | + echo "=== DEBUG: Go validation - artifact tree ===" + echo "=== Setting execute permissions on bundle executables ===" + chmod +x ${{ github.workspace }}/artifacts/bundle/aspire || echo "aspire not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/runtime/dotnet || echo "dotnet not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/tools/aspire-nuget/aspire-nuget || echo "aspire-nuget not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/tools/dev-certs/aspire-dev-certs || echo "aspire-dev-certs not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/dashboard/aspire-dashboard || echo "Dashboard not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/aspire-server/aspire-server || echo "RemoteHost not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/dcp/dcp || echo "dcp not found" + echo "" + echo "=== artifacts/bundle/ ===" + ls -la ${{ github.workspace }}/artifacts/bundle/ || echo "bundle/ does not exist" + echo "" + echo "=== Bundle structure check ===" + for dir in runtime dashboard dcp aspire-server; do + if [ -d "${{ github.workspace }}/artifacts/bundle/$dir" ]; then + echo "✓ $dir/" + else + echo "✗ $dir/ MISSING" + fi + done + ls -la ${{ github.workspace }}/artifacts/bundle/aspire 2>/dev/null || echo "aspire CLI MISSING" + ls -la ${{ github.workspace }}/artifacts/bundle/runtime/dotnet 2>/dev/null || echo "runtime/dotnet MISSING" + - name: Build Go validation image run: | docker build \ @@ -97,11 +172,11 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Download CLI archive + - name: Download bundle uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 with: - name: cli-native-archives-linux-x64 - path: ${{ github.workspace }}/artifacts/cli + name: aspire-bundle-linux-x64 + path: ${{ github.workspace }}/artifacts/bundle - name: Download NuGet packages uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 @@ -115,6 +190,32 @@ jobs: name: built-nugets-for-linux-x64 path: ${{ github.workspace }}/artifacts/nugets-rid + - name: Debug - List all downloaded artifacts + run: | + echo "=== DEBUG: Java validation - artifact tree ===" + echo "=== Setting execute permissions on bundle executables ===" + chmod +x ${{ github.workspace }}/artifacts/bundle/aspire || echo "aspire not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/runtime/dotnet || echo "dotnet not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/tools/aspire-nuget/aspire-nuget || echo "aspire-nuget not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/tools/dev-certs/aspire-dev-certs || echo "aspire-dev-certs not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/dashboard/aspire-dashboard || echo "Dashboard not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/aspire-server/aspire-server || echo "RemoteHost not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/dcp/dcp || echo "dcp not found" + echo "" + echo "=== artifacts/bundle/ ===" + ls -la ${{ github.workspace }}/artifacts/bundle/ || echo "bundle/ does not exist" + echo "" + echo "=== Bundle structure check ===" + for dir in runtime dashboard dcp aspire-server; do + if [ -d "${{ github.workspace }}/artifacts/bundle/$dir" ]; then + echo "✓ $dir/" + else + echo "✗ $dir/ MISSING" + fi + done + ls -la ${{ github.workspace }}/artifacts/bundle/aspire 2>/dev/null || echo "aspire CLI MISSING" + ls -la ${{ github.workspace }}/artifacts/bundle/runtime/dotnet 2>/dev/null || echo "runtime/dotnet MISSING" + - name: Build Java validation image run: | docker build \ @@ -138,11 +239,11 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Download CLI archive + - name: Download bundle uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 with: - name: cli-native-archives-linux-x64 - path: ${{ github.workspace }}/artifacts/cli + name: aspire-bundle-linux-x64 + path: ${{ github.workspace }}/artifacts/bundle - name: Download NuGet packages uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 @@ -156,6 +257,32 @@ jobs: name: built-nugets-for-linux-x64 path: ${{ github.workspace }}/artifacts/nugets-rid + - name: Debug - List all downloaded artifacts + run: | + echo "=== DEBUG: Rust validation - artifact tree ===" + echo "=== Setting execute permissions on bundle executables ===" + chmod +x ${{ github.workspace }}/artifacts/bundle/aspire || echo "aspire not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/runtime/dotnet || echo "dotnet not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/tools/aspire-nuget/aspire-nuget || echo "aspire-nuget not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/tools/dev-certs/aspire-dev-certs || echo "aspire-dev-certs not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/dashboard/aspire-dashboard || echo "Dashboard not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/aspire-server/aspire-server || echo "RemoteHost not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/dcp/dcp || echo "dcp not found" + echo "" + echo "=== artifacts/bundle/ ===" + ls -la ${{ github.workspace }}/artifacts/bundle/ || echo "bundle/ does not exist" + echo "" + echo "=== Bundle structure check ===" + for dir in runtime dashboard dcp aspire-server; do + if [ -d "${{ github.workspace }}/artifacts/bundle/$dir" ]; then + echo "✓ $dir/" + else + echo "✗ $dir/ MISSING" + fi + done + ls -la ${{ github.workspace }}/artifacts/bundle/aspire 2>/dev/null || echo "aspire CLI MISSING" + ls -la ${{ github.workspace }}/artifacts/bundle/runtime/dotnet 2>/dev/null || echo "runtime/dotnet MISSING" + - name: Build Rust validation image run: | docker build \ @@ -177,11 +304,11 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Download CLI archive + - name: Download bundle uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 with: - name: cli-native-archives-linux-x64 - path: ${{ github.workspace }}/artifacts/cli + name: aspire-bundle-linux-x64 + path: ${{ github.workspace }}/artifacts/bundle - name: Download NuGet packages uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 @@ -195,6 +322,32 @@ jobs: name: built-nugets-for-linux-x64 path: ${{ github.workspace }}/artifacts/nugets-rid + - name: Debug - List all downloaded artifacts + run: | + echo "=== DEBUG: TypeScript validation - artifact tree ===" + echo "=== Setting execute permissions on bundle executables ===" + chmod +x ${{ github.workspace }}/artifacts/bundle/aspire || echo "aspire not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/runtime/dotnet || echo "dotnet not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/tools/aspire-nuget/aspire-nuget || echo "aspire-nuget not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/tools/dev-certs/aspire-dev-certs || echo "aspire-dev-certs not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/dashboard/aspire-dashboard || echo "Dashboard not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/aspire-server/aspire-server || echo "RemoteHost not found" + chmod +x ${{ github.workspace }}/artifacts/bundle/dcp/dcp || echo "dcp not found" + echo "" + echo "=== artifacts/bundle/ ===" + ls -la ${{ github.workspace }}/artifacts/bundle/ || echo "bundle/ does not exist" + echo "" + echo "=== Bundle structure check ===" + for dir in runtime dashboard dcp aspire-server; do + if [ -d "${{ github.workspace }}/artifacts/bundle/$dir" ]; then + echo "✓ $dir/" + else + echo "✗ $dir/ MISSING" + fi + done + ls -la ${{ github.workspace }}/artifacts/bundle/aspire 2>/dev/null || echo "aspire CLI MISSING" + ls -la ${{ github.workspace }}/artifacts/bundle/runtime/dotnet 2>/dev/null || echo "runtime/dotnet MISSING" + - name: Build TypeScript validation image run: | docker build \ diff --git a/.github/workflows/polyglot-validation/Dockerfile.go b/.github/workflows/polyglot-validation/Dockerfile.go index f1861f5ed0a..95a00d3021a 100644 --- a/.github/workflows/polyglot-validation/Dockerfile.go +++ b/.github/workflows/polyglot-validation/Dockerfile.go @@ -8,7 +8,7 @@ # -v /var/run/docker.sock:/var/run/docker.sock \ # polyglot-go # -# Note: Expects CLI and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ +# Note: Expects bundle and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ # FROM mcr.microsoft.com/devcontainers/go:1-trixie @@ -22,11 +22,7 @@ RUN apt-get update && apt-get install -y \ jq \ && rm -rf /var/lib/apt/lists/* -# Install .NET SDK 10.0 with retry logic -COPY install-dotnet.sh /scripts/install-dotnet.sh -RUN chmod +x /scripts/install-dotnet.sh && /scripts/install-dotnet.sh -ENV PATH="/root/.dotnet:${PATH}" -ENV DOTNET_ROOT="/root/.dotnet" +# Note: .NET SDK is NOT required - the bundle includes the .NET runtime # Pre-configure Aspire CLI path ENV PATH="/root/.aspire/bin:${PATH}" @@ -37,12 +33,28 @@ COPY setup-local-cli.sh /scripts/setup-local-cli.sh COPY test-go.sh /scripts/test-go.sh RUN chmod +x /scripts/setup-local-cli.sh /scripts/test-go.sh -# Entrypoint: Set up Aspire CLI from local artifacts, enable polyglot, run validation +# Entrypoint: Set up Aspire CLI from bundle, enable polyglot, run validation +# Note: ASPIRE_LAYOUT_PATH must be exported before running any aspire commands ENTRYPOINT ["/bin/bash", "-c", "\ set -e && \ + echo '=== ENTRYPOINT DEBUG ===' && \ + echo 'Starting Docker entrypoint...' && \ + echo 'PWD:' $(pwd) && \ + echo '' && \ + echo '=== Running setup-local-cli.sh ===' && \ /scripts/setup-local-cli.sh && \ + echo '' && \ + echo '=== Post-setup: Setting ASPIRE_LAYOUT_PATH ===' && \ + export ASPIRE_LAYOUT_PATH=/workspace/artifacts/bundle && \ + echo 'ASPIRE_LAYOUT_PATH=' $ASPIRE_LAYOUT_PATH && \ + echo '' && \ + echo '=== Verifying CLI with layout path ===' && \ + echo 'Running: aspire --version' && \ + aspire --version && \ + echo '' && \ echo '=== Enabling polyglot support ===' && \ aspire config set features:polyglotSupportEnabled true --global && \ + echo '' && \ echo '=== Running validation ===' && \ /scripts/test-go.sh \ "] diff --git a/.github/workflows/polyglot-validation/Dockerfile.java b/.github/workflows/polyglot-validation/Dockerfile.java index b8494c697fe..d5666de93f9 100644 --- a/.github/workflows/polyglot-validation/Dockerfile.java +++ b/.github/workflows/polyglot-validation/Dockerfile.java @@ -8,7 +8,7 @@ # -v /var/run/docker.sock:/var/run/docker.sock \ # polyglot-java # -# Note: Expects CLI and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ +# Note: Expects bundle and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ # FROM mcr.microsoft.com/devcontainers/java:17 @@ -22,11 +22,7 @@ jq \ && rm -rf /var/lib/apt/lists/* -# Install .NET SDK 10.0 with retry logic -COPY install-dotnet.sh /scripts/install-dotnet.sh -RUN chmod +x /scripts/install-dotnet.sh && /scripts/install-dotnet.sh -ENV PATH="/root/.dotnet:${PATH}" -ENV DOTNET_ROOT="/root/.dotnet" +# Note: .NET SDK is NOT required - the bundle includes the .NET runtime # Pre-configure Aspire CLI path ENV PATH="/root/.aspire/bin:${PATH}" @@ -37,12 +33,28 @@ COPY test-java.sh /scripts/test-java.sh RUN chmod +x /scripts/setup-local-cli.sh /scripts/test-java.sh -# Entrypoint: Set up Aspire CLI from local artifacts, enable polyglot, run validation +# Entrypoint: Set up Aspire CLI from bundle, enable polyglot, run validation +# Note: ASPIRE_LAYOUT_PATH must be exported before running any aspire commands ENTRYPOINT ["/bin/bash", "-c", "\ set -e && \ + echo '=== ENTRYPOINT DEBUG ===' && \ + echo 'Starting Docker entrypoint...' && \ + echo 'PWD:' $(pwd) && \ + echo '' && \ + echo '=== Running setup-local-cli.sh ===' && \ /scripts/setup-local-cli.sh && \ + echo '' && \ + echo '=== Post-setup: Setting ASPIRE_LAYOUT_PATH ===' && \ + export ASPIRE_LAYOUT_PATH=/workspace/artifacts/bundle && \ + echo 'ASPIRE_LAYOUT_PATH=' $ASPIRE_LAYOUT_PATH && \ + echo '' && \ + echo '=== Verifying CLI with layout path ===' && \ + echo 'Running: aspire --version' && \ + aspire --version && \ + echo '' && \ echo '=== Enabling polyglot support ===' && \ aspire config set features:polyglotSupportEnabled true --global && \ + echo '' && \ echo '=== Running validation ===' && \ /scripts/test-java.sh \ "] diff --git a/.github/workflows/polyglot-validation/Dockerfile.python b/.github/workflows/polyglot-validation/Dockerfile.python index becff2c5474..9a8d391c3ea 100644 --- a/.github/workflows/polyglot-validation/Dockerfile.python +++ b/.github/workflows/polyglot-validation/Dockerfile.python @@ -8,7 +8,7 @@ # -v /var/run/docker.sock:/var/run/docker.sock \ # polyglot-python # -# Note: Expects CLI and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ +# Note: Expects bundle and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ # FROM mcr.microsoft.com/devcontainers/python:3.12 @@ -22,11 +22,7 @@ RUN apt-get update && apt-get install -y \ jq \ && rm -rf /var/lib/apt/lists/* -# Install .NET SDK 10.0 with retry logic -COPY install-dotnet.sh /scripts/install-dotnet.sh -RUN chmod +x /scripts/install-dotnet.sh && /scripts/install-dotnet.sh -ENV PATH="/root/.dotnet:${PATH}" -ENV DOTNET_ROOT="/root/.dotnet" +# Note: .NET SDK is NOT required - the bundle includes the .NET runtime # Install uv package manager (Python-specific) RUN curl -LsSf https://astral.sh/uv/install.sh | sh @@ -41,12 +37,28 @@ COPY setup-local-cli.sh /scripts/setup-local-cli.sh COPY test-python.sh /scripts/test-python.sh RUN chmod +x /scripts/setup-local-cli.sh /scripts/test-python.sh -# Entrypoint: Set up Aspire CLI from local artifacts, enable polyglot, run validation +# Entrypoint: Set up Aspire CLI from bundle, enable polyglot, run validation +# Note: ASPIRE_LAYOUT_PATH must be exported before running any aspire commands ENTRYPOINT ["/bin/bash", "-c", "\ set -e && \ + echo '=== ENTRYPOINT DEBUG ===' && \ + echo 'Starting Docker entrypoint...' && \ + echo 'PWD:' $(pwd) && \ + echo '' && \ + echo '=== Running setup-local-cli.sh ===' && \ /scripts/setup-local-cli.sh && \ + echo '' && \ + echo '=== Post-setup: Setting ASPIRE_LAYOUT_PATH ===' && \ + export ASPIRE_LAYOUT_PATH=/workspace/artifacts/bundle && \ + echo 'ASPIRE_LAYOUT_PATH=' $ASPIRE_LAYOUT_PATH && \ + echo '' && \ + echo '=== Verifying CLI with layout path ===' && \ + echo 'Running: aspire --version' && \ + aspire --version && \ + echo '' && \ echo '=== Enabling polyglot support ===' && \ aspire config set features:polyglotSupportEnabled true --global && \ + echo '' && \ echo '=== Running validation ===' && \ /scripts/test-python.sh \ "] diff --git a/.github/workflows/polyglot-validation/Dockerfile.rust b/.github/workflows/polyglot-validation/Dockerfile.rust index 63904dae9cd..6eb4e70959d 100644 --- a/.github/workflows/polyglot-validation/Dockerfile.rust +++ b/.github/workflows/polyglot-validation/Dockerfile.rust @@ -8,7 +8,7 @@ # -v /var/run/docker.sock:/var/run/docker.sock \ # polyglot-rust # -# Note: Expects CLI and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ +# Note: Expects bundle and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ # FROM mcr.microsoft.com/devcontainers/rust:1 @@ -22,11 +22,7 @@ RUN apt-get update && apt-get install -y \ jq \ && rm -rf /var/lib/apt/lists/* -# Install .NET SDK 10.0 with retry logic -COPY install-dotnet.sh /scripts/install-dotnet.sh -RUN chmod +x /scripts/install-dotnet.sh && /scripts/install-dotnet.sh -ENV PATH="/root/.dotnet:${PATH}" -ENV DOTNET_ROOT="/root/.dotnet" +# Note: .NET SDK is NOT required - the bundle includes the .NET runtime # Pre-configure Aspire CLI path ENV PATH="/root/.aspire/bin:${PATH}" @@ -37,12 +33,28 @@ COPY setup-local-cli.sh /scripts/setup-local-cli.sh COPY test-rust.sh /scripts/test-rust.sh RUN chmod +x /scripts/setup-local-cli.sh /scripts/test-rust.sh -# Entrypoint: Set up Aspire CLI from local artifacts, enable polyglot, run validation +# Entrypoint: Set up Aspire CLI from bundle, enable polyglot, run validation +# Note: ASPIRE_LAYOUT_PATH must be exported before running any aspire commands ENTRYPOINT ["/bin/bash", "-c", "\ set -e && \ + echo '=== ENTRYPOINT DEBUG ===' && \ + echo 'Starting Docker entrypoint...' && \ + echo 'PWD:' $(pwd) && \ + echo '' && \ + echo '=== Running setup-local-cli.sh ===' && \ /scripts/setup-local-cli.sh && \ + echo '' && \ + echo '=== Post-setup: Setting ASPIRE_LAYOUT_PATH ===' && \ + export ASPIRE_LAYOUT_PATH=/workspace/artifacts/bundle && \ + echo 'ASPIRE_LAYOUT_PATH=' $ASPIRE_LAYOUT_PATH && \ + echo '' && \ + echo '=== Verifying CLI with layout path ===' && \ + echo 'Running: aspire --version' && \ + aspire --version && \ + echo '' && \ echo '=== Enabling polyglot support ===' && \ aspire config set features:polyglotSupportEnabled true --global && \ + echo '' && \ echo '=== Running validation ===' && \ /scripts/test-rust.sh \ "] diff --git a/.github/workflows/polyglot-validation/Dockerfile.typescript b/.github/workflows/polyglot-validation/Dockerfile.typescript index a44b607ddb0..c6bfb80b4c1 100644 --- a/.github/workflows/polyglot-validation/Dockerfile.typescript +++ b/.github/workflows/polyglot-validation/Dockerfile.typescript @@ -8,7 +8,7 @@ # -v /var/run/docker.sock:/var/run/docker.sock \ # polyglot-typescript # -# Note: Expects CLI and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ +# Note: Expects bundle and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ # FROM mcr.microsoft.com/devcontainers/typescript-node:22 @@ -22,11 +22,7 @@ RUN apt-get update && apt-get install -y \ jq \ && rm -rf /var/lib/apt/lists/* -# Install .NET SDK 10.0 with retry logic -COPY install-dotnet.sh /scripts/install-dotnet.sh -RUN chmod +x /scripts/install-dotnet.sh && /scripts/install-dotnet.sh -ENV PATH="/root/.dotnet:${PATH}" -ENV DOTNET_ROOT="/root/.dotnet" +# Note: .NET SDK is NOT required - the bundle includes the .NET runtime # Pre-configure Aspire CLI path ENV PATH="/root/.aspire/bin:${PATH}" @@ -37,12 +33,28 @@ COPY setup-local-cli.sh /scripts/setup-local-cli.sh COPY test-typescript.sh /scripts/test-typescript.sh RUN chmod +x /scripts/setup-local-cli.sh /scripts/test-typescript.sh -# Entrypoint: Set up Aspire CLI from local artifacts, enable polyglot, run validation +# Entrypoint: Set up Aspire CLI from bundle, enable polyglot, run validation +# Note: ASPIRE_LAYOUT_PATH must be exported before running any aspire commands ENTRYPOINT ["/bin/bash", "-c", "\ set -e && \ + echo '=== ENTRYPOINT DEBUG ===' && \ + echo 'Starting Docker entrypoint...' && \ + echo 'PWD:' $(pwd) && \ + echo '' && \ + echo '=== Running setup-local-cli.sh ===' && \ /scripts/setup-local-cli.sh && \ + echo '' && \ + echo '=== Post-setup: Setting ASPIRE_LAYOUT_PATH ===' && \ + export ASPIRE_LAYOUT_PATH=/workspace/artifacts/bundle && \ + echo 'ASPIRE_LAYOUT_PATH=' $ASPIRE_LAYOUT_PATH && \ + echo '' && \ + echo '=== Verifying CLI with layout path ===' && \ + echo 'Running: aspire --version' && \ + aspire --version && \ + echo '' && \ echo '=== Enabling polyglot support ===' && \ aspire config set features:polyglotSupportEnabled true --global && \ + echo '' && \ echo '=== Running validation ===' && \ /scripts/test-typescript.sh \ "] diff --git a/.github/workflows/polyglot-validation/setup-local-cli.sh b/.github/workflows/polyglot-validation/setup-local-cli.sh index db304cccb3f..08d47d4e283 100644 --- a/.github/workflows/polyglot-validation/setup-local-cli.sh +++ b/.github/workflows/polyglot-validation/setup-local-cli.sh @@ -1,74 +1,231 @@ #!/bin/bash # setup-local-cli.sh - Set up Aspire CLI and NuGet packages from local artifacts # Used by polyglot validation Dockerfiles to use pre-built artifacts from the workflow +# +# This version uses the BUNDLE instead of CLI archive: +# - Bundle includes: CLI, runtime, dashboard, dcp, aspire-server +# - No .NET SDK required (uses bundled runtime) set -e ARTIFACTS_DIR="/workspace/artifacts" -CLI_DIR="$ARTIFACTS_DIR/cli" +BUNDLE_DIR="$ARTIFACTS_DIR/bundle" NUGETS_DIR="$ARTIFACTS_DIR/nugets" NUGETS_RID_DIR="$ARTIFACTS_DIR/nugets-rid" ASPIRE_HOME="$HOME/.aspire" -echo "=== Setting up Aspire CLI from local artifacts ===" +echo "==============================================" +echo "=== SETUP-LOCAL-CLI.SH - DEBUG OUTPUT ===" +echo "==============================================" +echo "" +echo "=== Environment ===" +echo "PWD: $(pwd)" +echo "HOME: $HOME" +echo "USER: $(whoami)" +echo "ARTIFACTS_DIR: $ARTIFACTS_DIR" +echo "BUNDLE_DIR: $BUNDLE_DIR" +echo "ASPIRE_HOME: $ASPIRE_HOME" +echo "" -# Find and extract the CLI archive -CLI_ARCHIVE=$(find "$CLI_DIR" -name "aspire-cli-linux-x64*.tar.gz" 2>/dev/null | head -1) -if [ -z "$CLI_ARCHIVE" ]; then - echo "Error: Could not find CLI archive in $CLI_DIR" - ls -la "$CLI_DIR" 2>/dev/null || echo "Directory does not exist" +echo "=== /workspace contents ===" +ls -la /workspace 2>/dev/null || echo "/workspace does not exist" +echo "" + +echo "=== /workspace/artifacts contents ===" +ls -la "$ARTIFACTS_DIR" 2>/dev/null || echo "artifacts dir does not exist" +echo "" + +echo "=== /workspace/artifacts/bundle contents ===" +ls -la "$BUNDLE_DIR" 2>/dev/null || echo "bundle dir does not exist" +echo "" + +echo "=== Full bundle tree (all files) ===" +find "$BUNDLE_DIR" -type f 2>/dev/null | sort || echo "No files in bundle" +echo "" + +echo "=== Full bundle tree (all directories) ===" +find "$BUNDLE_DIR" -type d 2>/dev/null | sort || echo "No directories in bundle" +echo "" + +# Verify bundle exists +if [ ! -d "$BUNDLE_DIR" ]; then + echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" + echo "ERROR: Bundle directory does not exist: $BUNDLE_DIR" + echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" + echo "" + echo "=== Checking parent directories ===" + ls -la /workspace 2>/dev/null || echo "/workspace does not exist" + ls -la "$ARTIFACTS_DIR" 2>/dev/null || echo "Artifacts directory does not exist" + echo "" + echo "=== Find any 'aspire' executables ===" + find /workspace -name "aspire" -type f 2>/dev/null || echo "None found" exit 1 fi -echo "Found CLI archive: $CLI_ARCHIVE" +echo "=== Checking required bundle structure ===" +MISSING_DIRS="" +for dir in runtime dashboard dcp aspire-server; do + if [ -d "$BUNDLE_DIR/$dir" ]; then + echo " ✓ $dir/ exists" + echo " Contents: $(ls "$BUNDLE_DIR/$dir" | head -5 | tr '\n' ' ')" + else + echo " ✗ $dir/ MISSING" + MISSING_DIRS="$MISSING_DIRS $dir" + fi +done +echo "" + +# Check for muxer +echo "=== Checking for .NET muxer ===" +if [ -f "$BUNDLE_DIR/runtime/dotnet" ]; then + echo " ✓ runtime/dotnet exists" + echo " Size: $(ls -lh "$BUNDLE_DIR/runtime/dotnet" | awk '{print $5}')" + echo " Permissions: $(ls -l "$BUNDLE_DIR/runtime/dotnet" | awk '{print $1}')" + file "$BUNDLE_DIR/runtime/dotnet" 2>/dev/null || true +else + echo " ✗ runtime/dotnet MISSING" + echo " Looking for dotnet anywhere in bundle:" + find "$BUNDLE_DIR" -name "dotnet*" -type f 2>/dev/null || echo " None found" +fi +echo "" + +# Report any missing directories +if [ -n "$MISSING_DIRS" ]; then + echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" + echo "WARNING: Missing directories:$MISSING_DIRS" + echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" + echo "" +fi -# Create CLI directory and extract +# Fix executable permissions (lost when downloading artifacts) +echo "=== Fixing executable permissions ===" +chmod +x "$BUNDLE_DIR/aspire" 2>/dev/null || true +chmod +x "$BUNDLE_DIR/runtime/dotnet" 2>/dev/null || true +# DCP executables +find "$BUNDLE_DIR/dcp" -type f -name "dcp*" -exec chmod +x {} \; 2>/dev/null || true +find "$BUNDLE_DIR/dcp" -type f ! -name "*.*" -exec chmod +x {} \; 2>/dev/null || true +echo " ✓ Permissions fixed" +echo "" + +# Check if aspire CLI exists in bundle +echo "=== Checking for aspire CLI executable ===" +if [ ! -f "$BUNDLE_DIR/aspire" ]; then + echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" + echo "ERROR: aspire CLI not found in bundle at: $BUNDLE_DIR/aspire" + echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" + echo "" + echo "Bundle contents:" + ls -la "$BUNDLE_DIR" + echo "" + echo "Looking for 'aspire' anywhere:" + find /workspace -name "aspire" -type f 2>/dev/null || echo "Not found anywhere" + exit 1 +fi + +echo " ✓ aspire CLI found" +echo " Size: $(ls -lh "$BUNDLE_DIR/aspire" | awk '{print $5}')" +echo " Permissions: $(ls -l "$BUNDLE_DIR/aspire" | awk '{print $1}')" +file "$BUNDLE_DIR/aspire" 2>/dev/null || true +echo "" + +# Create CLI directory and copy CLI +echo "=== Installing CLI to $ASPIRE_HOME/bin ===" mkdir -p "$ASPIRE_HOME/bin" -tar -xzf "$CLI_ARCHIVE" -C "$ASPIRE_HOME/bin" +cp "$BUNDLE_DIR/aspire" "$ASPIRE_HOME/bin/" chmod +x "$ASPIRE_HOME/bin/aspire" +echo " ✓ CLI copied and made executable" +echo "" -# Verify CLI works -echo "CLI version:" -"$ASPIRE_HOME/bin/aspire" --version +# Set ASPIRE_LAYOUT_PATH to point to the bundle so CLI uses bundled runtime/components +export ASPIRE_LAYOUT_PATH="$BUNDLE_DIR" +echo "=== Environment variable set ===" +echo " ASPIRE_LAYOUT_PATH=$ASPIRE_LAYOUT_PATH" +echo "" + +# Verify CLI works (this also tests that the bundled runtime works) +echo "=== Testing CLI with --version ===" +echo " Running: $ASPIRE_HOME/bin/aspire --version" +"$ASPIRE_HOME/bin/aspire" --version || { + echo "" + echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" + echo "ERROR: CLI --version failed!" + echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" + echo "" + echo "=== Debug: Running CLI with ASPIRE_DEBUG_LAYOUT=1 ===" + ASPIRE_DEBUG_LAYOUT=1 "$ASPIRE_HOME/bin/aspire" --version 2>&1 || true + exit 1 +} +echo "" # Set up NuGet hive +echo "=== Setting up NuGet package hive ===" HIVE_DIR="$ASPIRE_HOME/hives/local/packages" mkdir -p "$HIVE_DIR" +echo " Hive directory: $HIVE_DIR" +echo "" + +# Debug NuGet directories +echo "=== NuGet artifact directories ===" +echo " NUGETS_DIR: $NUGETS_DIR" +ls -la "$NUGETS_DIR" 2>/dev/null || echo " Does not exist" +echo "" +echo " NUGETS_RID_DIR: $NUGETS_RID_DIR" +ls -la "$NUGETS_RID_DIR" 2>/dev/null || echo " Does not exist" +echo "" # Find NuGet packages in the shipping directory SHIPPING_DIR="$NUGETS_DIR/Release/Shipping" if [ ! -d "$SHIPPING_DIR" ]; then - # Try without Release subdirectory + echo " Release/Shipping not found, trying $NUGETS_DIR directly" SHIPPING_DIR="$NUGETS_DIR" fi if [ -d "$SHIPPING_DIR" ]; then - echo "Copying NuGet packages from $SHIPPING_DIR to hive" + echo " Copying NuGet packages from $SHIPPING_DIR to hive" # Copy all .nupkg files, handling nested directories find "$SHIPPING_DIR" -name "*.nupkg" -exec cp {} "$HIVE_DIR/" \; PKG_COUNT=$(find "$HIVE_DIR" -name "*.nupkg" | wc -l) - echo "Copied $PKG_COUNT packages to hive" + echo " ✓ Copied $PKG_COUNT packages to hive" else - echo "Warning: Could not find NuGet packages directory" - ls -la "$NUGETS_DIR" 2>/dev/null || echo "Directory does not exist" + echo " ✗ Warning: Could not find NuGet packages directory" + ls -la "$NUGETS_DIR" 2>/dev/null || echo " Directory does not exist" fi # Copy RID-specific packages (Aspire.Hosting.Orchestration.linux-x64, Aspire.Dashboard.Sdk.linux-x64) if [ -d "$NUGETS_RID_DIR" ]; then - echo "Copying RID-specific NuGet packages from $NUGETS_RID_DIR to hive" + echo " Copying RID-specific NuGet packages from $NUGETS_RID_DIR to hive" find "$NUGETS_RID_DIR" -name "*.nupkg" -exec cp {} "$HIVE_DIR/" \; RID_PKG_COUNT=$(find "$NUGETS_RID_DIR" -name "*.nupkg" | wc -l) - echo "Copied $RID_PKG_COUNT RID-specific packages to hive" + echo " ✓ Copied $RID_PKG_COUNT RID-specific packages to hive" else - echo "Warning: Could not find RID-specific NuGet packages directory at $NUGETS_RID_DIR" + echo " ✗ Warning: Could not find RID-specific NuGet packages directory at $NUGETS_RID_DIR" fi # Total package count TOTAL_PKG_COUNT=$(find "$HIVE_DIR" -name "*.nupkg" | wc -l) -echo "Total packages in hive: $TOTAL_PKG_COUNT" +echo "" +echo " Total packages in hive: $TOTAL_PKG_COUNT" +echo " Sample packages:" +find "$HIVE_DIR" -name "*.nupkg" | head -5 | while read f; do echo " - $(basename "$f")"; done +echo "" # Set the channel to 'local' so CLI uses our hive -echo "Setting channel to 'local'" -"$ASPIRE_HOME/bin/aspire" config set channel local --global || true +echo "=== Configuring CLI channel ===" +echo " Setting channel to 'local'" +"$ASPIRE_HOME/bin/aspire" config set channel local --global || { + echo " ✗ Warning: Failed to set channel" +} +echo "" + +# Export ASPIRE_LAYOUT_PATH for child processes (like aspire run) +# This tells the CLI to use the bundled runtime, dashboard, dcp, etc. +echo "export ASPIRE_LAYOUT_PATH=$BUNDLE_DIR" >> ~/.bashrc +echo " ✓ Added ASPIRE_LAYOUT_PATH to ~/.bashrc" +echo "" +echo "==============================================" echo "=== Aspire CLI setup complete ===" +echo "==============================================" +echo "Bundle mode enabled - using bundled runtime (no .NET SDK required)" +echo "ASPIRE_LAYOUT_PATH=$BUNDLE_DIR" +echo "" diff --git a/.github/workflows/polyglot-validation/test-go.sh b/.github/workflows/polyglot-validation/test-go.sh index 91c4509b5a8..a13ac52b798 100755 --- a/.github/workflows/polyglot-validation/test-go.sh +++ b/.github/workflows/polyglot-validation/test-go.sh @@ -21,11 +21,11 @@ cd "$WORK_DIR" # Initialize Go AppHost echo "Creating Go apphost project..." -aspire init -l go --non-interactive +aspire init -l go --non-interactive -d # Add Redis integration echo "Adding Redis integration..." -aspire add Aspire.Hosting.Redis --non-interactive 2>&1 || { +aspire add Aspire.Hosting.Redis --non-interactive -d 2>&1 || { echo "aspire add failed, manually updating settings.json..." PKG_VERSION=$(aspire --version | grep -oP '\d+\.\d+\.\d+-.*' | head -1) if [ -f ".aspire/settings.json" ]; then diff --git a/.github/workflows/polyglot-validation/test-java.sh b/.github/workflows/polyglot-validation/test-java.sh index a6a7f7a89f9..28c91567709 100755 --- a/.github/workflows/polyglot-validation/test-java.sh +++ b/.github/workflows/polyglot-validation/test-java.sh @@ -21,11 +21,11 @@ cd "$WORK_DIR" # Initialize Java AppHost echo "Creating Java apphost project..." -aspire init -l java --non-interactive +aspire init -l java --non-interactive -d # Add Redis integration echo "Adding Redis integration..." -aspire add Aspire.Hosting.Redis --non-interactive 2>&1 || { +aspire add Aspire.Hosting.Redis --non-interactive -d 2>&1 || { echo "aspire add failed, manually updating settings.json..." PKG_VERSION=$(aspire --version | grep -oP '\d+\.\d+\.\d+-.*' | head -1) if [ -f ".aspire/settings.json" ]; then diff --git a/.github/workflows/polyglot-validation/test-python.sh b/.github/workflows/polyglot-validation/test-python.sh index 6009375f247..e510a15d404 100755 --- a/.github/workflows/polyglot-validation/test-python.sh +++ b/.github/workflows/polyglot-validation/test-python.sh @@ -21,11 +21,11 @@ cd "$WORK_DIR" # Initialize Python AppHost echo "Creating Python apphost project..." -aspire init -l python --non-interactive +aspire init -l python --non-interactive -d # Add Redis integration echo "Adding Redis integration..." -aspire add Aspire.Hosting.Redis --non-interactive 2>&1 || { +aspire add Aspire.Hosting.Redis --non-interactive -d 2>&1 || { echo "aspire add failed, manually updating settings.json..." PKG_VERSION=$(aspire --version | grep -oP '\d+\.\d+\.\d+-.*' | head -1) if [ -f ".aspire/settings.json" ]; then diff --git a/.github/workflows/polyglot-validation/test-rust.sh b/.github/workflows/polyglot-validation/test-rust.sh index f2c414c4c89..a66647d88ca 100755 --- a/.github/workflows/polyglot-validation/test-rust.sh +++ b/.github/workflows/polyglot-validation/test-rust.sh @@ -21,11 +21,11 @@ cd "$WORK_DIR" # Initialize Rust AppHost echo "Creating Rust apphost project..." -aspire init -l rust --non-interactive +aspire init -l rust --non-interactive -d # Add Redis integration echo "Adding Redis integration..." -aspire add Aspire.Hosting.Redis --non-interactive 2>&1 || { +aspire add Aspire.Hosting.Redis --non-interactive -d 2>&1 || { echo "aspire add failed, manually updating settings.json..." PKG_VERSION=$(aspire --version | grep -oP '\d+\.\d+\.\d+-.*' | head -1) if [ -f ".aspire/settings.json" ]; then diff --git a/.github/workflows/polyglot-validation/test-typescript.sh b/.github/workflows/polyglot-validation/test-typescript.sh index ddaab0d05e5..2b4060da29c 100755 --- a/.github/workflows/polyglot-validation/test-typescript.sh +++ b/.github/workflows/polyglot-validation/test-typescript.sh @@ -21,11 +21,11 @@ cd "$WORK_DIR" # Initialize TypeScript AppHost echo "Creating TypeScript apphost project..." -aspire init -l typescript --non-interactive +aspire init -l typescript --non-interactive -d # Add Redis integration echo "Adding Redis integration..." -aspire add Aspire.Hosting.Redis --non-interactive 2>&1 || { +aspire add Aspire.Hosting.Redis --non-interactive -d 2>&1 || { echo "aspire add failed, manually updating settings.json..." PKG_VERSION=$(aspire --version | grep -oP '\d+\.\d+\.\d+-.*' | head -1) if [ -f ".aspire/settings.json" ]; then diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9cee2be9c82..4f027a856b9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -71,6 +71,13 @@ jobs: with: versionOverrideArg: ${{ inputs.versionOverrideArg }} + build_bundle: + name: Build bundle + needs: [build_packages, build_cli_archives] + uses: ./.github/workflows/build-bundle.yml + with: + versionOverrideArg: ${{ inputs.versionOverrideArg }} + integrations_test_lin: uses: ./.github/workflows/run-tests.yml name: Integrations Linux @@ -186,7 +193,7 @@ jobs: # Only run CLI E2E tests during PR builds if: ${{ github.event_name == 'pull_request' }} uses: ./.github/workflows/run-tests.yml - needs: [setup_for_tests_lin, build_packages, build_cli_archives] + needs: [setup_for_tests_lin, build_packages, build_cli_archives, build_bundle] strategy: fail-fast: false matrix: ${{ fromJson(needs.setup_for_tests_lin.outputs.cli_e2e_tests_matrix) }} @@ -202,7 +209,7 @@ jobs: polyglot_validation: name: Polyglot SDK Validation uses: ./.github/workflows/polyglot-validation.yml - needs: [build_packages, build_cli_archives] + needs: [build_packages, build_bundle] with: versionOverrideArg: ${{ inputs.versionOverrideArg }} @@ -236,6 +243,7 @@ jobs: runs-on: ubuntu-latest name: Final Test Results needs: [ + build_bundle, build_cli_archives, cli_e2e_tests, endtoend_tests, diff --git a/Aspire.slnx b/Aspire.slnx index c89c9907d9e..9a174df70c9 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -370,6 +370,7 @@ + @@ -493,6 +494,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 36db9b75c0e..77397aa81f6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -86,7 +86,10 @@ + + + diff --git a/docs/specs/bundle.md b/docs/specs/bundle.md new file mode 100644 index 00000000000..8762ac532ca --- /dev/null +++ b/docs/specs/bundle.md @@ -0,0 +1,1282 @@ +# Aspire Bundle - Self-Contained Distribution + +> **Status:** Draft Specification +> **Last Updated:** February 2026 + +This document specifies the **Aspire Bundle**, a self-contained distribution package that provides the Aspire CLI along with all runtime components (Dashboard, DCP) needed to run any Aspire application. + +## Table of Contents + +1. [Overview](#overview) +2. [Problem Statement](#problem-statement) +3. [Goals and Non-Goals](#goals-and-non-goals) +4. [Architecture](#architecture) +5. [Bundle Layout](#bundle-layout) +6. [Component Discovery](#component-discovery) +7. [NuGet Operations](#nuget-operations) +8. [Certificate Management](#certificate-management) +9. [AppHost Server](#apphost-server) +10. [CLI Integration](#cli-integration) +11. [Configuration](#configuration) +12. [Size and Distribution](#size-and-distribution) +13. [Security Considerations](#security-considerations) +14. [Build Process](#build-process) + +--- + +## Overview + +The Aspire Bundle is a platform-specific archive containing the Aspire CLI and all runtime components: + +- **Aspire CLI** (native AOT executable) +- **.NET Runtime** (for running managed components) +- **Pre-built AppHost Server** (for polyglot app hosts) +- **Aspire Dashboard** (no longer distributed via NuGet) +- **Developer Control Plane (DCP)** (no longer distributed via NuGet) +- **NuGet Helper Tool** (for package search and restore without SDK) +- **Dev-Certs Tool** (for HTTPS certificate management without SDK) + +**Key change**: DCP and Dashboard are now bundled with the CLI installation, not downloaded as NuGet packages. This applies to **all** Aspire applications, including .NET ones. This: + +- Eliminates large NuGet package downloads on first run +- Ensures version consistency between CLI and runtime components +- Simplifies the Aspire.Hosting SDK (no more MSBuild magic for DCP/Dashboard) +- Makes offline scenarios work reliably + +Users download a single archive (~200 MB compressed, ~577 MB on disk), extract it, and have everything needed to run any Aspire application. + +--- + +## Problem Statement + +Currently, Aspire has two distribution challenges: + +### For Polyglot App Hosts +The polyglot app host requires a globally-installed .NET SDK for: +1. **Dynamic Project Build**: The AppHost Server project is generated and built at runtime +2. **Package Operations**: `aspire add` uses `dotnet package search` +3. **Component Resolution**: DCP and Dashboard come from NuGet + +### For All Applications +DCP and Dashboard distribution via NuGet packages causes: +1. **Large first-run downloads**: ~100+ MB of NuGet packages +2. **Version skew**: Dashboard/DCP version can mismatch CLI version +3. **Complex MSBuild targets**: Magic in Aspire.Hosting.AppHost SDK +4. **Offline difficulties**: Needs NuGet cache or internet access + +--- + +## Goals and Non-Goals + +### Goals + +- **Zero .NET SDK dependency** for polyglot app host scenarios +- **Single download** containing all required runtime components +- **Unified DCP/Dashboard distribution** - bundled with CLI, not via NuGet +- **Offline capable** once the bundle is installed +- **Same functionality** as current approach, simpler distribution +- **Backward compatible** with existing SDK-based workflows + +### Non-Goals + +- Replacing the .NET SDK for .NET app host development +- Supporting `aspire new` for .NET project templates (requires SDK) +- Auto-updating the bundle (manual download for now) + +--- + +## Architecture + +### Component Interaction + +```text +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ASPIRE BUNDLE │ +│ aspire-{version}-{platform} │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ spawns ┌───────────────────────────────────┐ │ +│ │ aspire │ ───────────────▶│ .NET RUNTIME │ │ +│ │ (Native AOT) │ │ │ │ +│ │ │ │ • Runs AppHost Server │ │ +│ │ Commands: │ │ • Runs NuGet Helper Tool │ │ +│ │ • run │ │ • Hosts Dashboard │ │ +│ │ • add │ └───────────────────────────────────┘ │ +│ │ • new │ │ │ +│ │ • publish │ ▼ │ +│ └──────┬───────┘ ┌───────────────────────────────────┐ │ +│ │ │ APPHOST SERVER │ │ +│ │ JSON-RPC │ │ │ +│ │◀────────────────────▶│ • Aspire.Hosting.* assemblies │ │ +│ │ (socket) │ • RemoteHostServer endpoint │ │ +│ │ │ • Dynamic integration loading │ │ +│ │ └───────────────┬───────────────────┘ │ +│ │ │ │ +│ │ ┌───────────────────────┼───────────────────┐ │ +│ │ ▼ ▼ ▼ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ +│ │ │ DASHBOARD │ │ DCP │ │INTEGRATIONS│ │ +│ │ │ │ │ │ │ │ │ +│ │ │ dashboard/ │ │ dcp/ │ │~/.aspire/ │ │ +│ │ └─────────────┘ └─────────────┘ │ packages/ │ │ +│ │ └────────────┘ │ +│ │ ▲ │ +│ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ USER'S APPHOST │────────┘ │ +│ │ │ (TypeScript / Python / etc.) │ │ +│ │ │ │ │ +│ │ │ apphost.ts / app.py │ │ +│ │ └─────────────────────────────────────────┘ │ +│ │ │ +└─────────┴──────────────────────────────────────────────────────────────────┘ +``` + +### Execution Flow + +When a user runs `aspire run` with a TypeScript app host: + +1. **CLI reads project configuration** from `.aspire/settings.json` +2. **CLI discovers bundle layout** using priority-based resolution +3. **CLI downloads missing integrations** using the NuGet Helper Tool +4. **CLI generates `appsettings.json`** for the AppHost Server with integration list +5. **CLI starts AppHost Server** using the bundled .NET runtime +6. **CLI starts guest app host** (TypeScript) which connects via JSON-RPC +7. **AppHost Server orchestrates** containers, Dashboard, and DCP + +--- + +## Bundle Layout + +### Directory Structure + +```text +aspire-{version}-{platform}/ +│ +├── aspire[.exe] # Native AOT CLI (~25 MB) +│ +├── layout.json # Bundle metadata +│ +├── runtime/ # .NET 10 Runtime (~106 MB) +│ ├── dotnet[.exe] # Muxer executable +│ ├── LICENSE.txt +│ ├── host/ +│ │ └── fxr/{version}/ +│ │ └── hostfxr.{dll|so|dylib} +│ └── shared/ +│ ├── Microsoft.NETCore.App/{version}/ +│ │ └── *.dll +│ └── Microsoft.AspNetCore.App/{version}/ +│ └── *.dll +│ +├── aspire-server/ # Pre-built AppHost Server (~19 MB) +│ ├── aspire-server[.exe] # Single-file executable +│ └── appsettings.json # Default config +│ +├── dashboard/ # Aspire Dashboard (~42 MB) +│ ├── aspire-dashboard[.exe] # Single-file executable +│ ├── wwwroot/ +│ └── ... +│ +├── dcp/ # Developer Control Plane (~127 MB) +│ ├── dcp[.exe] # Native executable +│ └── ... +│ +└── tools/ # Helper tools (~5 MB) + ├── aspire-nuget/ # NuGet operations + │ ├── aspire-nuget[.exe] # Single-file executable + │ └── ... + │ + └── dev-certs/ # HTTPS certificate tool + ├── dotnet-dev-certs.dll + ├── dotnet-dev-certs.deps.json + └── dotnet-dev-certs.runtimeconfig.json +``` + +**Total Bundle Size:** +- **Unzipped:** ~323 MB +- **Zipped:** ~113 MB + +### layout.json Schema + +```json +{ + "version": "13.2.0", + "platform": "linux-x64", + "runtimeVersion": "10.0.0", + "components": { + "cli": "aspire", + "runtime": "runtime", + "apphostServer": "aspire-server", + "dashboard": "dashboard", + "dcp": "dcp", + "nugetHelper": "tools/aspire-nuget", + "devCerts": "tools/dev-certs" + }, + "builtInIntegrations": [] +} +``` + +--- + +## Component Discovery + +The CLI and `Aspire.Hosting` both need to discover DCP, Dashboard, and .NET runtime locations. During the transition period, different versions of CLI and Aspire.Hosting may be used together, so both components implement discovery with graceful fallback. + +### Discovery Priority + +Both CLI and Aspire.Hosting follow this priority order for DCP and Dashboard: + +1. **Environment variables** (`ASPIRE_DCP_PATH`, `ASPIRE_DASHBOARD_PATH`, `ASPIRE_RUNTIME_PATH`) - highest priority +2. **Disk discovery** - check for bundle layout next to the executable, then in the parent directory +3. **Assembly metadata** - NuGet package paths embedded at build time (Aspire.Hosting only) + +For .NET runtime resolution (used when launching Dashboard): + +1. **Environment variable** (`ASPIRE_RUNTIME_PATH`) - set by CLI for guest apphosts +2. **Disk discovery** - check for `runtime/` directory next to the app, then in the parent directory +3. **PATH fallback** - use `dotnet` from system PATH + +The parent directory check supports the installed layout where the CLI binary lives in `bin/` (`~/.aspire/bin/aspire`) while bundle components are siblings at the root (`~/.aspire/runtime/`, `~/.aspire/dashboard/`, etc.). + +### Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `ASPIRE_LAYOUT_PATH` | Root of the bundle | `/opt/aspire` | +| `ASPIRE_DCP_PATH` | DCP binaries location | `/opt/aspire/dcp` | +| `ASPIRE_DASHBOARD_PATH` | Dashboard executable path | `/opt/aspire/dashboard/aspire-dashboard` | +| `ASPIRE_RUNTIME_PATH` | Bundled .NET runtime directory (guest apphosts only) | `/opt/aspire/runtime` | +| `ASPIRE_INTEGRATION_LIBS_PATH` | Path to integration DLLs for aspire-server assembly resolution | `/home/user/.aspire/libs` | +| `ASPIRE_USE_GLOBAL_DOTNET` | Force SDK mode | `true` | +| `ASPIRE_REPO_ROOT` | Dev mode (Aspire repo path, DEBUG builds only) | `/home/user/aspire` | + +**Note:** `ASPIRE_RUNTIME_PATH` is only set for guest (polyglot) apphosts. .NET apphosts use the globally installed `dotnet`. + +**Note:** `ASPIRE_INTEGRATION_LIBS_PATH` is set by the CLI when running guest apphosts that require additional hosting integration packages (e.g., `Aspire.Hosting.Redis`). The aspire-server uses this path to resolve integration assemblies at runtime. + +### Transition Compatibility + +During the transition from NuGet-based to bundle-based distribution, these version combinations must work: + +#### Scenario 1: New CLI + New Aspire.Hosting + +```text +Bundle CLI ────► runs ────► .NET AppHost (new Aspire.Hosting) + │ │ + │ sets ASPIRE_DCP_PATH │ reads ASPIRE_DCP_PATH + │ sets ASPIRE_DASHBOARD_PATH│ reads ASPIRE_DASHBOARD_PATH + │ │ + └──────────────────────────►│ Uses bundled DCP/Dashboard ✓ +``` + +**Behavior**: CLI detects bundle, sets environment variables. Aspire.Hosting reads them first. + +#### Scenario 2: New CLI + Old Aspire.Hosting + +```text +Bundle CLI ────► runs ────► .NET AppHost (old Aspire.Hosting) + │ │ + │ sets ASPIRE_DCP_PATH │ ignores (doesn't check env vars) + │ sets ASPIRE_DASHBOARD_PATH│ + │ │ + │ │ Uses NuGet package paths ✓ +``` + +**Behavior**: CLI sets env vars, but old Aspire.Hosting doesn't read them. Falls back to assembly metadata (NuGet packages). Works correctly. + +#### Scenario 3: Old CLI + New Aspire.Hosting + +```text +Old CLI ────► runs ────► .NET AppHost (new Aspire.Hosting) + │ │ + │ (no env vars set) │ checks env vars → empty + │ │ does disk discovery → not found + │ │ uses assembly metadata (NuGet) ✓ +``` + +**Behavior**: No env vars set. New Aspire.Hosting tries disk discovery, doesn't find bundle, falls back to NuGet packages. + +#### Scenario 4: No CLI (direct `dotnet run`) + +```text +dotnet run ────► .NET AppHost (any Aspire.Hosting) + │ + │ checks env vars → empty + │ does disk discovery → not found (unless bundle installed) + │ uses assembly metadata (NuGet) ✓ +``` + +**Behavior**: Standard SDK workflow. Uses NuGet packages as always. + +#### Scenario 5: Bundle installed system-wide + +```text +dotnet run ────► .NET AppHost (new Aspire.Hosting) + │ + │ checks env vars → empty + │ does disk discovery → finds /opt/aspire/dcp ✓ + │ uses bundled DCP/Dashboard ✓ +``` + +**Behavior**: Even without CLI, if bundle is installed to a well-known location and AppHost is run from there, disk discovery finds it. + +### Why Both Need Discovery + +| Component | When it discovers | What it does | +|-----------|------------------|--------------| +| **CLI** | Before launching AppHost | Sets `ASPIRE_DCP_PATH`, `ASPIRE_DASHBOARD_PATH`, and `ASPIRE_RUNTIME_PATH` (guest only) env vars | +| **Aspire.Hosting** | At AppHost startup | Reads env vars OR does its own disk discovery OR uses NuGet | + +This dual-discovery approach ensures: +- **Forward compatibility**: New CLI works with old Aspire.Hosting +- **Backward compatibility**: Old CLI works with new Aspire.Hosting +- **Standalone operation**: Aspire.Hosting works even without CLI + +--- + +## NuGet Operations + +The bundle includes a managed NuGet Helper Tool that provides package search and restore functionality without requiring the .NET SDK. + +### NuGet Helper Commands + +```bash +# Search for packages +{runtime}/dotnet {tools}/aspire-nuget/aspire-nuget.dll search \ + --query "Aspire.Hosting" \ + --prerelease \ + --take 50 \ + --source https://api.nuget.org/v3/index.json \ + --format json + +# Restore packages +{runtime}/dotnet {tools}/aspire-nuget/aspire-nuget.dll restore \ + --package "Aspire.Hosting.Redis" \ + --version "13.2.0" \ + --framework net10.0 \ + --output ~/.aspire/packages + +# Create flat layout from restored packages (DLLs + XML doc files) +{runtime}/dotnet {tools}/aspire-nuget/aspire-nuget.dll layout \ + --assets ~/.aspire/packages/obj/project.assets.json \ + --output ~/.aspire/packages/libs \ + --framework net10.0 +``` + +### Search Output Format + +```json +{ + "packages": [ + { + "id": "Aspire.Hosting.Redis", + "version": "13.2.0", + "allVersions": ["13.1.0", "13.2.0"], + "description": "Redis hosting integration for .NET Aspire", + "authors": ["Microsoft"], + "source": "nuget.org", + "deprecated": false + } + ], + "totalHits": 42 +} +``` + +### Package Cache Structure + +```text +~/.aspire/packages/ +├── aspire.hosting.redis/ +│ └── 13.2.0/ +│ ├── aspire.hosting.redis.13.2.0.nupkg +│ └── lib/ +│ └── net10.0/ +│ └── Aspire.Hosting.Redis.dll +├── aspire.hosting.valkey/ +│ └── 13.2.0/ +│ └── ... +└── libs/ # Flat layout for probing + ├── Aspire.Hosting.Redis.dll + ├── Aspire.Hosting.Redis.xml # XML doc file (for IntelliSense/MCP) + ├── Aspire.Hosting.Valkey.dll + ├── Aspire.Hosting.Valkey.xml + └── ... +``` + +--- + +## Certificate Management + +The bundle includes the `dotnet-dev-certs` tool for HTTPS certificate management. This enables polyglot apphosts to configure HTTPS certificates without requiring a globally-installed .NET SDK. + +### Dev-Certs Tool Usage + +```bash +# Check certificate trust status (machine-readable output) +{runtime}/dotnet {tools}/dev-certs/dotnet-dev-certs.dll https --check --trust + +# Trust the development certificate (requires elevation on some platforms) +{runtime}/dotnet {tools}/dev-certs/dotnet-dev-certs.dll https --trust +``` + +### Certificate Tool Abstraction + +The CLI uses an `ICertificateToolRunner` abstraction to support both bundle and SDK modes: + +| Mode | Implementation | Usage | +|------|----------------|-------| +| Bundle | `BundleCertificateToolRunner` | Uses bundled runtime + dev-certs.dll | +| SDK | `SdkCertificateToolRunner` | Uses `dotnet dev-certs` from global SDK | + +The appropriate implementation is selected via DI based on whether a bundle layout is detected: + +```csharp +services.AddSingleton(sp => +{ + var layout = sp.GetService(); + var devCertsPath = layout?.GetDevCertsDllPath(); + + if (devCertsPath is not null && File.Exists(devCertsPath)) + { + return new BundleCertificateToolRunner(layout!); + } + + return new SdkCertificateToolRunner(sp.GetRequiredService()); +}); +``` + +--- + +## AppHost Server + +### Pre-built vs Dynamic Mode + +The bundle includes a pre-built AppHost Server with core hosting only (no integrations). All integrations are downloaded on-demand: + +| Condition | Mode | Description | +|-----------|------|-------------| +| Bundle detected | **Pre-built + Dynamic Loading** | Use pre-built server, download integrations as needed | +| No bundle detected | **Dynamic** | Generate and build project (requires SDK) | + +### Integration Download Flow + +When a project references integrations (e.g., `Aspire.Hosting.Redis`): + +1. CLI reads `.aspire/settings.json` for package list +2. CLI checks local cache (`~/.aspire/packages/`) +3. Missing packages are downloaded via NuGet Helper +4. Packages are extracted to flat layout for assembly loading +5. AppHost Server loads integration assemblies at startup + +### Pre-built Mode Execution + +```bash +# CLI spawns the pre-built AppHost Server +{aspire-server}/aspire-server \ + --project {user-project-path} \ + --socket {socket-path} +``` + +### Dynamic Integration Loading + +When the user's project requires integrations not included in the bundle: + +1. CLI downloads missing packages using NuGet Helper to a project-specific cache +2. AppHost Server receives the paths to restored assemblies via command line arguments +3. Assemblies are loaded using the standard .NET assembly loading mechanism + +--- + +## CLI Integration + +### Transparent Mode Detection + +The CLI automatically detects whether to use bundle or SDK mode based on its execution context: + +1. **Bundle mode**: CLI is running from within a bundle layout (detected via relative paths) +2. **SDK mode**: CLI is installed via `dotnet tool` or running standalone + +No user configuration or flags are required - the experience is identical regardless of installation method. + +### Self-Update Command + +When running from a bundle, `aspire update --self` updates the bundle to the latest version: + +```bash +# Update the bundle to the latest version +aspire update --self + +# Check for updates without installing +aspire update --self --check +``` + +The update process: +1. Queries GitHub releases API for latest version +2. Downloads the appropriate platform-specific archive +3. Extracts to a temporary location +4. Replaces the current bundle (preserving user config) +5. Restarts the CLI if needed + +When running via `dotnet tool`, `aspire update --self` displays instructions to use `dotnet tool update`. + +### Mode Detection Algorithm + +```csharp +bool ShouldUseBundleMode() +{ + // Check if explicitly disabled via environment variable + var useSdk = Environment.GetEnvironmentVariable("ASPIRE_USE_GLOBAL_DOTNET"); + if (string.Equals(useSdk, "true", StringComparison.OrdinalIgnoreCase)) + return false; + + // Auto-detect: check if CLI is running from within a bundle layout + var layoutPath = DiscoverRelativeLayout(); + if (layoutPath != null && ValidateLayout(layoutPath)) + return true; + + // Fall back to SDK mode + return false; +} +``` + +### Environment Variable Override + +For advanced scenarios (testing, debugging), a single environment variable can force SDK mode: + +| Variable | Description | +|----------|-------------| +| `ASPIRE_USE_GLOBAL_DOTNET=true` | Force SDK mode even when running from bundle | + +This is not documented for end users - it's for internal testing and edge cases only. + +--- + +## Installation + +### One-Line Install Scripts + +**Linux/macOS (bash):** +```bash +curl -fsSL https://aka.ms/install-aspire.sh | bash +``` + +**Windows (PowerShell):** +```powershell +irm https://aka.ms/install-aspire.ps1 | iex +``` + +### Script Behavior + +The install scripts: +1. Detect the current platform (OS + architecture) +2. Query GitHub releases for the latest bundle version +3. Download the appropriate archive +4. Extract to the default location (`~/.aspire/`) +5. Move CLI binary to `bin/` subdirectory for consistent PATH +6. Add `~/.aspire/bin` to PATH (with user confirmation) +7. Verify installation with `aspire --version` + +### Installed Layout + +The bundle installs components as siblings under `~/.aspire/`, with the CLI binary placed in `bin/` so that both bundle and CLI-only installs share the same PATH entry: + +```text +~/.aspire/ +├── bin/ # CLI binary (shared path for both install methods) +│ └── aspire # - Native AOT CLI executable (bundle install) +│ # - Or SDK-based CLI (CLI-only install) +│ +├── layout.json # Bundle metadata (present only for bundle install) +│ +├── runtime/ # Bundled .NET runtime +│ └── dotnet +│ +├── dashboard/ # Pre-built Dashboard +│ └── Aspire.Dashboard +│ +├── dcp/ # Developer Control Plane +│ └── dcp +│ +├── aspire-server/ # Pre-built AppHost Server (polyglot) +│ └── aspire-server +│ +├── tools/ +│ └── aspire-nuget/ # NuGet operations without SDK +│ └── aspire-nuget +│ +├── hives/ # NuGet package hives (preserved across installs) +│ └── pr-{number}/ +│ └── packages/ +│ +└── globalsettings.json # Global CLI settings (preserved across installs) +``` + +**Key behaviors:** +- The CLI lives at `~/.aspire/bin/aspire` regardless of install method +- Bundle components (`runtime/`, `dashboard/`, `dcp/`, etc.) are siblings at the `~/.aspire/` root +- NuGet hives and settings are preserved across installations +- `LayoutDiscovery` finds the bundle by checking the CLI's parent directory for components + +### Script Options + +**Linux/macOS:** +```bash +# Install specific version +curl -fsSL https://aka.ms/install-aspire.sh | bash -s -- --version 13.2.0 + +# Install to custom location +curl -fsSL https://aka.ms/install-aspire.sh | bash -s -- --install-dir /opt/aspire + +# Skip PATH modification +curl -fsSL https://aka.ms/install-aspire.sh | bash -s -- --no-path +``` + +**Windows:** +```powershell +# Install specific version +irm https://aka.ms/install-aspire.ps1 | iex -Args '--version', '13.2.0' + +# Install to custom location +irm https://aka.ms/install-aspire.ps1 | iex -Args '--install-dir', 'C:\aspire' +``` + +### Default Installation Locations + +| Component | Linux/macOS | Windows | +|-----------|-------------|---------| +| CLI (bundle or CLI-only) | `~/.aspire/bin/aspire` | `%USERPROFILE%\.aspire\bin\aspire.exe` | +| NuGet Hives | `~/.aspire/hives/` | `%USERPROFILE%\.aspire\hives\` | +| Settings | `~/.aspire/globalsettings.json` | `%USERPROFILE%\.aspire\globalsettings.json` | + +### PR Build Installation + +For testing PR builds before they are merged: + +**Bundle from PR (self-contained):** +```bash +# Linux/macOS +./eng/scripts/get-aspire-cli-bundle-pr.sh 1234 + +# Windows +.\eng\scripts\get-aspire-cli-bundle-pr.ps1 -PRNumber 1234 +``` + +**Existing CLI from PR (requires SDK):** +```bash +# Linux/macOS +./eng/scripts/get-aspire-cli-pr.sh 1234 + +# Windows +.\eng\scripts\get-aspire-cli-pr.ps1 -PRNumber 1234 +``` + +Both bundle and CLI-only PR scripts also download NuGet package artifacts (`built-nugets` and `built-nugets-for-{rid}`) and install them as a NuGet hive at `~/.aspire/hives/pr-{N}/packages/`. This enables `aspire new` and `aspire add` to resolve PR-built package versions when the channel is set to `pr-{N}`. + +--- + +## Configuration + +Configuration is primarily done through environment variables. No user-editable configuration files are required. + +### Environment Variable Precedence + +```text +ASPIRE_* env vars > relative path auto-detect > assembly metadata (NuGet packages) +``` + +### Integration Cache + +Downloaded integration packages are cached in: +- Linux/macOS: `~/.aspire/packages/` +- Windows: `%LOCALAPPDATA%\Aspire\packages\` + +--- + +## Size and Distribution + +### Size Estimates (Windows x64) + +| Component | On Disk | Zipped | +|-----------|---------|--------| +| DCP (platform-specific) | ~286 MB | ~100 MB | +| .NET 10 Runtime (incl. ASP.NET Core) | ~200 MB | ~70 MB | +| Dashboard (framework-dependent) | ~43 MB | ~15 MB | +| CLI (native AOT) | ~22 MB | ~10 MB | +| AppHost Server (core only) | ~21 MB | ~8 MB | +| NuGet Helper (aspire-nuget) | ~5 MB | ~2 MB | +| Dev-certs Tool | ~0.1 MB | ~0.05 MB | +| **Total** | **~577 MB** | **~204 MB** | + +*AppHost Server includes core hosting only - all integrations are downloaded on-demand.* +*Dashboard is framework-dependent (not self-contained), sharing the bundled .NET runtime.* +*Sizes vary by platform. Linux tends to be smaller than Windows.* + +### Distribution Formats + +| Platform | Format | Filename | +|----------|--------|----------| +| Windows x64 | ZIP | `aspire-13.2.0-win-x64.zip` | +| Linux x64 | tar.gz | `aspire-13.2.0-linux-x64.tar.gz` | +| Linux ARM64 | tar.gz | `aspire-13.2.0-linux-arm64.tar.gz` | +| macOS x64 | tar.gz | `aspire-13.2.0-osx-x64.tar.gz` | +| macOS ARM64 | tar.gz | `aspire-13.2.0-osx-arm64.tar.gz` | + +### Download Locations + +- **GitHub Releases**: `https://github.com/dotnet/aspire/releases` +- **aspire.dev**: Direct download links on documentation site + +--- + +## Development Mode + +When developing Aspire itself, the bundle mode is **not** used even if `ASPIRE_REPO_ROOT` is set. This ensures developers can: + +1. Make changes to `Aspire.Hosting.*` assemblies +2. Use project references instead of pre-built binaries +3. See their changes reflected immediately without rebuilding a bundle + +### How It Works + +The layout discovery system detects development mode and creates a "dev layout" with `Version = "dev"`. When the CLI detects a dev layout, it falls back to the standard SDK-based flow: + +```csharp +if (layout.IsDevLayout) +{ + // Continue using SDK mode with project references + return false; +} +``` + +### Environment Variables for Development + +| Variable | Description | +|----------|-------------| +| `ASPIRE_REPO_ROOT` | Path to local Aspire repo (triggers dev layout detection) | +| `ASPIRE_USE_GLOBAL_DOTNET=true` | Force SDK mode, skip bundle detection entirely | + +### Testing Bundle Infrastructure + +To test bundle infrastructure during development without affecting the normal dev workflow: + +1. Build the aspire-server standalone: `dotnet build src/Aspire.Hosting.RemoteHost` +2. Create a test bundle layout manually with the built artifacts +3. Set `ASPIRE_LAYOUT_PATH` to point to your test layout +4. The dev layout detection only activates when `ASPIRE_REPO_ROOT` is set + +--- + +## Security Considerations + +### Package Signing + +| Platform | Mechanism | +|----------|-----------| +| Windows | Authenticode signature on CLI executable | +| macOS | Notarization + code signing | +| Linux | GPG signature file (`.asc`) | + +### Checksum Verification + +Each release includes SHA256 checksums: + +```text +aspire-13.2.0-linux-x64.tar.gz.sha256 +aspire-13.2.0-win-x64.zip.sha256 +``` + +### Runtime Isolation + +The bundled .NET runtime is isolated from any globally-installed .NET: + +- `DOTNET_ROOT` is set to the bundle's runtime directory +- `DOTNET_MULTILEVEL_LOOKUP=0` disables global probing +- No modification to system PATH or environment + +### NuGet Security + +- Package downloads use HTTPS only +- Package signatures are verified when available +- Authenticated feeds require explicit credential configuration + +--- + +## Backward Compatibility + +A core design principle of the bundle feature is **complete backward compatibility**. Users with existing workflows must not experience any breaking changes. + +### Compatibility Requirements + +1. **Existing SDK-based workflows continue to work unchanged** + - If the .NET SDK is installed globally, all existing commands work identically + - No new CLI flags required to use existing functionality + - `aspire new`, `aspire add`, `aspire run` behave the same as before + +2. **Dotnet tool installation remains supported** + - `dotnet tool install -g Aspire.Cli` continues to work + - `aspire update --self` shows `dotnet tool update` instructions when running as a tool + +3. **Bundle mode is transparent** + - No user action required to switch between bundle and SDK mode + - CLI auto-detects which mode to use based on installation location + - All commands produce the same user-visible output regardless of mode + +### Detection Logic + +The CLI determines its execution mode using this priority order: + +```text +1. ASPIRE_USE_GLOBAL_DOTNET=true → Force SDK mode (for testing/debugging) +2. ASPIRE_REPO_ROOT is set → Dev mode (use SDK with project refs) +3. Valid bundle layout found → Bundle mode +4. .NET SDK available globally → SDK mode +5. Neither available → Error with installation instructions +``` + +### API Compatibility + +Changes to internal CLI classes maintain backward compatibility through: + +1. **New dependencies are optional or have sensible defaults** + ```csharp + // IBundleDownloader is nullable - if not registered, bundle update is skipped + private readonly IBundleDownloader? _bundleDownloader; + + // ILayoutDiscovery always returns null if no layout found - SDK mode continues + var layout = _layoutDiscovery.DiscoverLayout(); + if (layout is null) { /* fall back to SDK mode */ } + ``` + +2. **DI registration is additive** + - New services are registered alongside existing ones + - Tests using full DI container continue to work + - Tests mocking specific services are unaffected + +3. **Graceful degradation** + - If bundle components are missing, fall back to SDK + - If NuGetHelper is unavailable, fall back to `dotnet` commands + - Error messages guide users to resolution + +### Test Compatibility + +Tests continue to work because: + +1. **Integration tests use the full DI container** + - New services (`ILayoutDiscovery`, `IBundleDownloader`) are registered + - Tests discover no layout → SDK mode is used → existing behavior + +2. **Unit tests mock at service boundaries** + - Tests mocking `IProjectLocator`, `IPackagingService` etc. are unaffected + - New services can be mocked independently if needed + +3. **Test helpers register all services** + - `CliTestHelper.CreateServiceCollection()` uses the same registration as production + - No test-specific configuration needed for backward compatibility + +### Environment Variable Summary + +| Variable | Purpose | Effect | +|----------|---------|--------| +| `ASPIRE_USE_GLOBAL_DOTNET=true` | Force SDK mode | Bypasses bundle detection entirely | +| `ASPIRE_REPO_ROOT` | Development mode | Uses SDK with project references | +| `ASPIRE_LAYOUT_PATH` | Bundle location | Overrides auto-detection | +| `ASPIRE_DCP_PATH` | DCP override | Works in both modes | +| `ASPIRE_DASHBOARD_PATH` | Dashboard override | Works in both modes | +| `ASPIRE_RUNTIME_PATH` | .NET runtime override | For guest apphosts only | + +### Migration Path + +Users migrating from SDK-based installation to bundle: + +1. **No migration required** - existing projects work with bundle CLI +2. **Package references unchanged** - same NuGet packages, same versions +3. **Configuration preserved** - `~/.aspire/` settings continue to work +4. **Can switch back anytime** - reinstall via `dotnet tool` to return to SDK mode + +### Version Compatibility + +A key design principle is that the CLI and AppHost can be updated independently: + +#### CLI Updated, AppHost Unchanged + +When using a newer CLI with an older AppHost (NuGet packages): + +1. **Protocol stability** - The JSON-RPC protocol between CLI and AppHost is versioned +2. **Feature detection** - CLI queries AppHost capabilities before using new features +3. **Graceful fallback** - Unknown features are skipped, core functionality preserved +4. **Package resolution** - NuGet packages from older Aspire versions continue to work + +```text +CLI v10.0 ────► AppHost (Aspire.Hosting v9.x) + │ + └── Uses SDK mode to build project with v9.x packages + Works identically to v9.x CLI +``` + +#### AppHost Updated, CLI Unchanged + +When using an older CLI with newer AppHost packages: + +1. **Forward compatibility** - Older CLI can run newer AppHost projects +2. **New features unavailable** - Features requiring CLI support won't work +3. **Clear error messages** - When incompatibility detected, show upgrade guidance +4. **Core functionality works** - `aspire run`, `aspire add` continue to function + +```text +CLI v9.x ────► AppHost (Aspire.Hosting v10.x) + │ + └── Builds and runs project + New v10 features that need CLI support are unavailable + User sees: "Upgrade CLI for new features: aspire update --self" +``` + +#### Bundle Updated, SDK-based AppHost + +When using bundle CLI with SDK-installed Aspire packages: + +1. **Mode detection** - Bundle CLI detects SDK is available +2. **SDK mode activation** - Uses `dotnet build` for AppHost, not pre-built server +3. **Identical behavior** - Works exactly like dotnet-tool-installed CLI +4. **No conflicts** - Bundle runtime isolated from global .NET + +```text +Bundle CLI ────► AppHost (via SDK) + │ + └── Detects .NET SDK is installed + Falls back to SDK mode + Uses dotnet build, not pre-built server +``` + +#### Version Mismatch Handling + +```csharp +// CLI checks AppHost protocol version +var serverVersion = await appHost.GetProtocolVersionAsync(); +if (serverVersion < MinSupportedVersion) +{ + // Show upgrade message but continue if possible + InteractionService.DisplayMessage("warning", + $"AppHost uses protocol v{serverVersion}, CLI expects v{MinSupportedVersion}+. " + + "Some features may not work. Consider updating packages."); +} +``` + +--- + +## Future Considerations + +### Out of Scope for Initial Release + +- **Auto-update mechanism**: Users manually download new versions +- **Minimal bundle variant**: Full bundle only, no on-demand component download +- **Template creation**: `aspire new` for .NET templates still requires SDK + +### Potential Enhancements + +1. **Modular bundles**: Base + optional integration packs +2. **CDN distribution**: Faster downloads via global CDN +3. **Update command**: `aspire update --self` for bundle updates (implemented) +4. **Bundle compression**: Support for zstd in ZIP format (better ratios) +5. **Single-file runtime bundle**: Consolidate runtime folder into single file (see below) + +### Single-File Runtime Bundle (Future Option) + +The current bundle layout includes a `runtime/` folder (~106 MB) containing the .NET runtime: + +```text +runtime/ +├── dotnet.exe # Host/muxer +├── host/fxr/{version}/ # hostfxr +└── shared/ + ├── Microsoft.NETCore.App/{version}/ + └── Microsoft.AspNetCore.App/{version}/ +``` + +A future enhancement could consolidate this into a **single-file runtime binary** using the `Microsoft.NET.HostModel.Bundle` API. This would: + +1. Create a single `dotnet-aspire` executable containing: + - The apphost (native executable stub) + - hostfxr and hostpolicy (statically linked or bundled) + - Microsoft.NETCore.App framework assemblies + - Microsoft.AspNetCore.App framework assemblies + +2. Use .NET's bundle format which: + - Memory-maps managed assemblies directly from the bundle (no extraction) + - Extracts only native libraries to a temp directory when needed + - Caches extracted files across runs + +#### Proposed Implementation + +**Step 1: Create a minimal host application** + +```csharp +// tools/AspireRuntimeHost/Program.cs +// Minimal app that forwards execution to the target DLL +public class Program +{ + public static int Main(string[] args) + { + // The actual DLL to run is passed as first argument + // This app just provides the runtime context + return 0; + } +} +``` + +**Step 2: Publish as single-file with shared frameworks** + +```xml + + + Exe + net10.0 + true + true + true + true + + +``` + +**Step 3: Use the Bundler API programmatically** + +```csharp +using Microsoft.NET.HostModel.Bundle; + +var bundler = new Bundler( + hostName: "dotnet-aspire", + outputDir: outputPath, + options: new BundleOptions( + targetOS: targetOS, + targetArch: targetArch, + enableCompression: true)); + +// Add framework assemblies +foreach (var dll in frameworkAssemblies) +{ + bundler.AddToBundle(dll, BundlerFileType.Assembly); +} + +// Add native libraries +foreach (var native in nativeLibs) +{ + bundler.AddToBundle(native, BundlerFileType.NativeBinary); +} + +// Generate the bundle +bundler.GenerateBundle(); +``` + +#### Bundle Structure + +The resulting `dotnet-aspire` binary would have this internal structure: + +```text +dotnet-aspire (single file, ~100-120 MB) +├── [AppHost Header] +├── [hostfxr + hostpolicy code] +├── [Bundle Manifest] +│ ├── File count, offsets, sizes +│ └── Compression metadata +├── [Framework Assemblies - Memory Mapped] +│ ├── System.Runtime.dll +│ ├── System.Collections.dll +│ ├── Microsoft.AspNetCore.*.dll +│ └── ... (~800 assemblies) +└── [Native Libraries - Extracted on demand] + ├── coreclr.dll/libcoreclr.so + ├── clrjit.dll/libclrjit.so + └── System.*.Native.dll/so +``` + +#### Runtime Behavior + +1. **First run**: Native libraries extracted to `~/.aspire/runtime-cache/{bundle-hash}/` +2. **Subsequent runs**: Cache hit, no extraction needed +3. **Managed code**: Memory-mapped directly from bundle, no disk I/O + +#### Usage in CLI + +```csharp +// LayoutProcessRunner would use the single-file runtime +public async Task RunAsync(string dllPath, string[] args) +{ + var runtimePath = _layout.GetSingleFileRuntime(); // "dotnet-aspire" + + var process = new Process(); + process.StartInfo.FileName = runtimePath; + process.StartInfo.ArgumentList.Add("exec"); + process.StartInfo.ArgumentList.Add(dllPath); + // ... +} +``` + +#### Estimated Sizes + +| Component | Current | Single-File | +|-----------|---------|-------------| +| runtime/ folder | 106 MB | - | +| dotnet-aspire binary | - | ~100-120 MB | +| **Net change** | - | ~0-15 MB smaller | + +The main benefit is **simplicity** (one file vs folder tree) rather than size reduction. + +#### Trade-offs + +**Pros:** +- Single file instead of ~200 files in runtime/ folder +- Simpler xcopy deployment +- Managed assemblies load faster (memory-mapped) +- No need to manage runtime folder structure + +**Cons:** +- Native libraries still extract to disk (required by OS loader) +- More complex build process +- Harder to debug/inspect +- Updates require full binary replacement + +#### Implementation Effort + +- **Low**: Self-extracting archive (compress runtime/, extract on first use) +- **Medium**: Use existing single-file publish infrastructure +- **High**: Custom Bundler integration with proper framework resolution + +#### Dependencies + +- `Microsoft.NET.HostModel` NuGet package (contains Bundler API) +- Understanding of deps.json and runtimeconfig.json generation +- Platform-specific native library handling + +--- + +## Implementation Status + +This section tracks the implementation progress of the bundle feature. + +### Completed + +- [x] **Specification document** - This document (`docs/specs/bundle.md`) +- [x] **Layout configuration classes** - `src/Aspire.Cli/Layout/LayoutConfiguration.cs` +- [x] **Layout discovery service** - `src/Aspire.Cli/Layout/LayoutDiscovery.cs` +- [x] **Layout process runner** - `src/Aspire.Cli/Layout/LayoutProcessRunner.cs` +- [x] **Bundle NuGet service** - `src/Aspire.Cli/NuGet/BundleNuGetService.cs` +- [x] **NuGet Helper tool** - `src/Aspire.Cli.NuGetHelper/` + - [x] Search command (NuGet v3 HTTP API) + - [x] Restore command (NuGet RestoreRunner) + - [x] Layout command (flat DLL + XML doc layout from project.assets.json) +- [x] **Layout services registered in DI** - `src/Aspire.Cli/Program.cs` +- [x] **Pre-built AppHost server class** - `src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs` +- [x] **DCP/Dashboard/Runtime env var support** - `src/Aspire.Hosting/Dcp/DcpOptions.cs`, `src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs` + - `ASPIRE_DCP_PATH` environment variable + - `ASPIRE_DASHBOARD_PATH` environment variable + - `ASPIRE_RUNTIME_PATH` environment variable (guest apphosts only) +- [x] **Shared discovery logic** - `src/Shared/BundleDiscovery.cs` + - `TryDiscoverDcpFromEntryAssembly()` / `TryDiscoverDcpFromDirectory()` + - `TryDiscoverDashboardFromEntryAssembly()` / `TryDiscoverDashboardFromDirectory()` + - `TryDiscoverRuntimeFromEntryAssembly()` / `TryDiscoverRuntimeFromDirectory()` + - `GetDotNetExecutablePath()` - env → disk → PATH fallback +- [x] **GuestAppHostProject bundle mode integration** - `src/Aspire.Cli/Projects/GuestAppHostProject.cs` + - Automatic bundle mode detection via `TryGetBundleLayout()` + - `PrepareSdkModeAsync()` for traditional SDK-based server build + - `PrepareBundleModeAsync()` for pre-built server from bundle +- [x] **Standalone aspire-server project** - `src/Aspire.Hosting.RemoteHost/` + - Pre-built server for bundle distribution + - Framework-dependent deployment (uses bundled runtime) +- [x] **Certificate management** - `src/Aspire.Cli/Certificates/` + - `ICertificateToolRunner` abstraction + - `BundleCertificateToolRunner` - uses bundled runtime + dev-certs.dll + - `SdkCertificateToolRunner` - uses global `dotnet dev-certs` +- [x] **Bundle build tooling** - `tools/CreateLayout/` + - Downloads .NET SDK and extracts runtime + dev-certs + - Copies DCP, Dashboard, aspire-server, NuGetHelper + - Generates layout.json metadata + - Enables RollForward=Major for all managed tools +- [x] **Installation scripts** - `eng/scripts/get-aspire-cli-bundle-pr.sh`, `eng/scripts/get-aspire-cli-bundle-pr.ps1` + - Downloads bundle archive from PR build artifacts + - Extracts to `~/.aspire/` with CLI in `bin/` subdirectory + - Downloads and installs NuGet hive packages for PR channel + +### In Progress + +- [ ] Integrate NuGet service with AddCommand + +### Pending + +- [ ] Self-update command (`aspire update --self`) - BundleDownloader exists but not wired +- [ ] Multi-platform build workflow (GitHub Actions) + +### Key Files + +| File | Purpose | +|------|---------| +| `src/Aspire.Cli/Layout/LayoutConfiguration.cs` | Configuration classes for layout structure | +| `src/Aspire.Cli/Layout/LayoutDiscovery.cs` | Priority-based layout discovery (env > config > relative) | +| `src/Aspire.Cli/Layout/LayoutProcessRunner.cs` | Run managed DLLs via layout's .NET runtime | +| `src/Aspire.Cli/NuGet/BundleNuGetService.cs` | NuGet operations wrapper for bundle mode | +| `src/Aspire.Cli.NuGetHelper/` | Managed tool for search/restore/layout | +| `src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs` | Bundle-mode server runner | +| `src/Aspire.Cli/Projects/GuestAppHostProject.cs` | Main polyglot handler with bundle/SDK mode switching | +| `src/Aspire.Hosting/Dcp/DcpOptions.cs` | DCP/Dashboard path resolution with env var support | +| `src/Aspire.Cli/Certificates/ICertificateToolRunner.cs` | Certificate tool abstraction | +| `src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs` | Bundled dev-certs runner | +| `src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs` | SDK-based dev-certs runner | +| `tools/CreateLayout/Program.cs` | Bundle build tool | + +--- + +## Build Process + +The bundle is built using the `tools/CreateLayout` tool, which assembles all components into the final bundle layout. + +### SDK Download Approach + +The bundle's .NET runtime is extracted from the official .NET SDK, which provides several advantages: + +1. **Single download**: The SDK contains the runtime, ASP.NET Core framework, and dev-certs tool +2. **Version consistency**: All components come from the same SDK release +3. **Official source**: Direct from Microsoft's build infrastructure + +```text +SDK download (~200 MB) +├── dotnet.exe → runtime/dotnet.exe +├── host/ → runtime/host/ +├── shared/Microsoft.NETCore.App/10.0.x/ → runtime/shared/Microsoft.NETCore.App/ +├── shared/Microsoft.AspNetCore.App/10.0.x/ → runtime/shared/Microsoft.AspNetCore.App/ +├── sdk/10.0.x/DotnetTools/dotnet-dev-certs → tools/dev-certs/ +└── (discard: sdk/, templates/, packs/, etc.) +``` + +The SDK version is discovered dynamically from `https://builds.dotnet.microsoft.com/dotnet/release-metadata/releases-index.json`. + +### RollForward Configuration + +All managed tools in the bundle are configured with `rollForward: Major` in their runtimeconfig.json files. This allows: + +- Tools built for .NET 8.0 or 9.0 to run on the bundled .NET 10+ runtime +- Maximum compatibility with older Aspire.Hosting packages +- Simpler bundle maintenance (single runtime version) + +The CreateLayout tool automatically patches all `*.runtimeconfig.json` files: + +```json +{ + "runtimeOptions": { + "rollForward": "Major", + "framework": { + "name": "Microsoft.AspNetCore.App", + "version": "8.0.0" + } + } +} +``` + +### Build Steps + +1. **Download .NET SDK** for the target platform +2. **Extract runtime components** (muxer, host, shared frameworks) +3. **Extract dev-certs tool** from `sdk/*/DotnetTools/dotnet-dev-certs/` +4. **Build and copy managed tools** (aspire-server, aspire-dashboard, NuGetHelper) +5. **Download and copy DCP** binaries +6. **Patch runtimeconfig.json files** to enable RollForward=Major +7. **Generate layout.json** with component metadata +8. **Create archive** (ZIP for Windows, tar.gz for Unix) diff --git a/eng/Bundle.proj b/eng/Bundle.proj new file mode 100644 index 00000000000..560501bbc72 --- /dev/null +++ b/eng/Bundle.proj @@ -0,0 +1,128 @@ + + + + + + + + + Debug + + + $(TargetRids) + win-x64 + osx-arm64 + linux-x64 + + + $([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)', '..')) + $(RepoRoot)artifacts\ + $(ArtifactsDir)log\$(Configuration)\ + $(ArtifactsDir)bundle\$(TargetRid)\ + + + $(RepoRoot)src\Aspire.Cli\Aspire.Cli.csproj + $(RepoRoot)src\Aspire.Cli.NuGetHelper\Aspire.Cli.NuGetHelper.csproj + $(RepoRoot)src\Aspire.Hosting.RemoteHost\Aspire.Hosting.RemoteHost.csproj + $(RepoRoot)src\Aspire.Dashboard\Aspire.Dashboard.csproj + $(RepoRoot)src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj + $(RepoRoot)tools\CreateLayout\CreateLayout.csproj + + + + $(VersionPrefix)-$(VersionSuffix) + $(VersionPrefix)-dev + + + <_BinlogArg Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_CliBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishCli.binlog + + + + + + + + <_NuGetHelperBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishNuGetHelper.binlog + <_AppHostServerBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishAppHostServer.binlog + <_DashboardBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishDashboard.binlog + + + + + + + + + + + <_DcpBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)RestoreDcp.binlog + + + + + + + + + <_BundleOutputDirArg>$(BundleOutputDir.TrimEnd('\').TrimEnd('/')) + <_ArtifactsDirArg>$(ArtifactsDir.TrimEnd('\').TrimEnd('/')) + <_CreateLayoutArgs>--output "$(_BundleOutputDirArg)" --artifacts "$(_ArtifactsDirArg)" --rid $(TargetRid) --bundle-version $(BundleVersion) --runtime-version $(BundleRuntimeVersion) --archive --verbose --download-runtime + + + + + + + diff --git a/eng/Versions.props b/eng/Versions.props index a11eff37881..a2d25ba628a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -15,6 +15,8 @@ 8.0.415 9.0.306 + + 10.0.102 3.1.0 1.21.0 3.1.0 diff --git a/eng/build.ps1 b/eng/build.ps1 index b7e95f0e6ca..9fab54f5769 100644 --- a/eng/build.ps1 +++ b/eng/build.ps1 @@ -9,6 +9,8 @@ Param( [switch]$testnobuild, [ValidateSet("x86","x64","arm","arm64")][string[]][Alias('a')]$arch = @([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant()), [switch]$mauirestore, + [switch]$bundle, + [string]$runtimeVersion, [Parameter(ValueFromRemainingArguments=$true)][String[]]$properties ) @@ -46,6 +48,8 @@ function Get-Help() { Write-Host "Libraries settings:" Write-Host " -testnobuild Skip building tests when invoking -test." Write-Host " -buildExtension Build the VS Code extension." + Write-Host " -bundle Build the self-contained bundle (CLI + Runtime + Dashboard + DCP)." + Write-Host " -runtimeVersion .NET runtime version for bundle (default: from eng/Versions.props RuntimeVersion)." Write-Host "" Write-Host "Command-line arguments not listed above are passed through to MSBuild." @@ -102,6 +106,8 @@ foreach ($argument in $PSBoundParameters.Keys) "testnobuild" { $arguments += " /p:VSTestNoBuild=true" } "buildExtension" { $arguments += " /p:BuildExtension=true" } "mauirestore" { $arguments += " -restoreMaui" } + "bundle" { } # Handled after main build + "runtimeVersion" { } # Handled after main build default { $arguments += " /p:$argument=$($PSBoundParameters[$argument])" } } } @@ -112,4 +118,65 @@ if ($env:TreatWarningsAsErrors -eq 'false') { Write-Host "& `"$PSScriptRoot/common/build.ps1`" $arguments" Invoke-Expression "& `"$PSScriptRoot/common/build.ps1`" $arguments" -exit $LASTEXITCODE +$buildExitCode = $LASTEXITCODE + +if ($buildExitCode -ne 0) { + exit $buildExitCode +} + +# Build bundle if requested +if ($bundle) { + Write-Host "" + Write-Host "Building bundle via MSBuild..." + Write-Host "" + + $repoRoot = Split-Path $PSScriptRoot -Parent + $config = if ($configuration) { $configuration } else { "Debug" } + + # Determine RID + $targetOs = if ($os) { $os } else { + if ($IsWindows -or $env:OS -eq "Windows_NT") { "win" } + elseif ($IsMacOS) { "osx" } + else { "linux" } + } + $targetArch = if ($arch) { + # If arch is an array with multiple values, use only the first one for bundle build + if ($arch -is [array]) { $arch[0] } else { $arch } + } else { + [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant() + } + if ($targetArch -eq "x64" -or $targetArch -eq "amd64") { $targetArch = "x64" } + $rid = "$targetOs-$targetArch" + + # Build MSBuild arguments (use MSBuild syntax, not dotnet build syntax) + $bundleArgs = @( + "$PSScriptRoot/Bundle.proj", + "/p:Configuration=$config", + "/p:TargetRid=$rid" + ) + + # Pass through SkipNativeBuild if set + if ($properties -contains "/p:SkipNativeBuild=true") { + $bundleArgs += "/p:SkipNativeBuild=true" + } + + # Pass through runtime version if set + if ($runtimeVersion) { + $bundleArgs += "/p:BundleRuntimeVersion=$runtimeVersion" + } + + # CI flag is passed to Bundle.proj which handles version computation via Versions.props + if ($ci) { + $bundleArgs += "/p:ContinuousIntegrationBuild=true" + } + + Write-Host " RID: $rid" + Write-Host " Configuration: $config" + Write-Host "" + + & dotnet msbuild @bundleArgs + + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +exit 0 diff --git a/eng/build.sh b/eng/build.sh index 5b931f36849..c80b2c68aba 100755 --- a/eng/build.sh +++ b/eng/build.sh @@ -47,6 +47,8 @@ usage() echo "Libraries settings:" echo " --testnobuild Skip building tests when invoking -test." echo " --build-extension Build the VS Code extension." + echo " --bundle Build the self-contained bundle (CLI + Runtime + Dashboard + DCP)." + echo " --runtime-version .NET runtime version for bundle (default: 10.0.2)." echo "" echo "Command line arguments starting with '/p:' are passed through to MSBuild." @@ -56,6 +58,9 @@ usage() arguments='' extraargs='' +build_bundle=false +runtime_version="" +config="Debug" # Check if an action is passed in declare -a actions=("b" "build" "r" "restore" "rebuild" "testnobuild" "sign" "publish" "clean" "t" "test" "build-extension") @@ -99,6 +104,7 @@ while [[ $# > 0 ]]; do case "$passedConfig" in debug|release) val="$(tr '[:lower:]' '[:upper:]' <<< ${passedConfig:0:1})${passedConfig:1}" + config="$val" ;; *) echo "Unsupported target configuration '$2'." @@ -148,6 +154,20 @@ while [[ $# > 0 ]]; do shift 1 ;; + -bundle) + build_bundle=true + shift 1 + ;; + + -runtime-version) + if [ -z ${2+x} ]; then + echo "No runtime version supplied." 1>&2 + exit 1 + fi + runtime_version="$2" + shift 2 + ;; + *) extraargs="$extraargs $1" shift 1 @@ -165,5 +185,86 @@ fi arguments="$arguments $extraargs" "$scriptroot/common/build.sh" $arguments +build_exit_code=$? + +if [ $build_exit_code -ne 0 ]; then + exit $build_exit_code +fi + +# Build bundle if requested +if [ "$build_bundle" = true ]; then + echo "" + echo "Building bundle via MSBuild..." + echo "" + + repo_root="$(dirname "$scriptroot")" + + # Use the local .NET SDK installed by restore + export DOTNET_ROOT="$repo_root/.dotnet" + export PATH="$DOTNET_ROOT:$PATH" + + # Free up disk space by cleaning intermediate build artifacts (CI only) + if [ "${CI:-}" = "true" ]; then + echo " Cleaning intermediate build artifacts to free disk space..." + find "$repo_root" -type d -name "obj" -exec rm -rf {} + 2>/dev/null || true + dotnet nuget locals http-cache --clear 2>/dev/null || true + df -h / 2>/dev/null || true + fi + + # Determine RID + if [ -z "${os:-}" ]; then + case "$(uname -s)" in + Linux*) target_os="linux" ;; + Darwin*) target_os="osx" ;; + *) target_os="linux" ;; + esac + else + target_os="$os" + fi + + if [ -z "${arch:-}" ]; then + case "$(uname -m)" in + x86_64) target_arch="x64" ;; + aarch64) target_arch="arm64" ;; + arm64) target_arch="arm64" ;; + *) target_arch="x64" ;; + esac + else + target_arch="$arch" + fi + + rid="${target_os}-${target_arch}" + + echo " RID: $rid" + echo " Configuration: $config" + echo "" + + # Build MSBuild arguments + bundle_args=( + "$scriptroot/Bundle.proj" + "/p:Configuration=$config" + "/p:TargetRid=$rid" + ) + + # Pass through SkipNativeBuild if set + for arg in "$@"; do + if [[ "$arg" == *"SkipNativeBuild=true"* ]]; then + bundle_args+=("/p:SkipNativeBuild=true") + break + fi + done + + # Pass through runtime version if set + if [ -n "$runtime_version" ]; then + bundle_args+=("/p:BundleRuntimeVersion=$runtime_version") + fi + + # CI flag is passed to Bundle.proj which handles version computation via Versions.props + if [ "${CI:-}" = "true" ]; then + bundle_args+=("/p:ContinuousIntegrationBuild=true") + fi + + dotnet msbuild "${bundle_args[@]}" || exit $? +fi -exit $? +exit 0 diff --git a/eng/clipack/Common.projitems b/eng/clipack/Common.projitems index 7733c85e4b6..0b20ebe2845 100644 --- a/eng/clipack/Common.projitems +++ b/eng/clipack/Common.projitems @@ -42,6 +42,9 @@ + + + diff --git a/eng/scripts/README.md b/eng/scripts/README.md index 6337d768961..2ae38de8a94 100644 --- a/eng/scripts/README.md +++ b/eng/scripts/README.md @@ -4,9 +4,26 @@ This directory contains scripts to download and install the Aspire CLI for diffe ## Scripts +### CLI Only (requires .NET SDK) + - **`get-aspire-cli.sh`** - Bash script for Unix-like systems (Linux, macOS) - **`get-aspire-cli.ps1`** - PowerShell script for cross-platform use (Windows, Linux, macOS) +### Self-Contained Bundle (no .NET SDK required) + +- **`install-aspire-bundle.sh`** - Bash script for Unix-like systems (Linux, macOS) +- **`install-aspire-bundle.ps1`** - PowerShell script for Windows + +The **bundle** includes everything needed to run Aspire applications without a .NET SDK: +- Aspire CLI (native AOT) +- .NET Runtime +- Aspire Dashboard +- Developer Control Plane (DCP) +- Pre-built AppHost Server +- NuGet Helper Tool + +This is ideal for polyglot developers using TypeScript, Python, Go, etc. + ## Current Limitations Supported Quality values: @@ -194,3 +211,102 @@ export ASPIRE_REPO=myfork/aspire $env:ASPIRE_REPO = 'myfork/aspire' ./get-aspire-cli-pr.ps1 1234 ``` + +## Aspire Bundle Installation + +The bundle scripts install a self-contained distribution that doesn't require a .NET SDK. + +### Quick Install + +**Linux/macOS:** +```bash +curl -sSL https://aka.ms/install-aspire-bundle.sh | bash +``` + +**Windows (PowerShell):** +```powershell +iex ((New-Object System.Net.WebClient).DownloadString('https://aka.ms/install-aspire-bundle.ps1')) +``` + +### Bundle Script Parameters + +#### Bash Script (`install-aspire-bundle.sh`) + +| Parameter | Short | Description | Default | +|------------------|-------|---------------------------------------------------|-----------------------| +| `--install-path` | `-i` | Directory to install the bundle | `$HOME/.aspire` | +| `--version` | | Specific version to install | latest release | +| `--os` | | Operating system (linux, osx) | auto-detect | +| `--arch` | | Architecture (x64, arm64) | auto-detect | +| `--skip-path` | | Do not add aspire to PATH | `false` | +| `--force` | | Overwrite existing installation | `false` | +| `--dry-run` | | Show what would be done without installing | `false` | +| `--verbose` | `-v` | Enable verbose output | `false` | +| `--help` | `-h` | Show help message | | + +#### PowerShell Script (`install-aspire-bundle.ps1`) + +| Parameter | Description | Default | +|-----------------|---------------------------------------------------|----------------------------------| +| `-InstallPath` | Directory to install the bundle | `$env:LOCALAPPDATA\Aspire` | +| `-Version` | Specific version to install | latest release | +| `-Architecture` | Architecture (x64, arm64) | auto-detect | +| `-SkipPath` | Do not add aspire to PATH | `false` | +| `-Force` | Overwrite existing installation | `false` | +| `-DryRun` | Show what would be done without installing | `false` | + +### Bundle Examples + +```bash +# Install latest version +./install-aspire-bundle.sh + +# Install specific version +./install-aspire-bundle.sh --version "9.2.0" + +# Install to custom location +./install-aspire-bundle.sh --install-path "/opt/aspire" + +# Dry run to see what would happen +./install-aspire-bundle.sh --dry-run --verbose +``` + +```powershell +# Install latest version +.\install-aspire-bundle.ps1 + +# Install specific version +.\install-aspire-bundle.ps1 -Version "9.2.0" + +# Install to custom location +.\install-aspire-bundle.ps1 -InstallPath "C:\Tools\Aspire" + +# Force reinstall +.\install-aspire-bundle.ps1 -Force +``` + +### Updating and Uninstalling + +**Update an existing bundle installation:** +```bash +aspire update --self +``` + +**Uninstall:** +```bash +# Linux/macOS +rm -rf ~/.aspire + +# Windows (PowerShell) +Remove-Item -Recurse -Force "$env:LOCALAPPDATA\Aspire" +``` + +### Bundle vs CLI-Only + +| Feature | CLI-Only Scripts | Bundle Scripts | +|---------|-----------------|----------------| +| Requires .NET SDK | Yes | No | +| Package size | ~25 MB | ~200 MB compressed | +| Polyglot support | Partial | Full | +| Components included | CLI only | CLI, Runtime, Dashboard, DCP | +| Use case | .NET developers | TypeScript, Python, Go developers | diff --git a/eng/scripts/get-aspire-cli-bundle-pr.ps1 b/eng/scripts/get-aspire-cli-bundle-pr.ps1 new file mode 100644 index 00000000000..0149df0deab --- /dev/null +++ b/eng/scripts/get-aspire-cli-bundle-pr.ps1 @@ -0,0 +1,631 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Download and unpack the Aspire CLI Bundle from a specific PR's build artifacts + +.DESCRIPTION + Downloads and installs the Aspire CLI Bundle from a specific pull request's latest successful build. + Automatically detects the current platform (OS and architecture) and downloads the appropriate artifact. + + The bundle is a self-contained distribution that includes: + - Native AOT Aspire CLI + - .NET runtime (for running managed components) + - Dashboard (web-based monitoring UI) + - DCP (Developer Control Plane for orchestration) + - AppHost Server (for polyglot apps - TypeScript, Python, Go, etc.) + - NuGet Helper tools + + This bundle allows running Aspire applications WITHOUT requiring a globally-installed .NET SDK. + +.PARAMETER PRNumber + Pull request number (required) + +.PARAMETER WorkflowRunId + Workflow run ID to download from (optional) + +.PARAMETER InstallPath + Directory to install bundle (default: $HOME/.aspire on Unix, %USERPROFILE%\.aspire on Windows) + +.PARAMETER OS + Override OS detection (win, linux, osx) + +.PARAMETER Architecture + Override architecture detection (x64, arm64) + +.PARAMETER SkipPath + Do not add the install path to PATH environment variable + +.PARAMETER KeepArchive + Keep downloaded archive files after installation + +.PARAMETER Help + Show this help message + +.EXAMPLE + .\get-aspire-cli-bundle-pr.ps1 1234 + +.EXAMPLE + .\get-aspire-cli-bundle-pr.ps1 1234 -WorkflowRunId 12345678 + +.EXAMPLE + .\get-aspire-cli-bundle-pr.ps1 1234 -InstallPath "C:\my-aspire-bundle" + +.EXAMPLE + .\get-aspire-cli-bundle-pr.ps1 1234 -OS linux -Architecture arm64 -Verbose + +.EXAMPLE + .\get-aspire-cli-bundle-pr.ps1 1234 -WhatIf + +.EXAMPLE + .\get-aspire-cli-bundle-pr.ps1 1234 -SkipPath + +.EXAMPLE + Piped execution + iex "& { $(irm https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-bundle-pr.ps1) } + +.NOTES + Requires GitHub CLI (gh) to be installed and authenticated + Requires appropriate permissions to download artifacts from target repository + +.PARAMETER ASPIRE_REPO (environment variable) + Override repository (owner/name). Default: dotnet/aspire + Example: $env:ASPIRE_REPO = 'myfork/aspire' +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Position = 0, HelpMessage = "Pull request number")] + [ValidateRange(1, [int]::MaxValue)] + [int]$PRNumber, + + [Parameter(HelpMessage = "Workflow run ID to download from")] + [ValidateRange(1, [long]::MaxValue)] + [long]$WorkflowRunId, + + [Parameter(HelpMessage = "Directory to install bundle")] + [string]$InstallPath = "", + + [Parameter(HelpMessage = "Override OS detection")] + [ValidateSet("", "win", "linux", "osx")] + [string]$OS = "", + + [Parameter(HelpMessage = "Override architecture detection")] + [ValidateSet("", "x64", "arm64")] + [string]$Architecture = "", + + [Parameter(HelpMessage = "Skip adding to PATH")] + [switch]$SkipPath, + + [Parameter(HelpMessage = "Keep downloaded archive files")] + [switch]$KeepArchive, + + [Parameter(HelpMessage = "Show help")] + [switch]$Help +) + +# ============================================================================= +# Constants +# ============================================================================= + +$script:BUNDLE_ARTIFACT_NAME_PREFIX = "aspire-bundle" +$script:BUILT_NUGETS_ARTIFACT_NAME = "built-nugets" +$script:BUILT_NUGETS_RID_ARTIFACT_NAME = "built-nugets-for" +$script:REPO = if ($env:ASPIRE_REPO) { $env:ASPIRE_REPO } else { "dotnet/aspire" } +$script:GH_REPOS_BASE = "repos/$script:REPO" + +# ============================================================================= +# Logging functions +# ============================================================================= + +function Write-VerboseMessage { + param([string]$Message) + if ($VerbosePreference -ne 'SilentlyContinue') { + Write-Host $Message -ForegroundColor Yellow + } +} + +function Write-ErrorMessage { + param([string]$Message) + Write-Host "Error: $Message" -ForegroundColor Red +} + +function Write-WarnMessage { + param([string]$Message) + Write-Host "Warning: $Message" -ForegroundColor Yellow +} + +function Write-InfoMessage { + param([string]$Message) + Write-Host $Message +} + +function Write-SuccessMessage { + param([string]$Message) + Write-Host $Message -ForegroundColor Green +} + +# ============================================================================= +# Platform detection +# ============================================================================= + +function Get-HostOS { + if ($IsWindows -or $env:OS -eq "Windows_NT") { + return "win" + } + elseif ($IsMacOS) { + return "osx" + } + elseif ($IsLinux) { + return "linux" + } + else { + return "win" # Default to Windows for PowerShell 5.1 + } +} + +function Get-HostArchitecture { + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + switch ($arch) { + "X64" { return "x64" } + "Arm64" { return "arm64" } + default { return "x64" } + } +} + +function Get-RuntimeIdentifier { + param( + [string]$TargetOS, + [string]$TargetArch + ) + + if ([string]::IsNullOrEmpty($TargetOS)) { + $TargetOS = Get-HostOS + } + + if ([string]::IsNullOrEmpty($TargetArch)) { + $TargetArch = Get-HostArchitecture + } + + return "$TargetOS-$TargetArch" +} + +# ============================================================================= +# GitHub API functions +# ============================================================================= + +function Test-GhDependency { + $ghPath = Get-Command "gh" -ErrorAction SilentlyContinue + if (-not $ghPath) { + Write-ErrorMessage "GitHub CLI (gh) is required but not installed." + Write-InfoMessage "Installation instructions: https://cli.github.com/" + return $false + } + + try { + $ghVersion = & gh --version 2>&1 + Write-VerboseMessage "GitHub CLI (gh) found: $($ghVersion | Select-Object -First 1)" + return $true + } + catch { + Write-ErrorMessage "GitHub CLI (gh) command failed: $_" + return $false + } +} + +function Invoke-GhApiCall { + param( + [string]$Endpoint, + [string]$JqFilter = "", + [string]$ErrorMessage = "Failed to call GitHub API" + ) + + $ghArgs = @("api", $Endpoint) + if (-not [string]::IsNullOrEmpty($JqFilter)) { + $ghArgs += @("--jq", $JqFilter) + } + + Write-VerboseMessage "Calling GitHub API: gh $($ghArgs -join ' ')" + + try { + $result = & gh @ghArgs 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-ErrorMessage "$ErrorMessage (API endpoint: $Endpoint): $result" + return $null + } + return $result + } + catch { + Write-ErrorMessage "$ErrorMessage (API endpoint: $Endpoint): $_" + return $null + } +} + +function Get-PrHeadSha { + param([int]$PrNumber) + + Write-VerboseMessage "Getting HEAD SHA for PR #$PrNumber" + + $headSha = Invoke-GhApiCall -Endpoint "$script:GH_REPOS_BASE/pulls/$PrNumber" -JqFilter ".head.sha" -ErrorMessage "Failed to get HEAD SHA for PR #$PrNumber" + + if ([string]::IsNullOrEmpty($headSha) -or $headSha -eq "null") { + Write-ErrorMessage "Could not retrieve HEAD SHA for PR #$PrNumber" + Write-InfoMessage "This could mean:" + Write-InfoMessage " - The PR number does not exist" + Write-InfoMessage " - You don't have access to the repository" + return $null + } + + Write-VerboseMessage "PR #$PrNumber HEAD SHA: $headSha" + return $headSha +} + +function Find-WorkflowRun { + param([string]$HeadSha) + + Write-VerboseMessage "Finding ci.yml workflow run for SHA: $HeadSha" + + $workflowRunId = Invoke-GhApiCall -Endpoint "$script:GH_REPOS_BASE/actions/workflows/ci.yml/runs?event=pull_request&head_sha=$HeadSha" -JqFilter ".workflow_runs | sort_by(.created_at, .updated_at) | reverse | .[0].id" -ErrorMessage "Failed to query workflow runs for SHA: $HeadSha" + + if ([string]::IsNullOrEmpty($workflowRunId) -or $workflowRunId -eq "null") { + Write-ErrorMessage "No ci.yml workflow run found for PR SHA: $HeadSha" + Write-InfoMessage "Check at https://github.com/$script:REPO/actions/workflows/ci.yml" + return $null + } + + Write-VerboseMessage "Found workflow run ID: $workflowRunId" + return $workflowRunId +} + +# ============================================================================= +# Bundle download and install +# ============================================================================= + +function Get-AspireBundle { + param( + [string]$WorkflowRunId, + [string]$Rid, + [string]$TempDir + ) + + $bundleArtifactName = "$script:BUNDLE_ARTIFACT_NAME_PREFIX-$Rid" + $downloadDir = Join-Path $TempDir "bundle" + + if ($WhatIfPreference) { + Write-InfoMessage "[WhatIf] Would download $bundleArtifactName" + return $downloadDir + } + + Write-InfoMessage "Downloading bundle artifact: $bundleArtifactName ..." + + New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null + + $ghArgs = @("run", "download", $WorkflowRunId, "-R", $script:REPO, "--name", $bundleArtifactName, "-D", $downloadDir) + Write-VerboseMessage "Downloading with: gh $($ghArgs -join ' ')" + + try { + & gh @ghArgs 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "gh run download failed with exit code $LASTEXITCODE" + } + } + catch { + Write-ErrorMessage "Failed to download artifact '$bundleArtifactName' from run: $WorkflowRunId" + Write-InfoMessage "If the workflow is still running, the artifact may not be available yet." + Write-InfoMessage "Check at https://github.com/$script:REPO/actions/runs/$WorkflowRunId#artifacts" + Write-InfoMessage "" + + # Try to list available artifacts from the workflow run + try { + $artifactsJson = & gh api "repos/$script:REPO/actions/runs/$WorkflowRunId/artifacts" --jq '.artifacts[].name' 2>&1 + if ($LASTEXITCODE -eq 0 -and $artifactsJson) { + $bundleArtifacts = $artifactsJson | Where-Object { $_ -like "$script:BUNDLE_ARTIFACT_NAME_PREFIX-*" } + if ($bundleArtifacts) { + Write-InfoMessage "Available bundle artifacts:" + foreach ($artifact in $bundleArtifacts) { + Write-InfoMessage " $artifact" + } + } + else { + Write-InfoMessage "No bundle artifacts found in this workflow run." + } + } + } + catch { + Write-VerboseMessage "Could not query available artifacts: $_" + } + + return $null + } + + Write-VerboseMessage "Successfully downloaded bundle to: $downloadDir" + return $downloadDir +} + +function Install-AspireBundle { + param( + [string]$DownloadDir, + [string]$InstallDir + ) + + if ($WhatIfPreference) { + Write-InfoMessage "[WhatIf] Would install bundle to: $InstallDir" + return $true + } + + # Create install directory (may already exist with other aspire state like logs, certs, etc.) + Write-VerboseMessage "Installing bundle from $DownloadDir to $InstallDir" + + try { + Copy-Item -Path "$DownloadDir/*" -Destination $InstallDir -Recurse -Force + + # Move CLI binary into bin/ subdirectory so it shares the same path as CLI-only install + # Layout: ~/.aspire/bin/aspire (CLI) + ~/.aspire/runtime/ + ~/.aspire/dashboard/ + ... + $binDir = Join-Path $InstallDir "bin" + if (-not (Test-Path $binDir)) { + New-Item -ItemType Directory -Path $binDir -Force | Out-Null + } + $cliExe = if ($IsWindows -or $env:OS -eq "Windows_NT") { "aspire.exe" } else { "aspire" } + $cliSource = Join-Path $InstallDir $cliExe + if (Test-Path $cliSource) { + Move-Item -Path $cliSource -Destination (Join-Path $binDir $cliExe) -Force + } + + Write-SuccessMessage "Aspire CLI bundle successfully installed to: $InstallDir" + return $true + } + catch { + Write-ErrorMessage "Failed to copy bundle files: $_" + return $false + } +} + +# ============================================================================= +# PATH management +# ============================================================================= + +function Add-ToUserPath { + param([string]$PathToAdd) + + if ($WhatIfPreference) { + Write-InfoMessage "[WhatIf] Would add $PathToAdd to user PATH" + return + } + + $currentPath = [Environment]::GetEnvironmentVariable("PATH", "User") + + if ($currentPath -split ";" | Where-Object { $_ -eq $PathToAdd }) { + Write-InfoMessage "Path $PathToAdd already exists in PATH, skipping addition" + return + } + + $newPath = "$PathToAdd;$currentPath" + [Environment]::SetEnvironmentVariable("PATH", $newPath, "User") + + # Also update current session + $env:PATH = "$PathToAdd;$env:PATH" + + Write-InfoMessage "Successfully added $PathToAdd to PATH" + Write-InfoMessage "You may need to restart your terminal for the change to take effect" +} + +# ============================================================================= +# NuGet hive download and install functions +# ============================================================================= + +function Get-BuiltNugets { + param( + [string]$WorkflowRunId, + [string]$Rid, + [string]$TempDir + ) + + $downloadDir = Join-Path $TempDir "built-nugets" + + if ($WhatIfPreference) { + Write-InfoMessage "[WhatIf] Would download built NuGet packages" + return $downloadDir + } + + Write-InfoMessage "Downloading built NuGet artifacts..." + New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null + + try { + $ghArgs = @("run", "download", $WorkflowRunId, "-R", $script:REPO, "--name", $script:BUILT_NUGETS_ARTIFACT_NAME, "-D", $downloadDir) + & gh @ghArgs 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to download $($script:BUILT_NUGETS_ARTIFACT_NAME)" } + + $ridArtifactName = "$($script:BUILT_NUGETS_RID_ARTIFACT_NAME)-$Rid" + $ghArgs = @("run", "download", $WorkflowRunId, "-R", $script:REPO, "--name", $ridArtifactName, "-D", $downloadDir) + & gh @ghArgs 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to download $ridArtifactName" } + } + catch { + Write-WarnMessage "Failed to download NuGet packages: $($_.Exception.Message)" + return $null + } + + return $downloadDir +} + +function Install-BuiltNugets { + param( + [string]$DownloadDir, + [string]$NugetHiveDir + ) + + if ($WhatIfPreference) { + Write-InfoMessage "[WhatIf] Would copy nugets to $NugetHiveDir" + return + } + + if (Test-Path $NugetHiveDir) { + Remove-Item $NugetHiveDir -Recurse -Force + } + New-Item -ItemType Directory -Path $NugetHiveDir -Force | Out-Null + + $nupkgFiles = Get-ChildItem -Path $DownloadDir -Filter "*.nupkg" -Recurse + if ($nupkgFiles.Count -eq 0) { + Write-WarnMessage "No .nupkg files found in downloaded artifact" + return + } + + foreach ($file in $nupkgFiles) { + Copy-Item $file.FullName -Destination $NugetHiveDir + } + + Write-InfoMessage "NuGet packages installed to: $NugetHiveDir" +} + +# ============================================================================= +# Main function +# ============================================================================= + +function Main { + if ($Help) { + Get-Help $MyInvocation.MyCommand.Path -Detailed + return + } + + if ($PRNumber -eq 0) { + Write-ErrorMessage "PR number is required" + Write-InfoMessage "Use -Help for usage information" + exit 1 + } + + # Check dependencies + if (-not (Test-GhDependency)) { + exit 1 + } + + # Set default install path + if ([string]::IsNullOrEmpty($InstallPath)) { + if ((Get-HostOS) -eq "win") { + $InstallPath = Join-Path $env:USERPROFILE ".aspire" + } + else { + $InstallPath = Join-Path $HOME ".aspire" + } + } + + Write-InfoMessage "Starting bundle download for PR #$PRNumber" + Write-InfoMessage "Install path: $InstallPath" + + # Get workflow run ID + $runId = $WorkflowRunId + if (-not $runId) { + $headSha = Get-PrHeadSha -PrNumber $PRNumber + if (-not $headSha) { + exit 1 + } + + $runId = Find-WorkflowRun -HeadSha $headSha + if (-not $runId) { + exit 1 + } + } + + Write-InfoMessage "Using workflow run https://github.com/$script:REPO/actions/runs/$runId" + + # Compute RID + $rid = Get-RuntimeIdentifier -TargetOS $OS -TargetArch $Architecture + Write-VerboseMessage "Computed RID: $rid" + + # Create temp directory + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-bundle-pr-$([System.Guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + + try { + # Download bundle + $downloadDir = Get-AspireBundle -WorkflowRunId $runId -Rid $rid -TempDir $tempDir + if (-not $downloadDir) { + exit 1 + } + + # Install bundle + if (-not (Install-AspireBundle -DownloadDir $downloadDir -InstallDir $InstallPath)) { + exit 1 + } + + # Download and install NuGet hive packages (needed for 'aspire new' and 'aspire add') + $nugetHiveDir = Join-Path $InstallPath "hives" "pr-$PRNumber" "packages" + $nugetDownloadDir = Get-BuiltNugets -WorkflowRunId $runId -Rid $rid -TempDir $tempDir + if (-not $nugetDownloadDir) { + Write-ErrorMessage "Failed to download NuGet packages" + exit 1 + } + Install-BuiltNugets -DownloadDir $nugetDownloadDir -NugetHiveDir $nugetHiveDir + + # Verify installation (CLI is now in bin/ subdirectory) + $binDir = Join-Path $InstallPath "bin" + $cliPath = Join-Path $binDir "aspire.exe" + if (-not (Test-Path $cliPath)) { + $cliPath = Join-Path $binDir "aspire" + } + + if ((Test-Path $cliPath) -and -not $WhatIfPreference) { + Write-InfoMessage "" + Write-InfoMessage "Verifying installation..." + try { + $version = & $cliPath --version 2>&1 + Write-SuccessMessage "Bundle verification passed!" + Write-InfoMessage "Installed version: $version" + } + catch { + Write-WarnMessage "Bundle verification failed - CLI may not work correctly" + } + } + + # Add to PATH (use bin/ subdirectory, same as CLI-only install) + if (-not $SkipPath) { + Add-ToUserPath -PathToAdd $binDir + } + else { + Write-InfoMessage "Skipping PATH configuration due to -SkipPath flag" + } + + # Save the global channel setting to the PR channel + # This allows 'aspire new' and 'aspire init' to use the same channel by default + if (-not $WhatIfPreference) { + Write-VerboseMessage "Setting global config: channel = pr-$PRNumber" + try { + $output = & $cliPath config set -g channel "pr-$PRNumber" 2>&1 + Write-VerboseMessage "Global config saved: channel = pr-$PRNumber" + } + catch { + Write-WarnMessage "Failed to set global channel config via aspire CLI (non-fatal)" + } + } + else { + Write-InfoMessage "[DRY RUN] Would run: $cliPath config set -g channel pr-$PRNumber" + } + +# Print success message + Write-InfoMessage "" + Write-SuccessMessage "============================================" + Write-SuccessMessage " Aspire Bundle from PR #$PRNumber Installed" + Write-SuccessMessage "============================================" + Write-InfoMessage "" + Write-InfoMessage "Bundle location: $InstallPath" + Write-InfoMessage "" + Write-InfoMessage "To use:" + Write-InfoMessage " $cliPath --help" + Write-InfoMessage " $cliPath run" + Write-InfoMessage "" + Write-InfoMessage "The bundle includes everything needed to run Aspire apps" + Write-InfoMessage "without requiring a globally-installed .NET SDK." + } + finally { + # Cleanup temp directory + if (-not $KeepArchive -and (Test-Path $tempDir)) { + Write-VerboseMessage "Cleaning up temporary files..." + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + elseif ($KeepArchive) { + Write-InfoMessage "Archive files kept in: $tempDir" + } + } +} + +# Run main +Main diff --git a/eng/scripts/get-aspire-cli-bundle-pr.sh b/eng/scripts/get-aspire-cli-bundle-pr.sh new file mode 100644 index 00000000000..333767c24cd --- /dev/null +++ b/eng/scripts/get-aspire-cli-bundle-pr.sh @@ -0,0 +1,842 @@ +#!/usr/bin/env bash + +# get-aspire-cli-bundle-pr.sh - Download and unpack the Aspire CLI Bundle from a specific PR's build artifacts +# Usage: ./get-aspire-cli-bundle-pr.sh PR_NUMBER [OPTIONS] +# +# The bundle is a self-contained distribution that includes: +# - Native AOT Aspire CLI +# - .NET runtime +# - Dashboard +# - DCP (Developer Control Plane) +# - AppHost Server (for polyglot apps) +# - NuGet Helper tools + +set -euo pipefail + +# Global constants / defaults +readonly BUNDLE_ARTIFACT_NAME_PREFIX="aspire-bundle" +readonly BUILT_NUGETS_ARTIFACT_NAME="built-nugets" +readonly BUILT_NUGETS_RID_ARTIFACT_NAME="built-nugets-for" + +# Repository: Allow override via ASPIRE_REPO env var (owner/name). Default: dotnet/aspire +readonly REPO="${ASPIRE_REPO:-dotnet/aspire}" +readonly GH_REPOS_BASE="repos/${REPO}" + +# Global constants +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly RESET='\033[0m' + +# Variables (defaults set after parsing arguments) +INSTALL_PREFIX="" +PR_NUMBER="" +WORKFLOW_RUN_ID="" +OS_ARG="" +ARCH_ARG="" +SHOW_HELP=false +VERBOSE=false +KEEP_ARCHIVE=false +DRY_RUN=false +SKIP_PATH=false +HOST_OS="unset" + +# Function to show help +show_help() { + cat << 'EOF' +Aspire CLI Bundle PR Download Script + +DESCRIPTION: + Downloads and installs the Aspire CLI Bundle from a specific pull request's latest successful build. + Automatically detects the current platform (OS and architecture) and downloads the appropriate artifact. + + The bundle is a self-contained distribution that includes: + - Native AOT Aspire CLI + - .NET runtime (for running managed components) + - Dashboard (web-based monitoring UI) + - DCP (Developer Control Plane for orchestration) + - AppHost Server (for polyglot apps - TypeScript, Python, Go, etc.) + - NuGet Helper tools + + This bundle allows running Aspire applications WITHOUT requiring a globally-installed .NET SDK. + + The script queries the GitHub API to find the latest successful run of the 'ci.yml' workflow + for the specified PR, then downloads and extracts the bundle archive for your platform. + +USAGE: + ./get-aspire-cli-bundle-pr.sh PR_NUMBER [OPTIONS] + ./get-aspire-cli-bundle-pr.sh PR_NUMBER --run-id WORKFLOW_RUN_ID [OPTIONS] + + PR_NUMBER Pull request number (required) + --run-id, -r WORKFLOW_ID Workflow run ID to download from (optional) + -i, --install-path PATH Directory to install bundle (default: ~/.aspire) + --os OS Override OS detection (win, linux, osx) + --arch ARCH Override architecture detection (x64, arm64) + --skip-path Do not add the install path to PATH environment variable + -v, --verbose Enable verbose output + -k, --keep-archive Keep downloaded archive files after installation + --dry-run Show what would be done without performing actions + -h, --help Show this help message + +EXAMPLES: + ./get-aspire-cli-bundle-pr.sh 1234 + ./get-aspire-cli-bundle-pr.sh 1234 --run-id 12345678 + ./get-aspire-cli-bundle-pr.sh 1234 --install-path ~/my-aspire-bundle + ./get-aspire-cli-bundle-pr.sh 1234 --os linux --arch arm64 --verbose + ./get-aspire-cli-bundle-pr.sh 1234 --skip-path + ./get-aspire-cli-bundle-pr.sh 1234 --dry-run + + curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-bundle-pr.sh | bash -s -- + +REQUIREMENTS: + - GitHub CLI (gh) must be installed and authenticated + - Permissions to download artifacts from the target repository + +ENVIRONMENT VARIABLES: + ASPIRE_REPO Override repository (owner/name). Default: dotnet/aspire + Example: export ASPIRE_REPO=myfork/aspire + +EOF +} + +# Function to parse command line arguments +parse_args() { + # Check for help flag first (can be anywhere in arguments) + for arg in "$@"; do + if [[ "$arg" == "-h" || "$arg" == "--help" ]]; then + SHOW_HELP=true + return 0 + fi + done + + # Check that at least one argument is provided + if [[ $# -lt 1 ]]; then + say_error "At least one argument is required. The first argument must be a PR number." + say_info "Use --help for usage information." + exit 1 + fi + + # First argument must be the PR number (cannot start with --) + if [[ "$1" == --* ]]; then + say_error "First argument must be a PR number, not an option. Got: '$1'" + say_info "Use --help for usage information." + exit 1 + fi + + # Validate that the first argument is a valid PR number (positive integer) + if [[ "$1" =~ ^[1-9][0-9]*$ ]]; then + PR_NUMBER="$1" + shift + else + say_error "First argument must be a valid PR number" + say_info "Use --help for usage information." + exit 1 + fi + + while [[ $# -gt 0 ]]; do + case $1 in + --run-id|-r) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + exit 1 + fi + if [[ ! "$2" =~ ^[0-9]+$ ]]; then + say_error "Run ID must be a number. Got: '$2'" + exit 1 + fi + WORKFLOW_RUN_ID="$2" + shift 2 + ;; + -i|--install-path) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + exit 1 + fi + INSTALL_PREFIX="$2" + shift 2 + ;; + --os) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + exit 1 + fi + OS_ARG="$2" + shift 2 + ;; + --arch) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + exit 1 + fi + ARCH_ARG="$2" + shift 2 + ;; + -k|--keep-archive) + KEEP_ARCHIVE=true + shift + ;; + --skip-path) + SKIP_PATH=true + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + -v|--verbose) + VERBOSE=true + shift + ;; + *) + say_error "Unknown option '$1'" + say_info "Use --help for usage information." + exit 1 + ;; + esac + done +} + +# ============================================================================= +# Logging functions +# ============================================================================= + +say_verbose() { + if [[ "$VERBOSE" == true ]]; then + echo -e "${YELLOW}$1${RESET}" >&2 + fi +} + +say_error() { + echo -e "${RED}Error: $1${RESET}" >&2 +} + +say_warn() { + echo -e "${YELLOW}Warning: $1${RESET}" >&2 +} + +say_info() { + echo -e "$1" >&2 +} + +say_success() { + echo -e "${GREEN}$1${RESET}" >&2 +} + +# ============================================================================= +# Platform detection +# ============================================================================= + +detect_os() { + local uname_s + uname_s=$(uname -s) + + case "$uname_s" in + Darwin*) + printf "osx" + ;; + Linux*) + printf "linux" + ;; + CYGWIN*|MINGW*|MSYS*) + printf "win" + ;; + *) + printf "unsupported" + return 1 + ;; + esac +} + +detect_architecture() { + local uname_m + uname_m=$(uname -m) + + case "$uname_m" in + x86_64|amd64) + printf "x64" + ;; + aarch64|arm64) + printf "arm64" + ;; + *) + say_error "Architecture $uname_m not supported." + return 1 + ;; + esac +} + +get_runtime_identifier() { + local target_os="${1:-$HOST_OS}" + local target_arch="${2:-}" + + if [[ -z "$target_arch" ]]; then + if ! target_arch=$(detect_architecture); then + return 1 + fi + fi + + printf "%s-%s" "$target_os" "$target_arch" +} + +# ============================================================================= +# Temp directory management +# ============================================================================= + +new_temp_dir() { + local prefix="$1" + if [[ "$DRY_RUN" == true ]]; then + printf "/tmp/%s-whatif" "$prefix" + return 0 + fi + local dir + if ! dir=$(mktemp -d -t "${prefix}-XXXXXXXX"); then + say_error "Unable to create temporary directory" + return 1 + fi + say_verbose "Creating temporary directory: $dir" + printf "%s" "$dir" +} + +remove_temp_dir() { + local dir="$1" + if [[ -z "$dir" || ! -d "$dir" ]]; then + return 0 + fi + if [[ "$DRY_RUN" == true ]]; then + return 0 + fi + if [[ "$KEEP_ARCHIVE" != true ]]; then + say_verbose "Cleaning up temporary files..." + rm -rf "$dir" || say_warn "Failed to clean up temporary directory: $dir" + else + printf "Archive files kept in: %s\n" "$dir" + fi +} + +# ============================================================================= +# Archive handling +# ============================================================================= + +install_archive() { + local archive_file="$1" + local destination_path="$2" + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would install archive $archive_file to $destination_path" + return 0 + fi + + say_verbose "Installing archive to: $destination_path" + + if [[ ! -d "$destination_path" ]]; then + say_verbose "Creating install directory: $destination_path" + mkdir -p "$destination_path" + fi + + if [[ "$archive_file" =~ \.zip$ ]]; then + if ! command -v unzip >/dev/null 2>&1; then + say_error "unzip command not found. Please install unzip." + return 1 + fi + if ! unzip -o "$archive_file" -d "$destination_path"; then + say_error "Failed to extract ZIP archive: $archive_file" + return 1 + fi + elif [[ "$archive_file" =~ \.tar\.gz$ ]]; then + if ! command -v tar >/dev/null 2>&1; then + say_error "tar command not found. Please install tar." + return 1 + fi + if ! tar -xzf "$archive_file" -C "$destination_path"; then + say_error "Failed to extract tar.gz archive: $archive_file" + return 1 + fi + else + say_error "Unsupported archive format: $archive_file" + return 1 + fi + + say_verbose "Successfully installed archive" +} + +# ============================================================================= +# PATH management +# ============================================================================= + +add_to_path() { + local config_file="$1" + local bin_path="$2" + local command="$3" + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would add '$command' to $config_file" + return 0 + fi + + if [[ ":$PATH:" == *":$bin_path:"* ]]; then + say_info "Path $bin_path already exists in \$PATH, skipping addition" + elif [[ -f "$config_file" ]] && grep -Fxq "$command" "$config_file"; then + say_info "Command already exists in $config_file, skipping addition" + elif [[ -w $config_file ]]; then + echo -e "\n# Added by get-aspire-cli-bundle-pr.sh script" >> "$config_file" + echo "$command" >> "$config_file" + say_info "Successfully added aspire bundle to \$PATH in $config_file" + else + say_info "Manually add the following to $config_file (or similar):" + say_info " $command" + fi +} + +add_to_shell_profile() { + local bin_path="$1" + local bin_path_unexpanded="$2" + local xdg_config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + + local shell_name + if [[ -n "${SHELL:-}" ]]; then + shell_name=$(basename "$SHELL") + else + shell_name=$(ps -p $$ -o comm= 2>/dev/null || echo "sh") + fi + + case "$shell_name" in + bash|zsh|fish) ;; + sh|dash|ash) shell_name="sh" ;; + *) shell_name="bash" ;; + esac + + say_verbose "Detected shell: $shell_name" + + local config_files + case "$shell_name" in + bash) config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile" ;; + zsh) config_files="$HOME/.zshrc $HOME/.zshenv" ;; + fish) config_files="$HOME/.config/fish/config.fish" ;; + sh) config_files="$HOME/.profile" ;; + *) config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile" ;; + esac + + local config_file + for file in $config_files; do + if [[ -f "$file" ]]; then + config_file="$file" + break + fi + done + + if [[ -z "${config_file:-}" ]]; then + say_warn "No existing shell profile file found. Not adding to PATH automatically." + say_info "Add Aspire bundle to PATH manually by adding:" + say_info " export PATH=\"$bin_path_unexpanded:\$PATH\"" + return 0 + fi + + case "$shell_name" in + bash|zsh|sh) + add_to_path "$config_file" "$bin_path" "export PATH=\"$bin_path_unexpanded:\$PATH\"" + ;; + fish) + add_to_path "$config_file" "$bin_path" "fish_add_path $bin_path_unexpanded" + ;; + esac + + if [[ "$DRY_RUN" != true ]]; then + printf "\nTo use the Aspire CLI bundle in new terminal sessions, restart your terminal or run:\n" + say_info " source $config_file" + fi +} + +# ============================================================================= +# GitHub API functions +# ============================================================================= + +check_gh_dependency() { + if ! command -v gh >/dev/null 2>&1; then + say_error "GitHub CLI (gh) is required but not installed." + say_info "Installation instructions: https://cli.github.com/" + return 1 + fi + + if ! gh_version_output=$(gh --version 2>&1); then + say_error "GitHub CLI (gh) command failed: $gh_version_output" + return 1 + fi + + say_verbose "GitHub CLI (gh) found: $(echo "$gh_version_output" | head -1)" +} + +gh_api_call() { + local endpoint="$1" + local jq_filter="${2:-}" + local error_message="${3:-Failed to call GitHub API}" + local gh_cmd=(gh api "$endpoint") + if [[ -n "$jq_filter" ]]; then + gh_cmd+=(--jq "$jq_filter") + fi + say_verbose "Calling GitHub API: ${gh_cmd[*]}" + local api_output + if ! api_output=$("${gh_cmd[@]}" 2>&1); then + say_error "$error_message (API endpoint: $endpoint): $api_output" + return 1 + fi + printf "%s" "$api_output" +} + +get_pr_head_sha() { + local pr_number="$1" + + say_verbose "Getting HEAD SHA for PR #$pr_number" + + local head_sha + if ! head_sha=$(gh_api_call "${GH_REPOS_BASE}/pulls/$pr_number" ".head.sha" "Failed to get HEAD SHA for PR #$pr_number"); then + say_info "This could mean:" + say_info " - The PR number does not exist" + say_info " - You don't have access to the repository" + exit 1 + fi + + if [[ -z "$head_sha" || "$head_sha" == "null" ]]; then + say_error "Could not retrieve HEAD SHA for PR #$pr_number" + exit 1 + fi + + say_verbose "PR #$pr_number HEAD SHA: $head_sha" + printf "%s" "$head_sha" +} + +find_workflow_run() { + local head_sha="$1" + + say_verbose "Finding ci.yml workflow run for SHA: $head_sha" + + local workflow_run_id + if ! workflow_run_id=$(gh_api_call "${GH_REPOS_BASE}/actions/workflows/ci.yml/runs?event=pull_request&head_sha=$head_sha" ".workflow_runs | sort_by(.created_at, .updated_at) | reverse | .[0].id" "Failed to query workflow runs for SHA: $head_sha"); then + return 1 + fi + + if [[ -z "$workflow_run_id" || "$workflow_run_id" == "null" ]]; then + say_error "No ci.yml workflow run found for PR SHA: $head_sha" + say_info "Check at https://github.com/${REPO}/actions/workflows/ci.yml" + return 1 + fi + + say_verbose "Found workflow run ID: $workflow_run_id" + printf "%s" "$workflow_run_id" +} + +# ============================================================================= +# Bundle download and install +# ============================================================================= + +download_aspire_bundle() { + local workflow_run_id="$1" + local rid="$2" + local temp_dir="$3" + + local bundle_artifact_name="${BUNDLE_ARTIFACT_NAME_PREFIX}-${rid}" + local download_dir="${temp_dir}/bundle" + local download_command=(gh run download "$workflow_run_id" -R "$REPO" --name "$bundle_artifact_name" -D "$download_dir") + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would download $bundle_artifact_name with: ${download_command[*]}" + printf "%s" "$download_dir" + return 0 + fi + + say_info "Downloading bundle artifact: $bundle_artifact_name ..." + say_verbose "Downloading with: ${download_command[*]}" + + if ! "${download_command[@]}"; then + say_verbose "gh run download command failed. Command: ${download_command[*]}" + say_error "Failed to download artifact '$bundle_artifact_name' from run: $workflow_run_id" + say_info "If the workflow is still running, the artifact may not be available yet." + say_info "Check at https://github.com/${REPO}/actions/runs/$workflow_run_id#artifacts" + say_info "" + say_info "Available bundle artifacts:" + say_info " aspire-bundle-linux-x64" + say_info " aspire-bundle-win-x64" + say_info " aspire-bundle-osx-x64" + say_info " aspire-bundle-osx-arm64" + return 1 + fi + + say_verbose "Successfully downloaded bundle to: $download_dir" + printf "%s" "$download_dir" +} + +install_aspire_bundle() { + local download_dir="$1" + local install_dir="$2" + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would install bundle to: $install_dir" + return 0 + fi + + # Create install directory (may already exist with other aspire state like logs, certs, etc.) + mkdir -p "$install_dir" + + # Copy bundle contents, overwriting existing files + say_verbose "Installing bundle from $download_dir to $install_dir" + if ! cp -rf "$download_dir"/* "$install_dir"/; then + say_error "Failed to copy bundle files" + return 1 + fi + + # Move CLI binary into bin/ subdirectory so it shares the same path as CLI-only install + # Layout: ~/.aspire/bin/aspire (CLI) + ~/.aspire/runtime/ + ~/.aspire/dashboard/ + ... + mkdir -p "$install_dir/bin" + if [[ -f "$install_dir/aspire" ]]; then + mv "$install_dir/aspire" "$install_dir/bin/aspire" + fi + + # Make CLI executable + local cli_path="$install_dir/bin/aspire" + if [[ -f "$cli_path" ]]; then + chmod +x "$cli_path" + fi + + # Make other executables executable + for exe in "$install_dir"/dcp/dcp "$install_dir"/runtime/dotnet; do + if [[ -f "$exe" ]]; then + chmod +x "$exe" + fi + done + + say_success "Aspire CLI bundle successfully installed to: $install_dir" +} + +# ============================================================================= +# NuGet hive download and install functions +# ============================================================================= + +download_built_nugets() { + local workflow_run_id="$1" + local rid="$2" + local temp_dir="$3" + + local download_dir="${temp_dir}/built-nugets" + local nugets_download_command=(gh run download "$workflow_run_id" -R "$REPO" --name "$BUILT_NUGETS_ARTIFACT_NAME" -D "$download_dir") + local nugets_rid_filename="$BUILT_NUGETS_RID_ARTIFACT_NAME-${rid}" + local nugets_rid_download_command=(gh run download "$workflow_run_id" -R "$REPO" --name "$nugets_rid_filename" -D "$download_dir") + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would download built nugets with: ${nugets_download_command[*]}" + say_info "[DRY RUN] Would download rid specific built nugets with: ${nugets_rid_download_command[*]}" + printf "%s" "$download_dir" + return 0 + fi + + say_info "Downloading built NuGet artifacts - $BUILT_NUGETS_ARTIFACT_NAME" + say_verbose "Downloading with: ${nugets_download_command[*]}" + + if ! "${nugets_download_command[@]}"; then + say_error "Failed to download artifact '$BUILT_NUGETS_ARTIFACT_NAME' from run: $workflow_run_id" + return 1 + fi + + say_info "Downloading RID-specific NuGet artifacts - $nugets_rid_filename ..." + say_verbose "Downloading with: ${nugets_rid_download_command[*]}" + + if ! "${nugets_rid_download_command[@]}"; then + say_error "Failed to download artifact '$nugets_rid_filename' from run: $workflow_run_id" + return 1 + fi + + say_verbose "Successfully downloaded NuGet packages to: $download_dir" + printf "%s" "$download_dir" + return 0 +} + +install_built_nugets() { + local download_dir="$1" + local nuget_install_dir="$2" + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would copy nugets to $nuget_install_dir" + return 0 + fi + + # Remove and recreate the target directory to ensure clean state + if [[ -d "$nuget_install_dir" ]]; then + say_verbose "Removing existing nuget directory: $nuget_install_dir" + rm -rf "$nuget_install_dir" + fi + mkdir -p "$nuget_install_dir" + + say_verbose "Copying nugets from $download_dir to $nuget_install_dir" + + if ! find "$download_dir" -name "*.nupkg" -exec cp -R {} "$nuget_install_dir"/ \;; then + say_error "Failed to copy NuGet artifact files" + return 1 + fi + + say_verbose "Successfully installed NuGet packages to: $nuget_install_dir" + say_info "NuGet packages successfully installed to: ${GREEN}$nuget_install_dir${RESET}" + return 0 +} + +# ============================================================================= +# Main download and install function +# ============================================================================= + +download_and_install_bundle() { + local temp_dir="$1" + local head_sha workflow_run_id rid + + if [[ -n "$WORKFLOW_RUN_ID" ]]; then + say_info "Starting bundle download for PR #$PR_NUMBER with workflow run ID: $WORKFLOW_RUN_ID" + workflow_run_id="$WORKFLOW_RUN_ID" + else + say_info "Starting bundle download for PR #$PR_NUMBER" + + if ! head_sha=$(get_pr_head_sha "$PR_NUMBER"); then + return 1 + fi + + if ! workflow_run_id=$(find_workflow_run "$head_sha"); then + return 1 + fi + fi + + say_info "Using workflow run https://github.com/${REPO}/actions/runs/$workflow_run_id" + + # Compute RID + if ! rid=$(get_runtime_identifier "$OS_ARG" "$ARCH_ARG"); then + return 1 + fi + say_verbose "Computed RID: $rid" + + # Download bundle + local download_dir + if ! download_dir=$(download_aspire_bundle "$workflow_run_id" "$rid" "$temp_dir"); then + return 1 + fi + + # Install bundle + if ! install_aspire_bundle "$download_dir" "$INSTALL_PREFIX"; then + return 1 + fi + + # Download and install NuGet hive packages (needed for 'aspire new' and 'aspire add') + local nuget_hive_dir="$INSTALL_PREFIX/hives/pr-$PR_NUMBER/packages" + local nuget_download_dir + if ! nuget_download_dir=$(download_built_nugets "$workflow_run_id" "$rid" "$temp_dir"); then + say_error "Failed to download NuGet packages" + return 1 + fi + + if ! install_built_nugets "$nuget_download_dir" "$nuget_hive_dir"; then + say_error "Failed to install NuGet packages" + return 1 + fi + + # Verify installation + local cli_path="$INSTALL_PREFIX/bin/aspire" + if [[ -f "$cli_path" && "$DRY_RUN" != true ]]; then + say_info "" + say_info "Verifying installation..." + if "$cli_path" --version >/dev/null 2>&1; then + say_success "Bundle verification passed!" + say_info "Installed version: $("$cli_path" --version 2>/dev/null || echo 'unknown')" + else + say_warn "Bundle verification failed - CLI may not work correctly" + fi + fi +} + +# ============================================================================= +# Main Execution +# ============================================================================= + +parse_args "$@" + +if [[ "$SHOW_HELP" == true ]]; then + show_help + exit 0 +fi + +HOST_OS=$(detect_os) + +if [[ "$HOST_OS" == "unsupported" ]]; then + say_error "Unsupported operating system: $(uname -s)" + exit 1 +fi + +check_gh_dependency + +# Set default install prefix if not provided +if [[ -z "$INSTALL_PREFIX" ]]; then + INSTALL_PREFIX="$HOME/.aspire" + INSTALL_PREFIX_UNEXPANDED="\$HOME/.aspire" +else + INSTALL_PREFIX_UNEXPANDED="$INSTALL_PREFIX" +fi + +# Validate install prefix contains only safe characters to prevent shell injection +# when writing to shell profile +if [[ ! "$INSTALL_PREFIX" =~ ^[a-zA-Z0-9/_.-]+$ ]] && [[ ! "$INSTALL_PREFIX" =~ ^\$HOME ]]; then + say_error "Install prefix contains invalid characters: $INSTALL_PREFIX" + say_info "Path must contain only alphanumeric characters, /, _, ., and -" + exit 1 +fi + +# Create temporary directory +if [[ "$DRY_RUN" == true ]]; then + temp_dir="/tmp/aspire-bundle-pr-dry-run" +else + temp_dir=$(mktemp -d -t aspire-bundle-pr-download-XXXXXX) + say_verbose "Creating temporary directory: $temp_dir" +fi + +# Set trap for cleanup +cleanup() { + remove_temp_dir "$temp_dir" +} +trap cleanup EXIT + +# Download and install bundle +if ! download_and_install_bundle "$temp_dir"; then + exit 1 +fi + +# Add to shell profile for persistent PATH (use bin/ subdirectory, same as CLI-only install) +if [[ "$SKIP_PATH" != true ]]; then + add_to_shell_profile "$INSTALL_PREFIX/bin" "$INSTALL_PREFIX_UNEXPANDED/bin" + + if [[ ":$PATH:" != *":$INSTALL_PREFIX/bin:"* ]]; then + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would add $INSTALL_PREFIX/bin to PATH" + else + export PATH="$INSTALL_PREFIX/bin:$PATH" + fi + fi +fi + +# Save the global channel setting to the PR channel +# This allows 'aspire new' and 'aspire init' to use the same channel by default +cli_path="$INSTALL_PREFIX/bin/aspire" +if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would run: $cli_path config set -g channel pr-$PR_NUMBER" +else + say_verbose "Setting global config: channel = pr-$PR_NUMBER" + if "$cli_path" config set -g channel "pr-$PR_NUMBER" 2>/dev/null; then + say_verbose "Global config saved: channel = pr-$PR_NUMBER" + else + say_warn "Failed to set global channel config via aspire CLI (non-fatal)" + fi +fi + +say_info "" +say_success "============================================" +say_success " Aspire Bundle from PR #$PR_NUMBER Installed" +say_success "============================================" +say_info "" +say_info "Bundle location: $INSTALL_PREFIX" +say_info "" +say_info "To use:" +say_info " $INSTALL_PREFIX/bin/aspire --help" +say_info " $INSTALL_PREFIX/bin/aspire run" +say_info "" +say_info "The bundle includes everything needed to run Aspire apps" +say_info "without requiring a globally-installed .NET SDK." diff --git a/eng/scripts/install-aspire-bundle.ps1 b/eng/scripts/install-aspire-bundle.ps1 new file mode 100644 index 00000000000..2b2e89ec528 --- /dev/null +++ b/eng/scripts/install-aspire-bundle.ps1 @@ -0,0 +1,414 @@ +<# +.SYNOPSIS + Downloads and installs the Aspire Bundle (self-contained distribution). + +.DESCRIPTION + This script downloads and installs the Aspire Bundle, which includes everything + needed to run Aspire applications without a .NET SDK. + + NOTE: This script is different from get-aspire-cli-pr.ps1: + - install-aspire-bundle.ps1: Installs the full self-contained bundle (runtime, dashboard, DCP, etc.) + for polyglot development without requiring .NET SDK. + - get-aspire-cli-pr.ps1: Downloads just the CLI from a PR build for testing/development purposes. + + The bundle includes: + + - Aspire CLI (native AOT) + - .NET Runtime + - Aspire Dashboard + - Developer Control Plane (DCP) + - Pre-built AppHost Server + - NuGet Helper Tool + + This enables polyglot development (TypeScript, Python, Go, etc.) without + requiring a global .NET SDK installation. + +.PARAMETER InstallPath + Directory to install the bundle. Default: $env:LOCALAPPDATA\Aspire + +.PARAMETER Version + Specific version to install (e.g., "9.2.0"). Default: latest release. + +.PARAMETER Architecture + Architecture to install (x64, arm64). Default: auto-detect. + +.PARAMETER SkipPath + Do not add aspire to PATH environment variable. + +.PARAMETER Force + Overwrite existing installation. + +.PARAMETER DryRun + Show what would be done without installing. + +.PARAMETER Verbose + Enable verbose output. + +.EXAMPLE + .\install-aspire-bundle.ps1 + Installs the latest version to the default location. + +.EXAMPLE + .\install-aspire-bundle.ps1 -Version "9.2.0" + Installs a specific version. + +.EXAMPLE + .\install-aspire-bundle.ps1 -InstallPath "C:\Tools\Aspire" + Installs to a custom location. + +.EXAMPLE + iex ((New-Object System.Net.WebClient).DownloadString('https://aka.ms/install-aspire-bundle.ps1')) + Piped execution from URL. + +.NOTES + After installation, you may need to restart your terminal. + + To update an existing installation: + aspire update --self + + To uninstall: + Remove-Item -Recurse -Force "$env:LOCALAPPDATA\Aspire" +#> + +[CmdletBinding()] +param( + [string]$InstallPath = "", + [string]$Version = "", + [ValidateSet("x64", "arm64", "")] + [string]$Architecture = "", + [switch]$SkipPath, + [switch]$Force, + [switch]$DryRun +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" # Speeds up Invoke-WebRequest + +# Constants +$ScriptVersion = "1.0.0" +$GitHubRepo = "dotnet/aspire" +$GitHubReleasesApi = "https://api.github.com/repos/$GitHubRepo/releases" +$UserAgent = "install-aspire-bundle.ps1/$ScriptVersion" + +# ═══════════════════════════════════════════════════════════════════════════════ +# LOGGING FUNCTIONS +# ═══════════════════════════════════════════════════════════════════════════════ + +function Write-Status { + param([string]$Message) + Write-Host "aspire-bundle: " -ForegroundColor Green -NoNewline + Write-Host $Message +} + +function Write-Info { + param([string]$Message) + Write-Host "aspire-bundle: " -ForegroundColor Cyan -NoNewline + Write-Host $Message +} + +function Write-Warn { + param([string]$Message) + Write-Host "aspire-bundle: WARNING: " -ForegroundColor Yellow -NoNewline + Write-Host $Message +} + +function Write-Err { + param([string]$Message) + Write-Host "aspire-bundle: ERROR: " -ForegroundColor Red -NoNewline + Write-Host $Message +} + +function Write-Verbose-Log { + param([string]$Message) + if ($VerbosePreference -eq "Continue") { + Write-Host "aspire-bundle: [VERBOSE] " -ForegroundColor DarkGray -NoNewline + Write-Host $Message -ForegroundColor DarkGray + } +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# PLATFORM DETECTION +# ═══════════════════════════════════════════════════════════════════════════════ + +function Get-Architecture { + if ($Architecture) { + Write-Verbose-Log "Using specified architecture: $Architecture" + return $Architecture + } + + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + switch ($arch) { + "X64" { return "x64" } + "Arm64" { return "arm64" } + default { + Write-Err "Unsupported architecture: $arch" + exit 1 + } + } +} + +function Get-PlatformRid { + $arch = Get-Architecture + return "win-$arch" +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# VERSION RESOLUTION +# ═══════════════════════════════════════════════════════════════════════════════ + +function Get-LatestVersion { + Write-Verbose-Log "Querying GitHub for latest release..." + + try { + $headers = @{ + "User-Agent" = $UserAgent + "Accept" = "application/vnd.github+json" + } + + $response = Invoke-RestMethod -Uri "$GitHubReleasesApi/latest" -Headers $headers -TimeoutSec 30 + $tagName = $response.tag_name + + if (-not $tagName) { + Write-Err "Could not determine latest version from GitHub" + exit 1 + } + + # Remove 'v' prefix if present + $version = $tagName -replace "^v", "" + Write-Verbose-Log "Latest version: $version" + return $version + } + catch { + Write-Err "Failed to query GitHub releases API: $_" + exit 1 + } +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# DOWNLOAD AND INSTALLATION +# ═══════════════════════════════════════════════════════════════════════════════ + +function Get-DownloadUrl { + param([string]$Ver) + + $rid = Get-PlatformRid + $filename = "aspire-bundle-$Ver-$rid.zip" + return "https://github.com/$GitHubRepo/releases/download/v$Ver/$filename" +} + +function Download-Bundle { + param( + [string]$Url, + [string]$OutputPath + ) + + $rid = Get-PlatformRid + Write-Status "Downloading Aspire Bundle v$Version for $rid..." + Write-Verbose-Log "URL: $Url" + + if ($DryRun) { + Write-Info "[DRY RUN] Would download: $Url" + return + } + + try { + $headers = @{ "User-Agent" = $UserAgent } + Invoke-WebRequest -Uri $Url -OutFile $OutputPath -Headers $headers -TimeoutSec 600 -UseBasicParsing + Write-Verbose-Log "Download complete: $OutputPath" + } + catch { + Write-Err "Failed to download bundle from: $Url" + Write-Host "" + Write-Info "Possible causes:" + Write-Info " - Version $Version may not have a bundle release yet" + Write-Info " - Platform $rid may not be supported" + Write-Info " - Network connectivity issues" + Write-Host "" + Write-Info "Check available releases at:" + Write-Info " https://github.com/$GitHubRepo/releases" + exit 1 + } +} + +function Extract-Bundle { + param( + [string]$ArchivePath, + [string]$DestPath + ) + + Write-Status "Extracting bundle to $DestPath..." + + if ($DryRun) { + Write-Info "[DRY RUN] Would extract to: $DestPath" + return + } + + # Create destination directory + if (-not (Test-Path $DestPath)) { + New-Item -ItemType Directory -Path $DestPath -Force | Out-Null + } + + try { + Expand-Archive -Path $ArchivePath -DestinationPath $DestPath -Force + Write-Verbose-Log "Extraction complete" + } + catch { + Write-Err "Failed to extract bundle archive: $_" + exit 1 + } +} + +function Verify-Installation { + param([string]$InstallDir) + + $cliPath = Join-Path $InstallDir "aspire.exe" + + if (-not (Test-Path $cliPath)) { + Write-Err "Installation verification failed: CLI not found" + exit 1 + } + + try { + $versionOutput = & $cliPath --version 2>&1 + Write-Verbose-Log "Installed version: $versionOutput" + } + catch { + Write-Warn "Could not verify CLI version" + } +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# PATH CONFIGURATION +# ═══════════════════════════════════════════════════════════════════════════════ + +function Configure-Path { + param([string]$InstallDir) + + if ($SkipPath) { + Write-Verbose-Log "Skipping PATH configuration (-SkipPath specified)" + return + } + + if ($DryRun) { + Write-Info "[DRY RUN] Would add to PATH: $InstallDir" + return + } + + # Check if already in PATH + $currentPath = [Environment]::GetEnvironmentVariable("PATH", "User") + if ($currentPath -split ";" | Where-Object { $_ -eq $InstallDir }) { + Write-Verbose-Log "Install directory already in PATH" + return + } + + # Add to user PATH + $newPath = "$InstallDir;$currentPath" + [Environment]::SetEnvironmentVariable("PATH", $newPath, "User") + + # Update current session + $env:PATH = "$InstallDir;$env:PATH" + + # GitHub Actions support + if ($env:GITHUB_PATH) { + Add-Content -Path $env:GITHUB_PATH -Value $InstallDir + Write-Verbose-Log "Added to GITHUB_PATH for CI" + } + + Write-Info "Added $InstallDir to user PATH" +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# MAIN +# ═══════════════════════════════════════════════════════════════════════════════ + +function Main { + Write-Status "Aspire Bundle Installer v$ScriptVersion" + Write-Host "" + + # Set defaults + if (-not $InstallPath) { + $InstallPath = if ($env:ASPIRE_INSTALL_PATH) { + $env:ASPIRE_INSTALL_PATH + } else { + Join-Path $env:LOCALAPPDATA "Aspire" + } + } + + if (-not $Version) { + $Version = if ($env:ASPIRE_BUNDLE_VERSION) { + $env:ASPIRE_BUNDLE_VERSION + } else { + Get-LatestVersion + } + } + + $rid = Get-PlatformRid + + Write-Info "Version: $Version" + Write-Info "Platform: $rid" + Write-Info "Install path: $InstallPath" + Write-Host "" + + # Check for existing installation + $cliPath = Join-Path $InstallPath "aspire.exe" + if ((Test-Path $cliPath) -and -not $Force -and -not $DryRun) { + Write-Warn "Aspire is already installed at $InstallPath" + Write-Info "Use -Force to overwrite, or run 'aspire update --self' to update" + exit 1 + } + + # Create temp directory + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-bundle-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + + try { + $archivePath = Join-Path $tempDir "aspire-bundle.zip" + $downloadUrl = Get-DownloadUrl -Ver $Version + + # Download + Download-Bundle -Url $downloadUrl -OutputPath $archivePath + + # Remove existing installation if -Force + if ((Test-Path $InstallPath) -and $Force -and -not $DryRun) { + Write-Verbose-Log "Removing existing installation..." + Remove-Item -Path $InstallPath -Recurse -Force + } + + # Extract + Extract-Bundle -ArchivePath $archivePath -DestPath $InstallPath + + # Verify + if (-not $DryRun) { + Verify-Installation -InstallDir $InstallPath + } + + # Configure PATH + Configure-Path -InstallDir $InstallPath + + Write-Host "" + Write-Host "aspire-bundle: " -ForegroundColor Green -NoNewline + Write-Host "✓ " -ForegroundColor Green -NoNewline + Write-Host "Aspire Bundle v$Version installed successfully!" + Write-Host "" + + if ($SkipPath) { + Write-Info "To use aspire, add to your PATH:" + Write-Info " `$env:PATH = `"$InstallPath;`$env:PATH`"" + } else { + Write-Info "You may need to restart your terminal for PATH changes to take effect." + } + Write-Host "" + Write-Info "Get started:" + Write-Info " aspire new" + Write-Info " aspire run" + Write-Host "" + } + finally { + # Cleanup temp directory + if (Test-Path $tempDir) { + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Main diff --git a/eng/scripts/install-aspire-bundle.sh b/eng/scripts/install-aspire-bundle.sh new file mode 100644 index 00000000000..2220b507f2b --- /dev/null +++ b/eng/scripts/install-aspire-bundle.sh @@ -0,0 +1,609 @@ +#!/usr/bin/env bash + +# install-aspire-bundle.sh - Download and install the Aspire Bundle (self-contained distribution) +# Usage: ./install-aspire-bundle.sh [OPTIONS] +# curl -sSL /install-aspire-bundle.sh | bash -s -- [OPTIONS] + +set -euo pipefail + +# Global constants +readonly SCRIPT_VERSION="1.0.0" +readonly USER_AGENT="install-aspire-bundle.sh/${SCRIPT_VERSION}" +readonly DOWNLOAD_TIMEOUT_SEC=600 +readonly GITHUB_REPO="dotnet/aspire" +readonly GITHUB_RELEASES_API="https://api.github.com/repos/${GITHUB_REPO}/releases" + +# Colors for output +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly RESET='\033[0m' + +# Default values +INSTALL_PATH="" +VERSION="" +OS="" +ARCH="" +SHOW_HELP=false +VERBOSE=false +DRY_RUN=false +SKIP_PATH=false +FORCE=false + +# ═══════════════════════════════════════════════════════════════════════════════ +# LOGGING FUNCTIONS +# ═══════════════════════════════════════════════════════════════════════════════ + +say() { + echo -e "${GREEN}aspire-bundle:${RESET} $*" +} + +say_info() { + echo -e "${BLUE}aspire-bundle:${RESET} $*" +} + +say_warning() { + echo -e "${YELLOW}aspire-bundle: WARNING:${RESET} $*" >&2 +} + +say_error() { + echo -e "${RED}aspire-bundle: ERROR:${RESET} $*" >&2 +} + +say_verbose() { + if [[ "$VERBOSE" == true ]]; then + echo -e "${BLUE}aspire-bundle: [VERBOSE]${RESET} $*" + fi +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# HELP +# ═══════════════════════════════════════════════════════════════════════════════ + +show_help() { + cat << 'EOF' +Aspire Bundle Installation Script + +DESCRIPTION: + Downloads and installs the Aspire Bundle - a self-contained distribution that + includes everything needed to run Aspire applications without a .NET SDK: + + • Aspire CLI (native AOT) + • .NET Runtime + • Aspire Dashboard + • Developer Control Plane (DCP) + • Pre-built AppHost Server + • NuGet Helper Tool + + This enables polyglot development (TypeScript, Python, Go, etc.) without + requiring a global .NET SDK installation. + +USAGE: + ./install-aspire-bundle.sh [OPTIONS] + +OPTIONS: + -i, --install-path PATH Directory to install the bundle + Default: $HOME/.aspire + --version VERSION Specific version to install (e.g., "9.2.0") + Default: latest release + --os OS Operating system (linux, osx) + Default: auto-detect + --arch ARCH Architecture (x64, arm64) + Default: auto-detect + --skip-path Do not add aspire to PATH + --force Overwrite existing installation + --dry-run Show what would be done without installing + -v, --verbose Enable verbose output + -h, --help Show this help message + +EXAMPLES: + # Install latest version + ./install-aspire-bundle.sh + + # Install specific version + ./install-aspire-bundle.sh --version "9.2.0" + + # Install to custom location + ./install-aspire-bundle.sh --install-path "/opt/aspire" + + # Piped execution + curl -sSL https://aka.ms/install-aspire-bundle.sh | bash + curl -sSL https://aka.ms/install-aspire-bundle.sh | bash -s -- --version "9.2.0" + +ENVIRONMENT VARIABLES: + ASPIRE_INSTALL_PATH Default installation path + ASPIRE_BUNDLE_VERSION Default version to install + +NOTES: + After installation, you may need to restart your shell or run: + source ~/.bashrc (or ~/.zshrc) + + To update an existing installation: + aspire update --self + + To uninstall: + rm -rf ~/.aspire + +EOF +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# ARGUMENT PARSING +# ═══════════════════════════════════════════════════════════════════════════════ + +parse_args() { + while [[ $# -gt 0 ]]; do + case $1 in + -i|--install-path) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + exit 1 + fi + INSTALL_PATH="$2" + shift 2 + ;; + --version) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + exit 1 + fi + VERSION="$2" + shift 2 + ;; + --os) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + exit 1 + fi + OS="$2" + shift 2 + ;; + --arch) + if [[ $# -lt 2 || -z "$2" ]]; then + say_error "Option '$1' requires a non-empty value" + exit 1 + fi + ARCH="$2" + shift 2 + ;; + --skip-path) + SKIP_PATH=true + shift + ;; + --force) + FORCE=true + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + -v|--verbose) + VERBOSE=true + shift + ;; + -h|--help) + SHOW_HELP=true + shift + ;; + *) + say_error "Unknown option: $1" + say_info "Use --help for usage information." + exit 1 + ;; + esac + done +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# PLATFORM DETECTION +# ═══════════════════════════════════════════════════════════════════════════════ + +# Supported RIDs for the bundle +readonly SUPPORTED_RIDS="linux-x64 linux-arm64 osx-x64 osx-arm64" + +detect_os() { + if [[ -n "$OS" ]]; then + say_verbose "Using specified OS: $OS" + return + fi + + local uname_out + uname_out="$(uname -s)" + + case "$uname_out" in + Linux*) + OS="linux" + ;; + Darwin*) + OS="osx" + ;; + *) + say_error "Unsupported operating system: $uname_out" + say_info "For Windows, use install-aspire-bundle.ps1" + exit 1 + ;; + esac + + say_verbose "Detected OS: $OS" +} + +detect_arch() { + if [[ -n "$ARCH" ]]; then + say_verbose "Using specified architecture: $ARCH" + return + fi + + local uname_arch + uname_arch="$(uname -m)" + + case "$uname_arch" in + x86_64|amd64) + ARCH="x64" + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) + say_error "Unsupported architecture: $uname_arch" + exit 1 + ;; + esac + + say_verbose "Detected architecture: $ARCH" +} + +get_platform_rid() { + echo "${OS}-${ARCH}" +} + +validate_rid() { + local rid="$1" + + # Check if the RID is in the supported list + if ! echo "$SUPPORTED_RIDS" | grep -qw "$rid"; then + say_error "Unsupported platform: $rid" + say_info "" + say_info "The Aspire Bundle is currently available for:" + for supported_rid in $SUPPORTED_RIDS; do + say_info " • $supported_rid" + done + say_info "" + say_info "If you need support for $rid, please open an issue at:" + say_info " https://github.com/${GITHUB_REPO}/issues" + exit 1 + fi +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# VERSION RESOLUTION +# ═══════════════════════════════════════════════════════════════════════════════ + +get_latest_version() { + say_verbose "Querying GitHub for latest release..." + + local response + response=$(curl -sSL --fail \ + -H "User-Agent: ${USER_AGENT}" \ + -H "Accept: application/vnd.github+json" \ + "${GITHUB_RELEASES_API}/latest" 2>/dev/null) || { + say_error "Failed to query GitHub releases API" + exit 1 + } + + local tag_name + tag_name=$(echo "$response" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4) + + if [[ -z "$tag_name" ]]; then + say_error "Could not determine latest version from GitHub" + exit 1 + fi + + # Remove 'v' prefix if present + VERSION="${tag_name#v}" + say_verbose "Latest version: $VERSION" +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# DOWNLOAD AND INSTALLATION +# ═══════════════════════════════════════════════════════════════════════════════ + +get_download_url() { + local rid + rid=$(get_platform_rid) + + # Bundle filename pattern: aspire-bundle-{version}-{rid}.tar.gz + local filename="aspire-bundle-${VERSION}-${rid}.tar.gz" + + echo "https://github.com/${GITHUB_REPO}/releases/download/v${VERSION}/${filename}" +} + +download_bundle() { + local url="$1" + local output="$2" + + say "Downloading Aspire Bundle v${VERSION} for $(get_platform_rid)..." + say_verbose "URL: $url" + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would download: $url" + return 0 + fi + + local http_code + http_code=$(curl -sSL --fail \ + -H "User-Agent: ${USER_AGENT}" \ + -w "%{http_code}" \ + --connect-timeout 30 \ + --max-time "${DOWNLOAD_TIMEOUT_SEC}" \ + -o "$output" \ + "$url" 2>/dev/null) || { + say_error "Failed to download bundle from: $url" + say_info "HTTP status: $http_code" + say_info "" + say_info "Possible causes:" + say_info " • Version ${VERSION} may not have a bundle release yet" + say_info " • Platform $(get_platform_rid) may not be supported" + say_info " • Network connectivity issues" + say_info "" + say_info "Check available releases at:" + say_info " https://github.com/${GITHUB_REPO}/releases" + exit 1 + } + + say_verbose "Download complete: $output" +} + +extract_bundle() { + local archive="$1" + local dest="$2" + + say "Extracting bundle to ${dest}..." + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would extract to: $dest" + return 0 + fi + + # Create destination directory + mkdir -p "$dest" + + # Extract tarball + tar -xzf "$archive" -C "$dest" --strip-components=1 || { + say_error "Failed to extract bundle archive" + exit 1 + } + + # Make executables executable (permissions may not be preserved in archive) + chmod +x "${dest}/aspire" 2>/dev/null || true + + # Make .NET runtime executable + if [[ -f "${dest}/runtime/dotnet" ]]; then + chmod +x "${dest}/runtime/dotnet" + fi + + # Make DCP executable + if [[ -f "${dest}/dcp/dcp" ]]; then + chmod +x "${dest}/dcp/dcp" + fi + + # Make Dashboard executable + if [[ -f "${dest}/dashboard/Aspire.Dashboard" ]]; then + chmod +x "${dest}/dashboard/Aspire.Dashboard" + fi + + # Make AppHost Server executable + if [[ -f "${dest}/aspire-server/aspire-server" ]]; then + chmod +x "${dest}/aspire-server/aspire-server" + fi + + # Make all tools executable + if [[ -d "${dest}/tools" ]]; then + find "${dest}/tools" -type f -exec chmod +x {} \; 2>/dev/null || true + fi + + say_verbose "Extraction complete" +} + +verify_installation() { + local install_dir="$1" + local cli_path="${install_dir}/aspire" + + if [[ ! -x "$cli_path" ]]; then + say_error "Installation verification failed: CLI not found or not executable" + exit 1 + fi + + # Try to run aspire --version + local version_output + version_output=$("$cli_path" --version 2>/dev/null) || { + say_warning "Could not verify CLI version" + return 0 + } + + say_verbose "Installed version: $version_output" +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# PATH CONFIGURATION +# ═══════════════════════════════════════════════════════════════════════════════ + +configure_path() { + local install_dir="$1" + + if [[ "$SKIP_PATH" == true ]]; then + say_verbose "Skipping PATH configuration (--skip-path specified)" + return 0 + fi + + if [[ "$DRY_RUN" == true ]]; then + say_info "[DRY RUN] Would add to PATH: $install_dir" + return 0 + fi + + # Check if already in PATH + if [[ ":$PATH:" == *":${install_dir}:"* ]]; then + say_verbose "Install directory already in PATH" + return 0 + fi + + # Detect shell config file + local shell_config="" + local shell_name="${SHELL##*/}" + + case "$shell_name" in + bash) + if [[ -f "$HOME/.bashrc" ]]; then + shell_config="$HOME/.bashrc" + elif [[ -f "$HOME/.bash_profile" ]]; then + shell_config="$HOME/.bash_profile" + fi + ;; + zsh) + shell_config="$HOME/.zshrc" + ;; + fish) + shell_config="$HOME/.config/fish/config.fish" + ;; + esac + + if [[ -z "$shell_config" ]]; then + say_warning "Could not detect shell config file" + say_info "Add this to your shell profile:" + say_info " export PATH=\"${install_dir}:\$PATH\"" + return 0 + fi + + # Check if export already exists + if grep -q "export PATH=.*${install_dir}" "$shell_config" 2>/dev/null; then + say_verbose "PATH export already exists in $shell_config" + return 0 + fi + + # Add to shell config + say_verbose "Adding to $shell_config" + echo "" >> "$shell_config" + echo "# Aspire CLI" >> "$shell_config" + echo "export PATH=\"${install_dir}:\$PATH\"" >> "$shell_config" + + # Update current session PATH + export PATH="${install_dir}:$PATH" + + # Check for GitHub Actions + if [[ -n "${GITHUB_PATH:-}" ]]; then + echo "$install_dir" >> "$GITHUB_PATH" + say_verbose "Added to GITHUB_PATH for CI" + fi + + say_info "Added ${install_dir} to PATH in ${shell_config}" +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# MAIN +# ═══════════════════════════════════════════════════════════════════════════════ + +main() { + parse_args "$@" + + if [[ "$SHOW_HELP" == true ]]; then + show_help + exit 0 + fi + + say "Aspire Bundle Installer v${SCRIPT_VERSION}" + echo "" + + # Detect platform + detect_os + detect_arch + + # Validate the RID is supported + validate_rid "$(get_platform_rid)" + + # Set defaults + if [[ -z "$INSTALL_PATH" ]]; then + INSTALL_PATH="${ASPIRE_INSTALL_PATH:-$HOME/.aspire}" + fi + + if [[ -z "$VERSION" ]]; then + VERSION="${ASPIRE_BUNDLE_VERSION:-}" + if [[ -z "$VERSION" ]]; then + get_latest_version + fi + fi + + # Expand ~ in install path + INSTALL_PATH="${INSTALL_PATH/#\~/$HOME}" + + # Validate install path contains only safe characters to prevent shell injection + if [[ ! "$INSTALL_PATH" =~ ^[a-zA-Z0-9/_.-]+$ ]]; then + say_error "Install path contains invalid characters: $INSTALL_PATH" + say_info "Path must contain only alphanumeric characters, /, _, ., and -" + exit 1 + fi + + say_info "Version: ${VERSION}" + say_info "Platform: $(get_platform_rid)" + say_info "Install path: ${INSTALL_PATH}" + echo "" + + # Check for existing installation + if [[ -d "$INSTALL_PATH" && "$FORCE" != true && "$DRY_RUN" != true ]]; then + if [[ -f "${INSTALL_PATH}/aspire" ]]; then + say_warning "Aspire is already installed at ${INSTALL_PATH}" + say_info "Use --force to overwrite, or run 'aspire update --self' to update" + exit 1 + fi + fi + + # Create temp directory + local temp_dir + temp_dir=$(mktemp -d) + trap "rm -rf '$temp_dir'" EXIT + + local archive_path="${temp_dir}/aspire-bundle.tar.gz" + local download_url + download_url=$(get_download_url) + + # Download + download_bundle "$download_url" "$archive_path" + + # Extract + if [[ "$DRY_RUN" != true ]]; then + # Remove existing installation if --force + if [[ -d "$INSTALL_PATH" && "$FORCE" == true ]]; then + say_verbose "Removing existing installation..." + rm -rf "$INSTALL_PATH" + fi + fi + + extract_bundle "$archive_path" "$INSTALL_PATH" + + # Verify + if [[ "$DRY_RUN" != true ]]; then + verify_installation "$INSTALL_PATH" + fi + + # Configure PATH + configure_path "$INSTALL_PATH" + + echo "" + say "${GREEN}✓${RESET} Aspire Bundle v${VERSION} installed successfully!" + echo "" + + if [[ "$SKIP_PATH" == true ]]; then + say_info "To use aspire, add to your PATH:" + say_info " export PATH=\"${INSTALL_PATH}:\$PATH\"" + else + say_info "You may need to restart your shell or run:" + say_info " source ~/.bashrc (or ~/.zshrc)" + fi + echo "" + say_info "Get started:" + say_info " aspire new" + say_info " aspire run" + echo "" +} + +main "$@" diff --git a/localhive.ps1 b/localhive.ps1 index 0ee3547ec8c..0cce5d158af 100644 --- a/localhive.ps1 +++ b/localhive.ps1 @@ -2,11 +2,12 @@ <#! .SYNOPSIS - Build local NuGet packages and create/update an Aspire CLI hive that points at them (Windows/PowerShell). + Build local NuGet packages and Aspire CLI, then create/update a hive and install the CLI (Windows/PowerShell). .DESCRIPTION - Mirrors localhive.sh behavior on Windows. Packs the repo, then either creates a symlink from - $HOME/.aspire/hives/ to artifacts/packages//Shipping or copies .nupkg files. + Mirrors localhive.sh behavior on Windows. Packs the repo, creates a symlink from + $HOME/.aspire/hives/ to artifacts/packages//Shipping (or copies .nupkg files), + and installs the locally-built Aspire CLI to $HOME/.aspire/bin. .PARAMETER Configuration Build configuration: Release or Debug (positional parameter 0). If omitted, the script tries Release then falls back to Debug. @@ -20,6 +21,9 @@ .PARAMETER Copy Copy .nupkg files instead of linking the hive directory. +.PARAMETER SkipCli + Skip installing the locally-built CLI to $HOME/.aspire/bin. + .PARAMETER Help Show help and exit. @@ -29,8 +33,12 @@ .EXAMPLE .\localhive.ps1 Debug my-feature +.EXAMPLE + .\localhive.ps1 -SkipCli + .NOTES The hive is created at $HOME/.aspire/hives/ so the Aspire CLI can discover a channel. + The CLI is installed to $HOME/.aspire/bin so it can be used directly. #> [CmdletBinding(PositionalBinding=$true)] @@ -48,6 +56,8 @@ param( [switch] $Copy, + [switch] $SkipCli, + [Alias('h')] [switch] $Help ) @@ -69,6 +79,7 @@ Options: -Name (-n) Hive name (default: local) -VersionSuffix (-v) Prerelease version suffix (default: auto-generates local.YYYYMMDD.tHHmmss) -Copy Copy .nupkg files instead of creating a symlink + -SkipCli Skip installing the locally-built CLI to $HOME\.aspire\bin -Help (-h) Show this help and exit Examples: @@ -81,6 +92,7 @@ Examples: This will pack NuGet packages into artifacts\packages\\Shipping and create/update a hive at $HOME\.aspire\hives\ so the Aspire CLI can use it as a channel. +It also installs the locally-built CLI to $HOME\.aspire\bin (unless -SkipCli is specified). '@ | Write-Host } @@ -141,9 +153,11 @@ function Get-PackagesPath { Join-Path (Join-Path (Join-Path (Join-Path $RepoRoot 'artifacts') 'packages') $Config) 'Shipping' } +$effectiveConfig = if ($Configuration) { $Configuration } else { 'Release' } + if ($Configuration) { Write-Log "Building and packing NuGet packages [-c $Configuration] with versionsuffix '$VersionSuffix'" - & $buildScript -r -b -pack -c $Configuration "/p:VersionSuffix=$VersionSuffix" "/p:SkipTestProjects=true" "/p:SkipPlaygroundProjects=true" + & $buildScript -restore -build -pack -c $Configuration "/p:VersionSuffix=$VersionSuffix" "/p:SkipTestProjects=true" "/p:SkipPlaygroundProjects=true" if ($LASTEXITCODE -ne 0) { Write-Err "Build failed for configuration $Configuration." exit 1 @@ -156,7 +170,7 @@ if ($Configuration) { } else { Write-Log "Building and packing NuGet packages [-c Release] with versionsuffix '$VersionSuffix'" - & $buildScript -r -b -pack -c Release "/p:VersionSuffix=$VersionSuffix" "/p:SkipTestProjects=true" "/p:SkipPlaygroundProjects=true" + & $buildScript -restore -build -pack -c Release "/p:VersionSuffix=$VersionSuffix" "/p:SkipTestProjects=true" "/p:SkipPlaygroundProjects=true" if ($LASTEXITCODE -ne 0) { Write-Err "Build failed for configuration Release." exit 1 @@ -177,11 +191,18 @@ if (-not $packages -or $packages.Count -eq 0) { Write-Log ("Found {0} packages in {1}" -f $packages.Count, $pkgDir) $hivesRoot = Join-Path (Join-Path $HOME '.aspire') 'hives' -$hivePath = Join-Path $hivesRoot $Name +$hiveRoot = Join-Path $hivesRoot $Name +$hivePath = Join-Path $hiveRoot 'packages' Write-Log "Preparing hive directory: $hivesRoot" New-Item -ItemType Directory -Path $hivesRoot -Force | Out-Null +# Remove previous hive content (handles both old layout junctions and stale data) +if (Test-Path -LiteralPath $hiveRoot) { + Write-Log "Removing previous hive '$Name'" + Remove-Item -LiteralPath $hiveRoot -Force -Recurse -ErrorAction SilentlyContinue +} + function Copy-PackagesToHive { param([string]$Source,[string]$Destination) New-Item -ItemType Directory -Path $Destination -Force | Out-Null @@ -194,25 +215,18 @@ if ($Copy) { Write-Log "Created/updated hive '$Name' at $hivePath (copied packages)." } else { - Write-Log "Linking hive '$Name' to $pkgDir" + Write-Log "Linking hive '$Name/packages' to $pkgDir" + New-Item -ItemType Directory -Path $hiveRoot -Force | Out-Null try { - if (Test-Path -LiteralPath $hivePath) { - $item = Get-Item -LiteralPath $hivePath -ErrorAction SilentlyContinue - if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { - # Remove existing link (symlink/junction) - Remove-Item -LiteralPath $hivePath -Force - } - } # Try symlink first (requires Developer Mode or elevated privilege) New-Item -Path $hivePath -ItemType SymbolicLink -Target $pkgDir -Force | Out-Null - Write-Log "Created/updated hive '$Name' -> $pkgDir (symlink)" + Write-Log "Created/updated hive '$Name/packages' -> $pkgDir (symlink)" } catch { Write-Warn "Symlink not supported; attempting junction, else copying .nupkg files" try { - if (Test-Path -LiteralPath $hivePath) { Remove-Item -LiteralPath $hivePath -Force -Recurse -ErrorAction SilentlyContinue } New-Item -Path $hivePath -ItemType Junction -Target $pkgDir -Force | Out-Null - Write-Log "Created/updated hive '$Name' -> $pkgDir (junction)" + Write-Log "Created/updated hive '$Name/packages' -> $pkgDir (junction)" } catch { Write-Warn "Link creation failed; copying .nupkg files instead" @@ -222,6 +236,44 @@ else { } } +# Install the locally-built CLI to $HOME/.aspire/bin +if (-not $SkipCli) { + $cliBinDir = Join-Path (Join-Path $HOME '.aspire') 'bin' + # The CLI is built as part of the pack target in artifacts/bin/Aspire.Cli.Tool//net10.0/publish + $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0" "publish" + + if (-not (Test-Path -LiteralPath $cliPublishDir)) { + # Fallback: try the non-publish directory + $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0" + } + + $cliExeName = if ($IsWindows) { 'aspire.exe' } else { 'aspire' } + $cliSourcePath = Join-Path $cliPublishDir $cliExeName + + if (Test-Path -LiteralPath $cliSourcePath) { + Write-Log "Installing Aspire CLI to $cliBinDir" + New-Item -ItemType Directory -Path $cliBinDir -Force | Out-Null + + # Copy all files from the publish directory (CLI and its dependencies) + Get-ChildItem -LiteralPath $cliPublishDir -File | Copy-Item -Destination $cliBinDir -Force + + $installedCliPath = Join-Path $cliBinDir $cliExeName + Write-Log "Aspire CLI installed to: $installedCliPath" + + # Check if the bin directory is in PATH + $pathSeparator = [System.IO.Path]::PathSeparator + $currentPathArray = $env:PATH.Split($pathSeparator, [StringSplitOptions]::RemoveEmptyEntries) + if ($currentPathArray -notcontains $cliBinDir) { + Write-Warn "The CLI bin directory is not in your PATH." + Write-Log "Add it to your PATH with: `$env:PATH = '$cliBinDir' + '$pathSeparator' + `$env:PATH" + } + } + else { + Write-Warn "Could not find CLI at $cliSourcePath. Skipping CLI installation." + Write-Warn "You may need to build the CLI separately or use 'dotnet tool install' for the Aspire.Cli package." + } +} + Write-Host Write-Log 'Done.' Write-Host @@ -230,4 +282,8 @@ Write-Log " $hivePath" Write-Host Write-Log "Channel behavior: Aspire* comes from the hive; others from nuget.org." Write-Host +if (-not $SkipCli) { + Write-Log "The locally-built CLI was installed to: $(Join-Path (Join-Path $HOME '.aspire') 'bin')" + Write-Host +} Write-Log 'The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required.' diff --git a/localhive.sh b/localhive.sh index 687ec3cd32c..40c08fcc6e3 100755 --- a/localhive.sh +++ b/localhive.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Build local NuGet packages and create/update an Aspire CLI hive that points at them. +# Build local NuGet packages and Aspire CLI, then create/update a hive and install the CLI. # # Usage: # ./localhive.sh [options] @@ -11,11 +11,13 @@ # -n, --name Hive name (default: local) # -v, --versionsuffix Prerelease version suffix (default: auto-generates local.YYYYMMDD.tHHmmss) # --copy Copy .nupkg files instead of creating a symlink +# --skip-cli Skip installing the locally-built CLI to $HOME/.aspire/bin # -h, --help Show this help and exit # # Notes: # - If no configuration is specified, the script tries Release then Debug. # - The hive is created at $HOME/.aspire/hives/ so the Aspire CLI can discover a channel. +# - The CLI is installed to $HOME/.aspire/bin so it can be used directly. set -euo pipefail @@ -30,15 +32,18 @@ Options: -n, --name Hive name (default: local) -v, --versionsuffix Prerelease version suffix (default: auto-generates local.YYYYMMDD.tHHmmss) --copy Copy .nupkg files instead of creating a symlink + --skip-cli Skip installing the locally-built CLI to \$HOME/.aspire/bin -h, --help Show this help and exit Examples: ./localhive.sh -c Release -n local ./localhive.sh Debug my-feature ./localhive.sh -c Release -n demo -v local.20250811.t033324 + ./localhive.sh --skip-cli This will pack NuGet packages into artifacts/packages//Shipping and create/update a hive at \$HOME/.aspire/hives/ so the Aspire CLI can use it as a channel. +It also installs the locally-built CLI to \$HOME/.aspire/bin (unless --skip-cli is specified). EOF } @@ -65,6 +70,7 @@ REPO_ROOT=$(cd "${scriptroot}"; pwd) CONFIG="" HIVE_NAME="local" USE_COPY=0 +SKIP_CLI=0 VERSION_SUFFIX="" is_valid_versionsuffix() { local s="$1" @@ -101,6 +107,8 @@ while [[ $# -gt 0 ]]; do VERSION_SUFFIX="$2"; shift 2 ;; --copy) USE_COPY=1; shift ;; + --skip-cli) + SKIP_CLI=1; shift ;; --) shift; break ;; Release|Debug|release|debug) @@ -135,10 +143,13 @@ if ! is_valid_versionsuffix "$VERSION_SUFFIX"; then fi log "Using prerelease version suffix: $VERSION_SUFFIX" +# Track effective configuration +EFFECTIVE_CONFIG="${CONFIG:-Release}" + if [ -n "$CONFIG" ]; then log "Building and packing NuGet packages [-c $CONFIG] with versionsuffix '$VERSION_SUFFIX'" # Single invocation: restore + build + pack to ensure all Build-triggered targets run and packages are produced. - "$REPO_ROOT/build.sh" -r -b --pack -c "$CONFIG" /p:VersionSuffix="$VERSION_SUFFIX" /p:SkipTestProjects=true /p:SkipPlaygroundProjects=true + "$REPO_ROOT/build.sh" --restore --build --pack -c "$CONFIG" /p:VersionSuffix="$VERSION_SUFFIX" /p:SkipTestProjects=true /p:SkipPlaygroundProjects=true PKG_DIR="$REPO_ROOT/artifacts/packages/$CONFIG/Shipping" if [ ! -d "$PKG_DIR" ]; then error "Could not find packages path $PKG_DIR for CONFIG=$CONFIG" @@ -146,7 +157,7 @@ if [ -n "$CONFIG" ]; then fi else log "Building and packing NuGet packages [-c Release] with versionsuffix '$VERSION_SUFFIX'" - "$REPO_ROOT/build.sh" -r -b --pack -c Release /p:VersionSuffix="$VERSION_SUFFIX" /p:SkipTestProjects=true /p:SkipPlaygroundProjects=true + "$REPO_ROOT/build.sh" --restore --build --pack -c Release /p:VersionSuffix="$VERSION_SUFFIX" /p:SkipTestProjects=true /p:SkipPlaygroundProjects=true PKG_DIR="$REPO_ROOT/artifacts/packages/Release/Shipping" if [ ! -d "$PKG_DIR" ]; then error "Could not find packages path $PKG_DIR for CONFIG=Release" @@ -166,20 +177,28 @@ fi log "Found $pkg_count packages in $PKG_DIR" HIVES_ROOT="$HOME/.aspire/hives" -HIVE_PATH="$HIVES_ROOT/$HIVE_NAME" +HIVE_ROOT="$HIVES_ROOT/$HIVE_NAME" +HIVE_PATH="$HIVE_ROOT/packages" log "Preparing hive directory: $HIVES_ROOT" mkdir -p "$HIVES_ROOT" +# Remove previous hive content (handles both old layout symlinks and stale data) +if [ -e "$HIVE_ROOT" ] || [ -L "$HIVE_ROOT" ]; then + log "Removing previous hive '$HIVE_NAME'" + rm -rf "$HIVE_ROOT" +fi + if [[ $USE_COPY -eq 1 ]]; then log "Populating hive '$HIVE_NAME' by copying .nupkg files" mkdir -p "$HIVE_PATH" cp -f "$PKG_DIR"/*.nupkg "$HIVE_PATH"/ 2>/dev/null || true log "Created/updated hive '$HIVE_NAME' at $HIVE_PATH (copied packages)." else - log "Linking hive '$HIVE_NAME' to $PKG_DIR" + log "Linking hive '$HIVE_NAME/packages' to $PKG_DIR" + mkdir -p "$HIVE_ROOT" if ln -sfn "$PKG_DIR" "$HIVE_PATH" 2>/dev/null; then - log "Created/updated hive '$HIVE_NAME' -> $PKG_DIR" + log "Created/updated hive '$HIVE_NAME/packages' -> $PKG_DIR" else warn "Symlink not supported; copying .nupkg files instead" mkdir -p "$HIVE_PATH" @@ -188,6 +207,42 @@ else fi fi +# Install the locally-built CLI to $HOME/.aspire/bin +if [[ $SKIP_CLI -eq 0 ]]; then + CLI_BIN_DIR="$HOME/.aspire/bin" + # The CLI is built as part of the pack target in artifacts/bin/Aspire.Cli.Tool//net10.0/publish + CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0/publish" + + if [ ! -d "$CLI_PUBLISH_DIR" ]; then + # Fallback: try the non-publish directory + CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0" + fi + + CLI_SOURCE_PATH="$CLI_PUBLISH_DIR/aspire" + + if [ -f "$CLI_SOURCE_PATH" ]; then + log "Installing Aspire CLI to $CLI_BIN_DIR" + mkdir -p "$CLI_BIN_DIR" + + # Copy all files from the publish directory (CLI and its dependencies) + cp -f "$CLI_PUBLISH_DIR"/* "$CLI_BIN_DIR"/ 2>/dev/null || true + + # Ensure the CLI is executable + chmod +x "$CLI_BIN_DIR/aspire" + + log "Aspire CLI installed to: $CLI_BIN_DIR/aspire" + + # Check if the bin directory is in PATH + if [[ ":$PATH:" != *":$CLI_BIN_DIR:"* ]]; then + warn "The CLI bin directory is not in your PATH." + log "Add it to your PATH with: export PATH=\"$CLI_BIN_DIR:\$PATH\"" + fi + else + warn "Could not find CLI at $CLI_SOURCE_PATH. Skipping CLI installation." + warn "You may need to build the CLI separately or use 'dotnet tool install' for the Aspire.Cli package." + fi +fi + echo log "Done." echo @@ -196,4 +251,8 @@ log " $HIVE_PATH" echo log "Channel behavior: Aspire* comes from the hive; others from nuget.org." echo +if [[ $SKIP_CLI -eq 0 ]]; then + log "The locally-built CLI was installed to: $HOME/.aspire/bin" + echo +fi log "The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required." diff --git a/src/Aspire.Cli.NuGetHelper/Aspire.Cli.NuGetHelper.csproj b/src/Aspire.Cli.NuGetHelper/Aspire.Cli.NuGetHelper.csproj new file mode 100644 index 00000000000..842f7e446f9 --- /dev/null +++ b/src/Aspire.Cli.NuGetHelper/Aspire.Cli.NuGetHelper.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + aspire-nuget + Aspire.Cli.NuGetHelper + false + false + + false + + + + + + + + + + + diff --git a/src/Aspire.Cli.NuGetHelper/Commands/LayoutCommand.cs b/src/Aspire.Cli.NuGetHelper/Commands/LayoutCommand.cs new file mode 100644 index 00000000000..8d4f98e85b2 --- /dev/null +++ b/src/Aspire.Cli.NuGetHelper/Commands/LayoutCommand.cs @@ -0,0 +1,244 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Globalization; +using NuGet.ProjectModel; + +namespace Aspire.Cli.NuGetHelper.Commands; + +/// +/// Layout command - creates a flat DLL layout from a project.assets.json file. +/// This enables the AppHost Server to load integration assemblies via probing paths. +/// +public static class LayoutCommand +{ + /// + /// Creates the layout command. + /// + public static Command Create() + { + var command = new Command("layout", "Create flat DLL layout from project.assets.json"); + + var assetsOption = new Option("--assets", "-a") + { + Description = "Path to project.assets.json file", + Required = true + }; + command.Options.Add(assetsOption); + + var outputOption = new Option("--output", "-o") + { + Description = "Output directory for flat DLL layout", + Required = true + }; + command.Options.Add(outputOption); + + var frameworkOption = new Option("--framework", "-f") + { + Description = "Target framework (default: net10.0)", + DefaultValueFactory = _ => "net10.0" + }; + command.Options.Add(frameworkOption); + + var verboseOption = new Option("--verbose", "-v") + { + Description = "Enable verbose output" + }; + command.Options.Add(verboseOption); + + command.SetAction((parseResult, ct) => + { + var assetsPath = parseResult.GetValue(assetsOption)!; + var outputPath = parseResult.GetValue(outputOption)!; + var framework = parseResult.GetValue(frameworkOption)!; + var verbose = parseResult.GetValue(verboseOption); + + return Task.FromResult(ExecuteLayout(assetsPath, outputPath, framework, verbose)); + }); + + return command; + } + + private static int ExecuteLayout( + string assetsPath, + string outputPath, + string framework, + bool verbose) + { + if (!File.Exists(assetsPath)) + { + Console.Error.WriteLine($"Error: Assets file not found: {assetsPath}"); + return 1; + } + + try + { + // Parse the lock file + var lockFileFormat = new LockFileFormat(); + var lockFile = lockFileFormat.Read(assetsPath); + + if (lockFile == null) + { + Console.Error.WriteLine("Error: Failed to parse project.assets.json"); + return 1; + } + + // Find the target for our framework + var target = lockFile.Targets.FirstOrDefault(t => + t.TargetFramework.GetShortFolderName().Equals(framework, StringComparison.OrdinalIgnoreCase) || + t.TargetFramework.ToString().Equals(framework, StringComparison.OrdinalIgnoreCase)); + + if (target == null) + { + Console.Error.WriteLine($"Error: Target framework '{framework}' not found in assets file"); + Console.Error.WriteLine($"Available targets: {string.Join(", ", lockFile.Targets.Select(t => t.TargetFramework.GetShortFolderName()))}"); + return 1; + } + + // Create output directory + Directory.CreateDirectory(outputPath); + + var copiedCount = 0; + var skippedCount = 0; + + // Get the packages path from the lock file + var packagesPath = lockFile.PackageFolders.FirstOrDefault()?.Path; + if (string.IsNullOrEmpty(packagesPath)) + { + packagesPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".nuget", "packages"); + } + + if (verbose) + { + Console.WriteLine($"Using packages path: {packagesPath}"); + Console.WriteLine($"Target framework: {target.TargetFramework.GetShortFolderName()}"); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "Libraries: {0}", target.Libraries.Count)); + } + + // Process each library in the target + foreach (var library in target.Libraries) + { + if (library.Type != "package") + { + continue; + } + + // Get the package folder + var libraryName = library.Name ?? string.Empty; + var libraryVersion = library.Version?.ToString() ?? string.Empty; + var packagePath = Path.Combine(packagesPath, libraryName.ToLowerInvariant(), libraryVersion); + + if (!Directory.Exists(packagePath)) + { + if (verbose) + { + Console.WriteLine($" Skip (not found): {libraryName}/{libraryVersion} at {packagePath}"); + } + + skippedCount++; + continue; + } + + // Copy runtime assemblies + foreach (var runtimeAssembly in library.RuntimeAssemblies) + { + // Skip placeholder files + if (runtimeAssembly.Path.EndsWith("_._", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var sourcePath = Path.Combine(packagePath, runtimeAssembly.Path.Replace('/', Path.DirectorySeparatorChar)); + + // Early exit if source doesn't exist + if (!File.Exists(sourcePath)) + { + continue; + } + + var fileName = Path.GetFileName(sourcePath); + var destPath = Path.Combine(outputPath, fileName); + + // Only copy if newer or doesn't exist + if (!File.Exists(destPath) || + File.GetLastWriteTimeUtc(sourcePath) > File.GetLastWriteTimeUtc(destPath)) + { + File.Copy(sourcePath, destPath, overwrite: true); + copiedCount++; + + if (verbose) + { + Console.WriteLine($" Copy: {sourcePath} -> {destPath}"); + } + } + + // Also copy the XML documentation file if it exists alongside the assembly + var xmlSourcePath = Path.ChangeExtension(sourcePath, ".xml"); + if (File.Exists(xmlSourcePath)) + { + var xmlDestPath = Path.ChangeExtension(destPath, ".xml"); + if (!File.Exists(xmlDestPath) || + File.GetLastWriteTimeUtc(xmlSourcePath) > File.GetLastWriteTimeUtc(xmlDestPath)) + { + File.Copy(xmlSourcePath, xmlDestPath, overwrite: true); + copiedCount++; + + if (verbose) + { + Console.WriteLine($" Copy (xml): {xmlSourcePath} -> {xmlDestPath}"); + } + } + } + } + + // Also copy native libraries if present + foreach (var nativeLib in library.NativeLibraries) + { + var sourcePath = Path.Combine(packagePath, nativeLib.Path.Replace('/', Path.DirectorySeparatorChar)); + + // Early exit if source doesn't exist + if (!File.Exists(sourcePath)) + { + continue; + } + + var fileName = Path.GetFileName(sourcePath); + var destPath = Path.Combine(outputPath, fileName); + + if (!File.Exists(destPath) || + File.GetLastWriteTimeUtc(sourcePath) > File.GetLastWriteTimeUtc(destPath)) + { + File.Copy(sourcePath, destPath, overwrite: true); + copiedCount++; + + if (verbose) + { + Console.WriteLine($" Copy (native): {sourcePath} -> {destPath}"); + } + } + } + } + + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "Layout created: {0} files copied to {1}", copiedCount, outputPath)); + if (skippedCount > 0 && verbose) + { + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, " ({0} packages skipped - not found in cache)", skippedCount)); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + if (verbose) + { + Console.Error.WriteLine(ex.StackTrace); + } + + return 1; + } + } +} diff --git a/src/Aspire.Cli.NuGetHelper/Commands/RestoreCommand.cs b/src/Aspire.Cli.NuGetHelper/Commands/RestoreCommand.cs new file mode 100644 index 00000000000..58ad8705ba0 --- /dev/null +++ b/src/Aspire.Cli.NuGetHelper/Commands/RestoreCommand.cs @@ -0,0 +1,390 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.CommandLine; +using System.Globalization; +using NuGet.Commands; +using NuGet.Configuration; +using NuGet.Frameworks; +using NuGet.LibraryModel; +using NuGet.ProjectModel; +using NuGet.Protocol.Core.Types; +using NuGet.Versioning; + +namespace Aspire.Cli.NuGetHelper.Commands; + +/// +/// Restore command - restores NuGet packages without requiring a .csproj file. +/// Uses NuGet's RestoreRunner to produce a project.assets.json file. +/// +public static class RestoreCommand +{ + /// + /// Creates the restore command. + /// + public static Command Create() + { + var command = new Command("restore", "Restore NuGet packages"); + + var packageOption = new Option("--package", "-p") + { + Description = "Package reference as 'PackageId,Version' (can specify multiple)", + Required = true, + AllowMultipleArgumentsPerToken = true + }; + command.Options.Add(packageOption); + + var frameworkOption = new Option("--framework", "-f") + { + Description = "Target framework (default: net10.0)", + DefaultValueFactory = _ => "net10.0" + }; + command.Options.Add(frameworkOption); + + var outputOption = new Option("--output", "-o") + { + Description = "Output directory for project.assets.json", + DefaultValueFactory = _ => "./obj" + }; + command.Options.Add(outputOption); + + var packagesDirOption = new Option("--packages-dir") + { + Description = "NuGet packages directory" + }; + command.Options.Add(packagesDirOption); + + var sourceOption = new Option("--source") + { + Description = "NuGet feed URL (can specify multiple)", + DefaultValueFactory = _ => Array.Empty(), + AllowMultipleArgumentsPerToken = true + }; + command.Options.Add(sourceOption); + + var configOption = new Option("--nuget-config") + { + Description = "Path to nuget.config file" + }; + command.Options.Add(configOption); + + var workingDirOption = new Option("--working-dir", "-w") + { + Description = "Working directory for nuget.config discovery" + }; + command.Options.Add(workingDirOption); + + var noNugetOrgOption = new Option("--no-nuget-org") + { + Description = "Don't add nuget.org as fallback source" + }; + command.Options.Add(noNugetOrgOption); + + var verboseOption = new Option("--verbose") + { + Description = "Enable verbose output" + }; + command.Options.Add(verboseOption); + + command.SetAction(async (parseResult, ct) => + { + // Note: ?? is used for null-safety even with DefaultValueFactory because GetValue returns T? + var packageArgs = parseResult.GetValue(packageOption) ?? []; + var framework = parseResult.GetValue(frameworkOption)!; + var output = parseResult.GetValue(outputOption)!; + var packagesDir = parseResult.GetValue(packagesDirOption); + var sources = parseResult.GetValue(sourceOption) ?? []; + var nugetConfigPath = parseResult.GetValue(configOption); + var workingDir = parseResult.GetValue(workingDirOption); + var noNugetOrg = parseResult.GetValue(noNugetOrgOption); + var verbose = parseResult.GetValue(verboseOption); + + // Validate that both nuget-config and sources aren't provided together + if (!string.IsNullOrEmpty(nugetConfigPath) && sources.Length > 0) + { + Console.Error.WriteLine("Error: Cannot specify both --nuget-config and --source. Use one or the other."); + return 1; + } + + // Parse packages (format: PackageId,Version) + var packages = new List<(string Id, string Version)>(); + foreach (var pkgArg in packageArgs) + { + if (verbose) + { + Console.WriteLine($"Parsing package argument: {pkgArg}"); + } + var parts = pkgArg.Split(',', 2, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + { + Console.Error.WriteLine($"Error: Package argument '{pkgArg}' must be in format 'PackageId,Version'"); + return 1; + } + packages.Add((parts[0], parts[1])); + } + + return await ExecuteRestoreAsync([.. packages], framework, output, packagesDir, sources, nugetConfigPath, workingDir, noNugetOrg, verbose).ConfigureAwait(false); + }); + + return command; + } + + private static async Task ExecuteRestoreAsync( + (string Id, string Version)[] packages, + string framework, + string output, + string? packagesDir, + string[] sources, + string? nugetConfigPath, + string? workingDir, + bool noNugetOrg, + bool verbose) + { + var outputPath = Path.GetFullPath(output); + Directory.CreateDirectory(outputPath); + + packagesDir ??= Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".nuget", "packages"); + + var logger = new NuGetLogger(verbose); + + try + { + var nugetFramework = NuGetFramework.Parse(framework); + + if (verbose) + { + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "Restoring {0} packages for {1}", packages.Length, framework)); + Console.WriteLine($"Output: {outputPath}"); + Console.WriteLine($"Packages: {packagesDir}"); + if (workingDir is not null) + { + Console.WriteLine($"Working dir: {workingDir}"); + } + if (nugetConfigPath is not null) + { + Console.WriteLine($"NuGet config: {nugetConfigPath}"); + } + } + + // Load package sources + var packageSources = LoadPackageSources(sources, nugetConfigPath, workingDir, noNugetOrg, verbose); + + // Build PackageSpec + var packageSpec = BuildPackageSpec(packages, nugetFramework, outputPath, packagesDir, packageSources); + + // Create DependencyGraphSpec + var dgSpec = new DependencyGraphSpec(); + dgSpec.AddProject(packageSpec); + dgSpec.AddRestore(packageSpec.RestoreMetadata.ProjectUniqueName); + + // Setup providers + var providerCache = new RestoreCommandProvidersCache(); + var providers = new List + { + new DependencyGraphSpecRequestProvider(providerCache, dgSpec) + }; + + // Run restore + using var cacheContext = new SourceCacheContext(); + var restoreContext = new RestoreArgs + { + CacheContext = cacheContext, + Log = logger, + PreLoadedRequestProviders = providers, + DisableParallel = Environment.ProcessorCount == 1, + AllowNoOp = false, + GlobalPackagesFolder = packagesDir + }; + + if (verbose) + { + Console.WriteLine("Running restore..."); + } + + var results = await RestoreRunner.RunAsync(restoreContext).ConfigureAwait(false); + var summary = results.Count > 0 ? results[0] : null; + + if (summary == null) + { + Console.Error.WriteLine("Error: Restore returned no results"); + return 1; + } + + if (!summary.Success) + { + var errors = string.Join(Environment.NewLine, + summary.Errors?.Select(e => e.Message) ?? ["Unknown error"]); + Console.Error.WriteLine($"Error: Restore failed: {errors}"); + return 1; + } + + var assetsPath = Path.Combine(outputPath, "project.assets.json"); + Console.WriteLine($"Restore completed successfully"); + Console.WriteLine($"Assets file: {assetsPath}"); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + if (verbose) + { + Console.Error.WriteLine(ex.ToString()); + } + + return 1; + } + } + + private static List LoadPackageSources(string[] sources, string? nugetConfigPath, string? workingDir, bool noNugetOrg, bool verbose) + { + var packageSources = new List(); + + // Add explicit sources first (they get priority) + foreach (var source in sources) + { + packageSources.Add(new PackageSource(source)); + } + + // Load from specific config file if specified + if (!string.IsNullOrEmpty(nugetConfigPath) && File.Exists(nugetConfigPath)) + { + var configDir = Path.GetDirectoryName(nugetConfigPath)!; + var configFile = Path.GetFileName(nugetConfigPath); + var settings = Settings.LoadSpecificSettings(configDir, configFile); + var provider = new PackageSourceProvider(settings); + + foreach (var source in provider.LoadPackageSources()) + { + if (source.IsEnabled && !packageSources.Any(s => s.Source == source.Source)) + { + packageSources.Add(source); + } + } + } + // Auto-discover nuget.config from working directory if specified + else if (!string.IsNullOrEmpty(workingDir) && Directory.Exists(workingDir)) + { + try + { + // LoadDefaultSettings walks up the directory tree looking for nuget.config files + var settings = Settings.LoadDefaultSettings(workingDir); + var provider = new PackageSourceProvider(settings); + + if (verbose) + { + // Show the config file paths that were loaded + var configPaths = settings.GetConfigFilePaths(); + Console.WriteLine($"Discovering NuGet config from: {workingDir}"); + foreach (var configPath in configPaths) + { + Console.WriteLine($" Loaded config: {configPath}"); + } + } + + foreach (var source in provider.LoadPackageSources()) + { + if (source.IsEnabled && !packageSources.Any(s => s.Source == source.Source)) + { + if (verbose) + { + Console.WriteLine($" Discovered source: {source.Name ?? source.Source}"); + } + packageSources.Add(source); + } + } + } + catch (Exception ex) + { + if (verbose) + { + Console.WriteLine($"Warning: Failed to load NuGet config from {workingDir}: {ex.ToString()}"); + } + } + } + + // Add nuget.org as a fallback source unless opted out + if (!noNugetOrg) + { + const string nugetOrgUrl = "https://api.nuget.org/v3/index.json"; + if (!packageSources.Any(s => s.Source.Equals(nugetOrgUrl, StringComparison.OrdinalIgnoreCase))) + { + Console.WriteLine("Note: Adding nuget.org as fallback package source. Use --no-nuget-org to disable."); + packageSources.Add(new PackageSource(nugetOrgUrl, "nuget.org")); + } + } + + if (verbose) + { + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "Using {0} package sources:", packageSources.Count)); + foreach (var source in packageSources) + { + Console.WriteLine($" - {source.Name ?? source.Source}"); + } + } + + return packageSources; + } + + private static PackageSpec BuildPackageSpec( + (string Id, string Version)[] packages, + NuGetFramework framework, + string outputPath, + string packagesPath, + List sources) + { + var projectName = "AspireRestore"; + var projectPath = Path.Combine(outputPath, "project.json"); + var tfmShort = framework.GetShortFolderName(); + + // Build dependencies + var dependencies = packages.Select(p => new LibraryDependency + { + LibraryRange = new LibraryRange( + p.Id, + VersionRange.Parse(p.Version), + LibraryDependencyTarget.Package) + }).ToImmutableArray(); + + // Build target framework info + var tfInfo = new TargetFrameworkInformation + { + FrameworkName = framework, + TargetAlias = tfmShort, + Dependencies = dependencies + }; + + // Build restore metadata + var restoreMetadata = new ProjectRestoreMetadata + { + ProjectUniqueName = projectName, + ProjectName = projectName, + ProjectPath = projectPath, + ProjectStyle = ProjectStyle.PackageReference, + OutputPath = outputPath, + PackagesPath = packagesPath, + OriginalTargetFrameworks = [tfmShort], + }; + + // Add sources + foreach (var source in sources) + { + restoreMetadata.Sources.Add(source); + } + + // Add target framework + restoreMetadata.TargetFrameworks.Add(new ProjectRestoreMetadataFrameworkInfo(framework) + { + TargetAlias = tfmShort + }); + + return new PackageSpec([tfInfo]) + { + Name = projectName, + FilePath = projectPath, + RestoreMetadata = restoreMetadata, + }; + } +} diff --git a/src/Aspire.Cli.NuGetHelper/Commands/SearchCommand.cs b/src/Aspire.Cli.NuGetHelper/Commands/SearchCommand.cs new file mode 100644 index 00000000000..d61272e5228 --- /dev/null +++ b/src/Aspire.Cli.NuGetHelper/Commands/SearchCommand.cs @@ -0,0 +1,349 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using NuGet.Configuration; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; +using INuGetLogger = NuGet.Common.ILogger; + +namespace Aspire.Cli.NuGetHelper.Commands; + +/// +/// Search command - searches NuGet feeds for packages using NuGet.Protocol. +/// +public static class SearchCommand +{ + /// + /// Creates the search command. + /// + public static Command Create() + { + var command = new Command("search", "Search for NuGet packages"); + + var queryOption = new Option("--query", "-q") + { + Description = "Search query string", + Required = true + }; + command.Options.Add(queryOption); + + var prereleaseOption = new Option("--prerelease", "-p") + { + Description = "Include prerelease packages" + }; + command.Options.Add(prereleaseOption); + + var takeOption = new Option("--take", "-t") + { + Description = "Maximum number of results (default: 100)", + DefaultValueFactory = _ => 100 + }; + command.Options.Add(takeOption); + + var skipOption = new Option("--skip", "-s") + { + Description = "Number of results to skip (default: 0)", + DefaultValueFactory = _ => 0 + }; + command.Options.Add(skipOption); + + var sourceOption = new Option("--source") + { + Description = "NuGet feed URL (can specify multiple)", + DefaultValueFactory = _ => Array.Empty(), + AllowMultipleArgumentsPerToken = true + }; + command.Options.Add(sourceOption); + + var configOption = new Option("--nuget-config") + { + Description = "Path to nuget.config file" + }; + command.Options.Add(configOption); + + var workingDirOption = new Option("--working-dir", "-d") + { + Description = "Working directory to search for nuget.config" + }; + command.Options.Add(workingDirOption); + + var formatOption = new Option("--format", "-f") + { + Description = "Output format: json or text (default: json)", + DefaultValueFactory = _ => "json" + }; + command.Options.Add(formatOption); + + var verboseOption = new Option("--verbose", "-v") + { + Description = "Enable verbose output" + }; + command.Options.Add(verboseOption); + + command.SetAction(async (parseResult, ct) => + { + var query = parseResult.GetValue(queryOption)!; + var prerelease = parseResult.GetValue(prereleaseOption); + var take = parseResult.GetValue(takeOption); + var skip = parseResult.GetValue(skipOption); + var sources = parseResult.GetValue(sourceOption) ?? []; + var configPath = parseResult.GetValue(configOption); + var workingDir = parseResult.GetValue(workingDirOption); + var format = parseResult.GetValue(formatOption) ?? "json"; + var verbose = parseResult.GetValue(verboseOption); + + return await ExecuteSearchAsync(query, prerelease, take, skip, sources, configPath, workingDir, format, verbose).ConfigureAwait(false); + }); + + return command; + } + + private static async Task ExecuteSearchAsync( + string query, + bool prerelease, + int take, + int skip, + string[] explicitSources, + string? configPath, + string? workingDir, + string format, + bool verbose) + { + var logger = new NuGetLogger(verbose); + var allPackages = new List(); + + try + { + // Load settings from nuget.config + var settings = LoadSettings(configPath, workingDir); + var packageSources = LoadPackageSources(settings, explicitSources, verbose); + + if (verbose) + { + Console.Error.WriteLine(string.Format(CultureInfo.InvariantCulture, "Searching {0} source(s) for '{1}'", packageSources.Count, query)); + } + + // Search each source in parallel using NuGet.Protocol + var searchFilter = new SearchFilter(prerelease); + var searchTasks = packageSources.Select(async source => + { + try + { + if (verbose) + { + Console.Error.WriteLine($"Searching {source.Name} ({source.Source})..."); + } + + return await SearchSourceAsync(source, query, searchFilter, skip, take, logger).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Failed to search {source.Name}: {ex.Message}"); + return new List(); + } + }).ToList(); + + var searchResults = await Task.WhenAll(searchTasks).ConfigureAwait(false); + foreach (var packages in searchResults) + { + allPackages.AddRange(packages); + } + + // Deduplicate by package ID, keeping the highest version + var dedupedPackages = allPackages + .GroupBy(p => p.Id, StringComparer.OrdinalIgnoreCase) + .Select(g => g.OrderByDescending(p => p.Version).First()) + .OrderBy(p => p.Id) + .Take(take) + .ToList(); + + OutputResults(dedupedPackages, format); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + if (verbose) + { + Console.Error.WriteLine(ex.StackTrace); + } + + return 1; + } + } + + private static ISettings LoadSettings(string? configPath, string? workingDir) + { + if (!string.IsNullOrEmpty(configPath) && File.Exists(configPath)) + { + var configDir = Path.GetDirectoryName(configPath)!; + var configFile = Path.GetFileName(configPath); + return Settings.LoadSpecificSettings(configDir, configFile); + } + + var searchDir = workingDir ?? Directory.GetCurrentDirectory(); + return Settings.LoadDefaultSettings(searchDir); + } + + private static List LoadPackageSources(ISettings settings, string[] explicitSources, bool verbose) + { + var packageSources = new List(); + + // Add explicit sources first + foreach (var source in explicitSources) + { + packageSources.Add(new PackageSource(source)); + if (verbose) + { + Console.Error.WriteLine($"Using explicit source: {source}"); + } + } + + // If no explicit sources, load from settings + if (packageSources.Count == 0) + { + var provider = new PackageSourceProvider(settings); + foreach (var source in provider.LoadPackageSources()) + { + if (source.IsEnabled) + { + packageSources.Add(source); + if (verbose) + { + Console.Error.WriteLine($"Using source from config: {source.Name} ({source.Source})"); + } + } + } + } + + // Default to nuget.org if still no sources + if (packageSources.Count == 0) + { + var defaultSource = new PackageSource("https://api.nuget.org/v3/index.json", "nuget.org"); + packageSources.Add(defaultSource); + Console.Error.WriteLine("Note: No package sources configured, using nuget.org as fallback."); + } + + return packageSources; + } + + private static async Task> SearchSourceAsync( + PackageSource source, + string query, + SearchFilter filter, + int skip, + int take, + INuGetLogger logger) + { + var repository = Repository.Factory.GetCoreV3(source); + var searchResource = await repository.GetResourceAsync().ConfigureAwait(false); + + if (searchResource is null) + { + return []; + } + + var results = await searchResource.SearchAsync( + query, + filter, + skip, + take, + logger, + CancellationToken.None).ConfigureAwait(false); + + var packages = new List(); + foreach (var result in results) + { + var versions = await result.GetVersionsAsync().ConfigureAwait(false); + var allVersions = versions?.Select(v => v.Version.ToString()).ToList() ?? []; + + packages.Add(new PackageInfo + { + Id = result.Identity.Id, + Version = result.Identity.Version.ToString(), + Description = result.Description, + Authors = result.Authors, + AllVersions = allVersions, + Source = source.Source, + Deprecated = await result.GetDeprecationMetadataAsync().ConfigureAwait(false) is not null + }); + } + + return packages; + } + + private static void OutputResults(List packages, string format) + { + if (format.Equals("json", StringComparison.OrdinalIgnoreCase)) + { + var result = new SearchResult + { + Packages = packages, + TotalHits = packages.Count + }; + Console.WriteLine(JsonSerializer.Serialize(result, SearchJsonContext.Default.SearchResult)); + } + else + { + foreach (var pkg in packages) + { + Console.WriteLine($"{pkg.Id} {pkg.Version}"); + if (!string.IsNullOrEmpty(pkg.Description)) + { + Console.WriteLine($" {pkg.Description}"); + } + + Console.WriteLine(); + } + } + } +} + +#region JSON Models + +/// +/// Result of a package search. +/// +public sealed class SearchResult +{ + /// Gets or sets the list of packages found. + public List Packages { get; set; } = []; + /// Gets or sets the total number of hits. + public int TotalHits { get; set; } +} + +/// +/// Information about a NuGet package. +/// +public sealed class PackageInfo +{ + /// Gets or sets the package ID. + public string Id { get; set; } = ""; + /// Gets or sets the latest version. + public string Version { get; set; } = ""; + /// Gets or sets the package description. + public string? Description { get; set; } + /// Gets or sets the package authors. + public string? Authors { get; set; } + /// Gets or sets all available versions. + public List AllVersions { get; set; } = []; + /// Gets or sets the source feed. + public string? Source { get; set; } + /// Gets or sets whether the package is deprecated. + public bool Deprecated { get; set; } +} + +[JsonSerializable(typeof(SearchResult))] +[JsonSerializable(typeof(PackageInfo))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +internal sealed partial class SearchJsonContext : JsonSerializerContext +{ +} + +#endregion diff --git a/src/Aspire.Cli.NuGetHelper/NuGetLogger.cs b/src/Aspire.Cli.NuGetHelper/NuGetLogger.cs new file mode 100644 index 00000000000..ce28391a5f1 --- /dev/null +++ b/src/Aspire.Cli.NuGetHelper/NuGetLogger.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 NuGetLogLevel = NuGet.Common.LogLevel; +using NuGetLogMessage = NuGet.Common.ILogMessage; +using INuGetLogger = NuGet.Common.ILogger; + +namespace Aspire.Cli.NuGetHelper; + +/// +/// Console logger adapter for NuGet operations. +/// +internal sealed class NuGetLogger(bool verbose) : INuGetLogger +{ + public void Log(NuGetLogLevel level, string data) + { + if (!verbose && level < NuGetLogLevel.Warning) + { + return; + } + + var prefix = level switch + { + NuGetLogLevel.Error => "ERROR: ", + NuGetLogLevel.Warning => "WARNING: ", + _ => "" + }; + + // All log output goes to stderr to avoid mixing with JSON output on stdout + Console.Error.WriteLine($"{prefix}{data}"); + } + + public void Log(NuGetLogMessage message) => Log(message.Level, message.Message); + public Task LogAsync(NuGetLogLevel level, string data) { Log(level, data); return Task.CompletedTask; } + public Task LogAsync(NuGetLogMessage message) { Log(message); return Task.CompletedTask; } + public void LogDebug(string data) => Log(NuGetLogLevel.Debug, data); + public void LogError(string data) => Log(NuGetLogLevel.Error, data); + public void LogInformation(string data) => Log(NuGetLogLevel.Information, data); + public void LogInformationSummary(string data) => Log(NuGetLogLevel.Information, data); + public void LogMinimal(string data) => Log(NuGetLogLevel.Minimal, data); + public void LogVerbose(string data) => Log(NuGetLogLevel.Verbose, data); + public void LogWarning(string data) => Log(NuGetLogLevel.Warning, data); +} diff --git a/src/Aspire.Cli.NuGetHelper/Program.cs b/src/Aspire.Cli.NuGetHelper/Program.cs new file mode 100644 index 00000000000..b2da6ba23bf --- /dev/null +++ b/src/Aspire.Cli.NuGetHelper/Program.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Aspire.Cli.NuGetHelper.Commands; + +namespace Aspire.Cli.NuGetHelper; + +/// +/// NuGet Helper Tool - Provides NuGet operations for the Aspire CLI bundle. +/// This tool runs under the bundled .NET runtime and provides package search, +/// restore, and layout generation functionality without requiring the .NET SDK. +/// +public static class Program +{ + /// + /// Entry point for the NuGet Helper tool. + /// + /// Command line arguments. + /// Exit code (0 for success). + public static async Task Main(string[] args) + { + var rootCommand = new RootCommand("Aspire NuGet Helper - Package operations for Aspire CLI bundle"); + + rootCommand.Subcommands.Add(SearchCommand.Create()); + rootCommand.Subcommands.Add(RestoreCommand.Create()); + rootCommand.Subcommands.Add(LayoutCommand.Create()); + + return await rootCommand.Parse(args).InvokeAsync().ConfigureAwait(false); + } +} diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 8d8ed84f6f3..1da9ab1f7b1 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -52,6 +52,7 @@ + diff --git a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs index bb05bbb3481..d78265455f8 100644 --- a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs +++ b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs @@ -33,7 +33,7 @@ public static List MapToResourceJsonList(IEnumerableWhether to include environment variable values. Defaults to true. Set to false to exclude values for security reasons. public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnlyList allSnapshots, string? dashboardBaseUrl = null, bool includeEnvironmentVariableValues = true) { - var urls = snapshot.Urls + var urls = (snapshot.Urls ?? []) .Select(u => new ResourceUrlJson { Name = u.Name, @@ -43,7 +43,7 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl }) .ToArray(); - var volumes = snapshot.Volumes + var volumes = (snapshot.Volumes ?? []) .Select(v => new ResourceVolumeJson { Source = v.Source, @@ -53,7 +53,7 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl }) .ToArray(); - var healthReports = snapshot.HealthReports + var healthReports = (snapshot.HealthReports ?? []) .Select(h => new ResourceHealthReportJson { Name = h.Name, @@ -63,7 +63,7 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl }) .ToArray(); - var environment = snapshot.EnvironmentVariables + var environment = (snapshot.EnvironmentVariables ?? []) .Where(e => e.IsFromSpec) .Select(e => new ResourceEnvironmentVariableJson { @@ -72,7 +72,7 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl }) .ToArray(); - var properties = snapshot.Properties + var properties = (snapshot.Properties ?? []) .Select(p => new ResourcePropertyJson { Name = p.Key, @@ -82,7 +82,7 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl // Build relationships by matching DisplayName var relationships = new List(); - foreach (var relationship in snapshot.Relationships) + foreach (var relationship in snapshot.Relationships ?? []) { var matches = allSnapshots .Where(r => string.Equals(r.DisplayName, relationship.ResourceName, StringComparisons.ResourceName)) @@ -99,7 +99,7 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl } // Only include enabled commands - var commands = snapshot.Commands + var commands = (snapshot.Commands ?? []) .Where(c => string.Equals(c.State, "Enabled", StringComparison.OrdinalIgnoreCase)) .Select(c => new ResourceCommandJson { @@ -109,7 +109,9 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl .ToArray(); // Get source information using the shared ResourceSourceViewModel - var sourceViewModel = ResourceSource.GetSourceModel(snapshot.ResourceType, snapshot.Properties); + var sourceViewModel = snapshot.Properties is not null + ? ResourceSource.GetSourceModel(snapshot.ResourceType, snapshot.Properties) + : null; // Generate dashboard URL for this resource if a base URL is provided string? dashboardUrl = null; diff --git a/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs new file mode 100644 index 00000000000..3a78444d1c1 --- /dev/null +++ b/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs @@ -0,0 +1,186 @@ +// 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.Text; +using System.Text.Json; +using Aspire.Cli.DotNet; +using Aspire.Cli.Layout; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Certificates; + +/// +/// Certificate tool runner that uses the bundled dev-certs DLL with the bundled runtime. +/// +internal sealed class BundleCertificateToolRunner( + LayoutConfiguration layout, + ILogger logger) : ICertificateToolRunner +{ + public async Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync( + DotNetCliRunnerInvocationOptions options, + CancellationToken cancellationToken) + { + var muxerPath = layout.GetMuxerPath(); + var devCertsPath = layout.GetDevCertsPath(); + + if (muxerPath is null) + { + throw new InvalidOperationException("Bundle runtime not found. The bundle may be corrupt."); + } + + if (devCertsPath is null || !File.Exists(devCertsPath)) + { + throw new InvalidOperationException("dev-certs tool not found in bundle. The bundle may be corrupt or incomplete."); + } + + var outputBuilder = new StringBuilder(); + + var startInfo = new ProcessStartInfo(muxerPath) + { + WorkingDirectory = Environment.CurrentDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + // Use ArgumentList to prevent command injection + startInfo.ArgumentList.Add(devCertsPath); + startInfo.ArgumentList.Add("https"); + startInfo.ArgumentList.Add("--check-trust-machine-readable"); + + using var process = new Process { StartInfo = startInfo }; + + process.OutputDataReceived += (sender, e) => + { + if (e.Data is not null) + { + outputBuilder.AppendLine(e.Data); + options.StandardOutputCallback?.Invoke(e.Data); + } + }; + + process.ErrorDataReceived += (sender, e) => + { + if (e.Data is not null) + { + options.StandardErrorCallback?.Invoke(e.Data); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.WaitForExitAsync(cancellationToken); + + var exitCode = process.ExitCode; + + // Parse the JSON output + try + { + var jsonOutput = outputBuilder.ToString().Trim(); + if (string.IsNullOrEmpty(jsonOutput)) + { + return (exitCode, new CertificateTrustResult + { + HasCertificates = false, + TrustLevel = null, + Certificates = [] + }); + } + + var certificates = JsonSerializer.Deserialize(jsonOutput, Aspire.Cli.JsonSourceGenerationContext.Default.ListDevCertInfo); + if (certificates is null || certificates.Count == 0) + { + return (exitCode, new CertificateTrustResult + { + HasCertificates = false, + TrustLevel = null, + Certificates = [] + }); + } + + // Find the highest versioned valid certificate + var now = DateTimeOffset.Now; + var validCertificates = certificates + .Where(c => c.IsHttpsDevelopmentCertificate && c.ValidityNotBefore <= now && now <= c.ValidityNotAfter) + .OrderByDescending(c => c.Version) + .ToList(); + + var highestVersionedCert = validCertificates.FirstOrDefault(); + var trustLevel = highestVersionedCert?.TrustLevel; + + return (exitCode, new CertificateTrustResult + { + HasCertificates = validCertificates.Count > 0, + TrustLevel = trustLevel, + Certificates = certificates + }); + } + catch (JsonException ex) + { + logger.LogDebug(ex, "Failed to parse dev-certs machine-readable output"); + return (exitCode, null); + } + } + + public async Task TrustHttpCertificateAsync( + DotNetCliRunnerInvocationOptions options, + CancellationToken cancellationToken) + { + var muxerPath = layout.GetMuxerPath(); + var devCertsPath = layout.GetDevCertsPath(); + + if (muxerPath is null) + { + throw new InvalidOperationException("Bundle runtime not found. The bundle may be corrupt."); + } + + if (devCertsPath is null || !File.Exists(devCertsPath)) + { + throw new InvalidOperationException("dev-certs tool not found in bundle. The bundle may be corrupt or incomplete."); + } + + var startInfo = new ProcessStartInfo(muxerPath) + { + WorkingDirectory = Environment.CurrentDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + // Use ArgumentList to prevent command injection + startInfo.ArgumentList.Add(devCertsPath); + startInfo.ArgumentList.Add("https"); + startInfo.ArgumentList.Add("--trust"); + + using var process = new Process { StartInfo = startInfo }; + + process.OutputDataReceived += (sender, e) => + { + if (e.Data is not null) + { + options.StandardOutputCallback?.Invoke(e.Data); + } + }; + + process.ErrorDataReceived += (sender, e) => + { + if (e.Data is not null) + { + options.StandardErrorCallback?.Invoke(e.Data); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.WaitForExitAsync(cancellationToken); + + return process.ExitCode; + } +} \ No newline at end of file diff --git a/src/Aspire.Cli/Certificates/CertificateService.cs b/src/Aspire.Cli/Certificates/CertificateService.cs index 3c550795920..320e161e74c 100644 --- a/src/Aspire.Cli/Certificates/CertificateService.cs +++ b/src/Aspire.Cli/Certificates/CertificateService.cs @@ -28,10 +28,13 @@ internal sealed class EnsureCertificatesTrustedResult internal interface ICertificateService { - Task EnsureCertificatesTrustedAsync(IDotNetCliRunner runner, CancellationToken cancellationToken); + Task EnsureCertificatesTrustedAsync(CancellationToken cancellationToken); } -internal sealed partial class CertificateService(IInteractionService interactionService, AspireCliTelemetry telemetry) : ICertificateService +internal sealed partial class CertificateService( + ICertificateToolRunner certificateToolRunner, + IInteractionService interactionService, + AspireCliTelemetry telemetry) : ICertificateService { private const string SslCertDirEnvVar = "SSL_CERT_DIR"; private const string DevCertsOpenSslCertDirEnvVar = "DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY"; @@ -50,7 +53,7 @@ private static string GetDevCertsTrustPath() return !string.IsNullOrEmpty(overridePath) ? overridePath : s_defaultDevCertsTrustPath; } - public async Task EnsureCertificatesTrustedAsync(IDotNetCliRunner runner, CancellationToken cancellationToken) + public async Task EnsureCertificatesTrustedAsync(CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(kind: ActivityKind.Client); @@ -58,8 +61,8 @@ public async Task EnsureCertificatesTrustedAsyn var ensureCertificateCollector = new OutputCollector(); // Use the machine-readable check (available in .NET 10 SDK which is the minimum required) - var trustResult = await CheckMachineReadableAsync(runner, ensureCertificateCollector, cancellationToken); - await HandleMachineReadableTrustAsync(runner, trustResult, ensureCertificateCollector, environmentVariables, cancellationToken); + var trustResult = await CheckMachineReadableAsync(ensureCertificateCollector, cancellationToken); + await HandleMachineReadableTrustAsync(trustResult, ensureCertificateCollector, environmentVariables, cancellationToken); return new EnsureCertificatesTrustedResult { @@ -68,7 +71,6 @@ public async Task EnsureCertificatesTrustedAsyn } private async Task CheckMachineReadableAsync( - IDotNetCliRunner runner, OutputCollector collector, CancellationToken cancellationToken) { @@ -80,7 +82,7 @@ private async Task CheckMachineReadableAsync( var (_, result) = await interactionService.ShowStatusAsync( $":locked_with_key: {InteractionServiceStrings.CheckingCertificates}", - async () => await runner.CheckHttpCertificateMachineReadableAsync(options, cancellationToken)); + async () => await certificateToolRunner.CheckHttpCertificateMachineReadableAsync(options, cancellationToken)); // Return the result or a default "no certificates" result return result ?? new CertificateTrustResult @@ -92,7 +94,6 @@ private async Task CheckMachineReadableAsync( } private async Task HandleMachineReadableTrustAsync( - IDotNetCliRunner runner, CertificateTrustResult trustResult, OutputCollector collector, Dictionary environmentVariables, @@ -115,7 +116,7 @@ private async Task HandleMachineReadableTrustAsync( var trustExitCode = await interactionService.ShowStatusAsync( $":locked_with_key: {InteractionServiceStrings.TrustingCertificates}", - () => runner.TrustHttpCertificateAsync(options, cancellationToken)); + () => certificateToolRunner.TrustHttpCertificateAsync(options, cancellationToken)); if (trustExitCode != 0) { @@ -130,7 +131,7 @@ private async Task HandleMachineReadableTrustAsync( StandardErrorCallback = collector.AppendError, }; - var (_, recheckResult) = await runner.CheckHttpCertificateMachineReadableAsync(recheckOptions, cancellationToken); + var (_, recheckResult) = await certificateToolRunner.CheckHttpCertificateMachineReadableAsync(recheckOptions, cancellationToken); if (recheckResult is not null) { trustResult = recheckResult; diff --git a/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs b/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs new file mode 100644 index 00000000000..4831ac5d1bf --- /dev/null +++ b/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs @@ -0,0 +1,26 @@ +// 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.DotNet; + +namespace Aspire.Cli.Certificates; + +/// +/// Interface for running dev-certs operations. +/// +internal interface ICertificateToolRunner +{ + /// + /// Checks certificate trust status using machine-readable output. + /// + Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync( + DotNetCliRunnerInvocationOptions options, + CancellationToken cancellationToken); + + /// + /// Trusts the HTTPS development certificate. + /// + Task TrustHttpCertificateAsync( + DotNetCliRunnerInvocationOptions options, + CancellationToken cancellationToken); +} diff --git a/src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs new file mode 100644 index 00000000000..922a7eaef5b --- /dev/null +++ b/src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs @@ -0,0 +1,155 @@ +// 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.Text; +using System.Text.Json; +using Aspire.Cli.DotNet; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Certificates; + +/// +/// Certificate tool runner that uses the global dotnet SDK's dev-certs command. +/// +internal sealed class SdkCertificateToolRunner(ILogger logger) : ICertificateToolRunner +{ + public async Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync( + DotNetCliRunnerInvocationOptions options, + CancellationToken cancellationToken) + { + var outputBuilder = new StringBuilder(); + + var startInfo = new ProcessStartInfo("dotnet") + { + WorkingDirectory = Environment.CurrentDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + startInfo.ArgumentList.Add("dev-certs"); + startInfo.ArgumentList.Add("https"); + startInfo.ArgumentList.Add("--check-trust-machine-readable"); + + using var process = new Process { StartInfo = startInfo }; + + process.OutputDataReceived += (sender, e) => + { + if (e.Data is not null) + { + outputBuilder.AppendLine(e.Data); + options.StandardOutputCallback?.Invoke(e.Data); + } + }; + + process.ErrorDataReceived += (sender, e) => + { + if (e.Data is not null) + { + options.StandardErrorCallback?.Invoke(e.Data); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.WaitForExitAsync(cancellationToken); + + var exitCode = process.ExitCode; + + // Parse the JSON output + try + { + var jsonOutput = outputBuilder.ToString().Trim(); + if (string.IsNullOrEmpty(jsonOutput)) + { + return (exitCode, new CertificateTrustResult + { + HasCertificates = false, + TrustLevel = null, + Certificates = [] + }); + } + + var certificates = JsonSerializer.Deserialize(jsonOutput, Aspire.Cli.JsonSourceGenerationContext.Default.ListDevCertInfo); + if (certificates is null || certificates.Count == 0) + { + return (exitCode, new CertificateTrustResult + { + HasCertificates = false, + TrustLevel = null, + Certificates = [] + }); + } + + // Find the highest versioned valid certificate + var now = DateTimeOffset.Now; + var validCertificates = certificates + .Where(c => c.IsHttpsDevelopmentCertificate && c.ValidityNotBefore <= now && now <= c.ValidityNotAfter) + .OrderByDescending(c => c.Version) + .ToList(); + + var highestVersionedCert = validCertificates.FirstOrDefault(); + var trustLevel = highestVersionedCert?.TrustLevel; + + return (exitCode, new CertificateTrustResult + { + HasCertificates = validCertificates.Count > 0, + TrustLevel = trustLevel, + Certificates = certificates + }); + } + catch (JsonException ex) + { + logger.LogDebug(ex, "Failed to parse dev-certs machine-readable output"); + return (exitCode, null); + } + } + + public async Task TrustHttpCertificateAsync( + DotNetCliRunnerInvocationOptions options, + CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo("dotnet") + { + WorkingDirectory = Environment.CurrentDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + startInfo.ArgumentList.Add("dev-certs"); + startInfo.ArgumentList.Add("https"); + startInfo.ArgumentList.Add("--trust"); + + using var process = new Process { StartInfo = startInfo }; + + process.OutputDataReceived += (sender, e) => + { + if (e.Data is not null) + { + options.StandardOutputCallback?.Invoke(e.Data); + } + }; + + process.ErrorDataReceived += (sender, e) => + { + if (e.Data is not null) + { + options.StandardErrorCallback?.Invoke(e.Data); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.WaitForExitAsync(cancellationToken); + + return process.ExitCode; + } +} diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index c075bc02953..a27bdebac36 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -335,6 +335,12 @@ static string FormatVersionLabel((string FriendlyName, NuGetPackage Package, Pac )) .ToArray(); + // Auto-select when there's only one version in the channel + if (choices.Length == 1) + { + return choices[0].Result; + } + var selection = await interactionService.PromptForSelectionAsync( string.Format(CultureInfo.CurrentCulture, AddCommandStrings.SelectAVersionOfPackage, firstPackage.Package.Id), choices, @@ -396,6 +402,12 @@ static string FormatVersionLabel((string FriendlyName, NuGetPackage Package, Pac return firstPackage; } + // Auto-select when there's only one option (e.g., single explicit channel) + if (rootChoices.Count == 1) + { + return await rootChoices[0].Action(cancellationToken); + } + var topSelection = await interactionService.PromptForSelectionAsync( string.Format(CultureInfo.CurrentCulture, AddCommandStrings.SelectAVersionOfPackage, firstPackage.Package.Id), rootChoices, diff --git a/src/Aspire.Cli/Commands/DeployCommand.cs b/src/Aspire.Cli/Commands/DeployCommand.cs index 285b5077aac..398974babcd 100644 --- a/src/Aspire.Cli/Commands/DeployCommand.cs +++ b/src/Aspire.Cli/Commands/DeployCommand.cs @@ -18,8 +18,8 @@ internal sealed class DeployCommand : PipelineCommandBase { private readonly Option _clearCacheOption; - public DeployCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) - : base("deploy", DeployCommandStrings.Description, runner, interactionService, projectLocator, telemetry, sdkInstaller, features, updateNotifier, executionContext, hostEnvironment, projectFactory, logger, ansiConsole) + public DeployCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) + : base("deploy", DeployCommandStrings.Description, runner, interactionService, projectLocator, telemetry, features, updateNotifier, executionContext, hostEnvironment, projectFactory, logger, ansiConsole) { _clearCacheOption = new Option("--clear-cache") { diff --git a/src/Aspire.Cli/Commands/DoCommand.cs b/src/Aspire.Cli/Commands/DoCommand.cs index 3408e270102..5555bec0ce3 100644 --- a/src/Aspire.Cli/Commands/DoCommand.cs +++ b/src/Aspire.Cli/Commands/DoCommand.cs @@ -18,8 +18,8 @@ internal sealed class DoCommand : PipelineCommandBase { private readonly Argument _stepArgument; - public DoCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) - : base("do", DoCommandStrings.Description, runner, interactionService, projectLocator, telemetry, sdkInstaller, features, updateNotifier, executionContext, hostEnvironment, projectFactory, logger, ansiConsole) + public DoCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) + : base("do", DoCommandStrings.Description, runner, interactionService, projectLocator, telemetry, features, updateNotifier, executionContext, hostEnvironment, projectFactory, logger, ansiConsole) { _stepArgument = new Argument("step") { diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 00fe1556598..e2589cecd11 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -532,7 +532,7 @@ await nugetConfigPrompter.PromptToCreateOrUpdateAsync( } // Trust certificates (result not used since we're not launching an AppHost) - _ = await _certificateService.EnsureCertificatesTrustedAsync(_runner, cancellationToken); + _ = await _certificateService.EnsureCertificatesTrustedAsync(cancellationToken); InteractionService.DisplaySuccess(InitCommandStrings.AspireInitializationComplete); return ExitCodeConstants.Success; @@ -596,7 +596,7 @@ private async Task CreateEmptyAppHostAsync(ParseResult parseResult, Cancell if (result.ExitCode == 0) { // Trust certificates (result not used since we're not launching an AppHost) - _ = await _certificateService.EnsureCertificatesTrustedAsync(_runner, cancellationToken); + _ = await _certificateService.EnsureCertificatesTrustedAsync(cancellationToken); InteractionService.DisplaySuccess(InitCommandStrings.AspireInitializationComplete); } diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index 801ce805ca2..a15cd763c62 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Backchannel; using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; +using Aspire.Cli.Exceptions; using Aspire.Cli.Interaction; using Aspire.Cli.Projects; using Aspire.Cli.Resources; @@ -25,7 +26,6 @@ internal abstract class PipelineCommandBase : BaseCommand protected readonly IDotNetCliRunner _runner; protected readonly IProjectLocator _projectLocator; - protected readonly IDotNetSdkInstaller _sdkInstaller; protected readonly IAppHostProjectFactory _projectFactory; private readonly IFeatures _features; @@ -67,12 +67,11 @@ private static bool IsCompletionStateError(string completionState) => private static bool IsCompletionStateWarning(string completionState) => completionState == CompletionStates.CompletedWithWarning; - protected PipelineCommandBase(string name, string description, IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) + protected PipelineCommandBase(string name, string description, IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) : base(name, description, features, updateNotifier, executionContext, interactionService, telemetry) { _runner = runner; _projectLocator = projectLocator; - _sdkInstaller = sdkInstaller; _hostEnvironment = hostEnvironment; _features = features; _projectFactory = projectFactory; @@ -111,14 +110,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Send terminal infinite progress bar start sequence StartTerminalProgressBar(); - // Check if the .NET SDK is available - if (!await SdkInstallHelper.EnsureSdkInstalledAsync(_sdkInstaller, InteractionService, _features, Telemetry, _hostEnvironment, cancellationToken)) - { - // Send terminal progress bar stop sequence - StopTerminalProgressBar(); - return ExitCodeConstants.SdkNotInstalled; - } - try { using var activity = Telemetry.StartDiagnosticActivity(this.Name); @@ -185,6 +176,12 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return await backchannelCompletionSource.Task; } + // Check if the task faulted with a known exception type that should be propagated directly + if (completedTask.IsFaulted && completedTask.Exception?.InnerException is DotNetSdkNotInstalledException sdkException) + { + throw sdkException; + } + // Throw an error if the run completed without returning a backchannel. // Include possible error if the run task faulted. var innerException = completedTask.IsFaulted ? completedTask.Exception : null; @@ -247,6 +244,12 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell StopTerminalProgressBar(); return HandleProjectLocatorException(ex, InteractionService, Telemetry); } + catch (DotNetSdkNotInstalledException) + { + // SDK not installed - message already displayed by EnsureSdkInstalledAsync + StopTerminalProgressBar(); + return ExitCodeConstants.SdkNotInstalled; + } catch (AppHostIncompatibleException ex) { // Send terminal progress bar stop sequence on exception diff --git a/src/Aspire.Cli/Commands/PublishCommand.cs b/src/Aspire.Cli/Commands/PublishCommand.cs index 7ccbe87ad46..f70d336e615 100644 --- a/src/Aspire.Cli/Commands/PublishCommand.cs +++ b/src/Aspire.Cli/Commands/PublishCommand.cs @@ -36,8 +36,8 @@ internal sealed class PublishCommand : PipelineCommandBase { private readonly IPublishCommandPrompter _prompter; - public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, IPublishCommandPrompter prompter, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) - : base("publish", PublishCommandStrings.Description, runner, interactionService, projectLocator, telemetry, sdkInstaller, features, updateNotifier, executionContext, hostEnvironment, projectFactory, logger, ansiConsole) + public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, IPublishCommandPrompter prompter, AspireCliTelemetry telemetry, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) + : base("publish", PublishCommandStrings.Description, runner, interactionService, projectLocator, telemetry, features, updateNotifier, executionContext, hostEnvironment, projectFactory, logger, ansiConsole) { _prompter = prompter; } diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index ba6384a4269..b5170cc3b60 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -58,10 +58,8 @@ internal sealed class RunCommand : BaseCommand private readonly IProjectLocator _projectLocator; private readonly IAnsiConsole _ansiConsole; private readonly IConfiguration _configuration; - private readonly IDotNetSdkInstaller _sdkInstaller; private readonly IServiceProvider _serviceProvider; private readonly IFeatures _features; - private readonly ICliHostEnvironment _hostEnvironment; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; private readonly IAppHostProjectFactory _projectFactory; @@ -94,12 +92,10 @@ public RunCommand( IAnsiConsole ansiConsole, AspireCliTelemetry telemetry, IConfiguration configuration, - IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, IServiceProvider serviceProvider, CliExecutionContext executionContext, - ICliHostEnvironment hostEnvironment, ILogger logger, IAppHostProjectFactory projectFactory, IAuxiliaryBackchannelMonitor backchannelMonitor, @@ -114,9 +110,7 @@ public RunCommand( _ansiConsole = ansiConsole; _configuration = configuration; _serviceProvider = serviceProvider; - _sdkInstaller = sdkInstaller; _features = features; - _hostEnvironment = hostEnvironment; _logger = logger; _projectFactory = projectFactory; _backchannelMonitor = backchannelMonitor; @@ -180,12 +174,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.Success; } - // Check if the .NET SDK is available - if (!await SdkInstallHelper.EnsureSdkInstalledAsync(_sdkInstaller, InteractionService, _features, Telemetry, _hostEnvironment, cancellationToken)) - { - return ExitCodeConstants.SdkNotInstalled; - } - AppHostProjectContext? context = null; try diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index 763466cf37a..95470a14386 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -121,8 +121,15 @@ private async Task DumpCapabilitiesAsync( try { - var appHostServerProject = _appHostServerProjectFactory.Create(tempDir); - var socketPath = appHostServerProject.GetSocketPath(); + // TODO: Support bundle mode by using DLL references instead of project references. + // In bundle mode, we'd need to add integration DLLs to the probing path rather than + // using additionalProjectReferences. For now, SDK dump only works with .NET SDK. + var appHostServerProjectInterface = _appHostServerProjectFactory.Create(tempDir); + if (appHostServerProjectInterface is not DotNetBasedAppHostServerProject appHostServerProject) + { + InteractionService.DisplayError("SDK dump is only available with .NET SDK installed."); + return ExitCodeConstants.FailedToBuildArtifacts; + } // Build packages list - empty since we only need core capabilities + optional integration var packages = new List<(string Name, string Version)>(); @@ -135,7 +142,6 @@ private async Task DumpCapabilitiesAsync( : null; await appHostServerProject.CreateProjectFilesAsync( - AppHostServerProject.DefaultSdkVersion, packages, cancellationToken, additionalProjectReferences: additionalProjectRefs); @@ -154,7 +160,7 @@ await appHostServerProject.CreateProjectFilesAsync( // Start the server var currentPid = Environment.ProcessId; - var (serverProcess, _) = appHostServerProject.Run(socketPath, currentPid, new Dictionary()); + var (socketPath, serverProcess, _) = appHostServerProject.Run(currentPid, new Dictionary()); try { diff --git a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs index 1996cb36c52..03332bbb49f 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs @@ -120,8 +120,15 @@ private async Task GenerateSdkAsync( try { - var appHostServerProject = _appHostServerProjectFactory.Create(tempDir); - var socketPath = appHostServerProject.GetSocketPath(); + // TODO: Support bundle mode by using DLL references instead of project references. + // In bundle mode, we'd need to add integration DLLs to the probing path rather than + // using additionalProjectReferences. For now, SDK generation only works with .NET SDK. + var appHostServerProjectInterface = _appHostServerProjectFactory.Create(tempDir); + if (appHostServerProjectInterface is not DotNetBasedAppHostServerProject appHostServerProject) + { + InteractionService.DisplayError("SDK generation is only available with .NET SDK installed."); + return ExitCodeConstants.FailedToBuildArtifacts; + } // Get code generation package for the target language var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(languageInfo.LanguageId, cancellationToken); @@ -130,14 +137,13 @@ private async Task GenerateSdkAsync( var packages = new List<(string Name, string Version)>(); if (codeGenPackage is not null) { - packages.Add((codeGenPackage, AppHostServerProject.DefaultSdkVersion)); + packages.Add((codeGenPackage, DotNetBasedAppHostServerProject.DefaultSdkVersion)); } _logger.LogDebug("Building AppHost server for SDK generation"); // Create project files with the integration project reference await appHostServerProject.CreateProjectFilesAsync( - AppHostServerProject.DefaultSdkVersion, packages, cancellationToken, additionalProjectReferences: [integrationProject.FullName]); @@ -156,7 +162,7 @@ await appHostServerProject.CreateProjectFilesAsync( // Start the server var currentPid = Environment.ProcessId; - var (serverProcess, _) = appHostServerProject.Run(socketPath, currentPid, new Dictionary()); + var (socketPath, serverProcess, _) = appHostServerProject.Run(currentPid, new Dictionary()); try { diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index ebe2d9fdf09..d69e8841c92 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -10,6 +10,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Exceptions; using Aspire.Cli.Interaction; +using Aspire.Cli.Layout; using Aspire.Cli.Packaging; using Aspire.Cli.Projects; using Aspire.Cli.Resources; @@ -27,6 +28,8 @@ internal sealed class UpdateCommand : BaseCommand private readonly IAppHostProjectFactory _projectFactory; private readonly ILogger _logger; private readonly ICliDownloader? _cliDownloader; + private readonly IBundleDownloader? _bundleDownloader; + private readonly ILayoutDiscovery _layoutDiscovery; private readonly ICliUpdateNotifier _updateNotifier; private readonly IFeatures _features; private readonly IConfigurationService _configurationService; @@ -48,6 +51,8 @@ public UpdateCommand( IAppHostProjectFactory projectFactory, ILogger logger, ICliDownloader? cliDownloader, + IBundleDownloader? bundleDownloader, + ILayoutDiscovery layoutDiscovery, IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, @@ -61,6 +66,8 @@ public UpdateCommand( _projectFactory = projectFactory; _logger = logger; _cliDownloader = cliDownloader; + _bundleDownloader = bundleDownloader; + _layoutDiscovery = layoutDiscovery; _updateNotifier = updateNotifier; _features = features; _configurationService = configurationService; @@ -121,6 +128,22 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return 0; } + // Check if we're running from a bundle layout + var layout = _layoutDiscovery.DiscoverLayout(); + if (layout is not null && _bundleDownloader is not null) + { + try + { + return await ExecuteBundleSelfUpdateAsync(layout, cancellationToken); + } + catch (OperationCanceledException) + { + InteractionService.DisplayCancellationMessage(); + return ExitCodeConstants.InvalidCommand; + } + } + + // Fall back to CLI-only update if (_cliDownloader is null) { InteractionService.DisplayError("CLI self-update is not available in this environment."); @@ -321,6 +344,77 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, Cancella } } + private async Task ExecuteBundleSelfUpdateAsync(LayoutConfiguration layout, CancellationToken cancellationToken) + { + if (_bundleDownloader is null) + { + InteractionService.DisplayError("Bundle update is not available in this environment."); + return ExitCodeConstants.InvalidCommand; + } + + var currentVersion = layout.Version ?? "unknown"; + var installPath = layout.LayoutPath; + + if (string.IsNullOrEmpty(installPath)) + { + InteractionService.DisplayError("Unable to determine bundle installation path."); + return ExitCodeConstants.InvalidCommand; + } + + InteractionService.DisplayMessage("package", $"Current bundle version: {currentVersion}"); + InteractionService.DisplayMessage("package", $"Bundle location: {installPath}"); + + // Check for updates + var latestVersion = await _bundleDownloader.GetLatestVersionAsync(cancellationToken); + if (string.IsNullOrEmpty(latestVersion)) + { + InteractionService.DisplayError("Unable to determine latest bundle version."); + return ExitCodeConstants.InvalidCommand; + } + + var isUpdateAvailable = await _bundleDownloader.IsUpdateAvailableAsync(currentVersion, cancellationToken); + if (!isUpdateAvailable) + { + InteractionService.DisplaySuccess($"You are already on the latest version ({currentVersion})."); + return 0; + } + + InteractionService.DisplayMessage("up_arrow", $"Updating to version: {latestVersion}"); + + try + { + // Download the bundle + var archivePath = await _bundleDownloader.DownloadLatestBundleAsync(cancellationToken); + + // Apply the update + var result = await _bundleDownloader.ApplyUpdateAsync(archivePath, installPath, cancellationToken); + + if (result.Success) + { + if (result.RestartRequired) + { + InteractionService.DisplayMessage("warning", "Update staged. Please restart to complete the update."); + if (!string.IsNullOrEmpty(result.PendingUpdateScript)) + { + InteractionService.DisplayMessage("information", $"Or run: {result.PendingUpdateScript}"); + } + } + return 0; + } + else + { + InteractionService.DisplayError($"Update failed: {result.ErrorMessage}"); + return ExitCodeConstants.InvalidCommand; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update bundle"); + InteractionService.DisplayError($"Failed to update bundle: {ex.Message}"); + return ExitCodeConstants.InvalidCommand; + } + } + private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken cancellationToken) { // Install to the same directory as the current CLI executable diff --git a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs index b65717df6dc..3afeaca8846 100644 --- a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs +++ b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs @@ -102,6 +102,13 @@ public static string GetFilePath(string directory) } var json = File.ReadAllText(filePath); + + // Handle empty files or whitespace-only content + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + return JsonSerializer.Deserialize(json, JsonSourceGenerationContext.Default.AspireJsonConfiguration); } @@ -158,22 +165,22 @@ public bool RemovePackage(string packageId) /// /// Gets all package references including the base Aspire.Hosting packages. /// Uses the SdkVersion for base packages. + /// Note: Aspire.Hosting.AppHost is an SDK package (not a runtime DLL) and is excluded. /// /// Enumerable of (PackageName, Version) tuples. public IEnumerable<(string Name, string Version)> GetAllPackages() { var sdkVersion = SdkVersion ?? throw new InvalidOperationException("SdkVersion must be set before calling GetAllPackages. Use LoadOrCreate to ensure it's set."); - // Base packages always included + // Base packages always included (Aspire.Hosting.AppHost is an SDK, not a runtime DLL) yield return ("Aspire.Hosting", sdkVersion); - yield return ("Aspire.Hosting.AppHost", sdkVersion); // Additional packages from settings if (Packages is not null) { foreach (var (packageName, version) in Packages) { - // Skip base packages as they're already included + // Skip base packages and SDK-only packages if (string.Equals(packageName, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase) || string.Equals(packageName, "Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Aspire.Cli/Configuration/ConfigurationService.cs b/src/Aspire.Cli/Configuration/ConfigurationService.cs index 2e18b00d87f..a7392e58dc4 100644 --- a/src/Aspire.Cli/Configuration/ConfigurationService.cs +++ b/src/Aspire.Cli/Configuration/ConfigurationService.cs @@ -20,7 +20,10 @@ public async Task SetConfigurationAsync(string key, string value, bool isGlobal if (File.Exists(settingsFilePath)) { var existingContent = await File.ReadAllTextAsync(settingsFilePath, cancellationToken); - settings = JsonNode.Parse(existingContent)?.AsObject() ?? new JsonObject(); + // Handle empty files or whitespace-only content + settings = string.IsNullOrWhiteSpace(existingContent) + ? new JsonObject() + : JsonNode.Parse(existingContent)?.AsObject() ?? new JsonObject(); } else { @@ -54,6 +57,13 @@ public async Task DeleteConfigurationAsync(string key, bool isGlobal = fal try { var existingContent = await File.ReadAllTextAsync(settingsFilePath, cancellationToken); + + // Handle empty files or whitespace-only content + if (string.IsNullOrWhiteSpace(existingContent)) + { + return false; + } + var settings = JsonNode.Parse(existingContent)?.AsObject(); if (settings is null) @@ -143,6 +153,13 @@ private static async Task LoadConfigurationFromFileAsync(string filePath, Dictio try { var content = await File.ReadAllTextAsync(filePath, cancellationToken); + + // Handle empty files or whitespace-only content + if (string.IsNullOrWhiteSpace(content)) + { + return; + } + var settings = JsonNode.Parse(content)?.AsObject(); if (settings is not null) @@ -159,10 +176,16 @@ private static async Task LoadConfigurationFromFileAsync(string filePath, Dictio /// /// Sets a nested value in a JsonObject using dot notation. /// Creates intermediate objects as needed and replaces primitives with objects when necessary. + /// Also removes any conflicting flattened keys (colon-separated format) to prevent duplicate key errors. /// private static void SetNestedValue(JsonObject settings, string key, string value) { var keyParts = key.Split('.'); + + // Remove any conflicting flattened keys (e.g., "features:polyglotSupportEnabled" when setting "features.polyglotSupportEnabled") + // This prevents duplicate key errors when loading the configuration + RemoveConflictingFlattenedKeys(settings, keyParts); + var currentObject = settings; // Navigate to the parent object, creating objects as needed @@ -184,6 +207,31 @@ private static void SetNestedValue(JsonObject settings, string key, string value currentObject[finalKey] = value; } + /// + /// Removes any flattened keys (colon-separated) that would conflict with a nested structure. + /// For example, when setting "features.polyglotSupportEnabled", remove "features:polyglotSupportEnabled". + /// + private static void RemoveConflictingFlattenedKeys(JsonObject settings, string[] keyParts) + { + // Build all possible flattened key patterns that could conflict + // For key "a.b.c", we need to remove "a:b:c" from the root + var flattenedKey = string.Join(":", keyParts); + settings.Remove(flattenedKey); + + // Also check for partial flattened keys at each level + // For example, if we have "a.b.c", we should also check for "a:b" in the root + // that might contain a "c" value + for (int i = 1; i < keyParts.Length; i++) + { + var partialKey = string.Join(":", keyParts.Take(i)); + if (settings.ContainsKey(partialKey) && settings[partialKey] is not JsonObject) + { + // This is a flattened value that conflicts with our nested structure + settings.Remove(partialKey); + } + } + } + /// /// Deletes a nested value from a JsonObject using dot notation. /// Cleans up empty parent objects after deletion. diff --git a/src/Aspire.Cli/Configuration/Features.cs b/src/Aspire.Cli/Configuration/Features.cs index 28d11c87817..61b5dff511d 100644 --- a/src/Aspire.Cli/Configuration/Features.cs +++ b/src/Aspire.Cli/Configuration/Features.cs @@ -2,10 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; namespace Aspire.Cli.Configuration; -internal sealed class Features(IConfiguration configuration) : IFeatures +internal sealed class Features(IConfiguration configuration, ILogger logger) : IFeatures { public bool IsFeatureEnabled(string feature, bool defaultValue) { @@ -13,11 +14,17 @@ public bool IsFeatureEnabled(string feature, bool defaultValue) var value = configuration[configKey]; + logger.LogDebug("Feature check: {Feature}, ConfigKey: {ConfigKey}, Value: '{Value}', DefaultValue: {DefaultValue}", + feature, configKey, value ?? "(null)", defaultValue); + if (string.IsNullOrEmpty(value)) { + logger.LogDebug("Feature {Feature} using default value: {DefaultValue}", feature, defaultValue); return defaultValue; } - return bool.TryParse(value, out var enabled) && enabled; + var enabled = bool.TryParse(value, out var parsed) && parsed; + logger.LogDebug("Feature {Feature} parsed value: {Enabled}", feature, enabled); + return enabled; } } \ No newline at end of file diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index f8b9156fcd8..13b7a1ce49f 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -29,8 +29,6 @@ internal interface IDotNetCliRunner Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task<(int ExitCode, Certificates.CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync(DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task TrustHttpCertificateAsync(DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task NewProjectAsync(string templateName, string name, string outputPath, string[] extraArgs, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task BuildAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); diff --git a/src/Aspire.Cli/Exceptions/DotNetSdkNotInstalledException.cs b/src/Aspire.Cli/Exceptions/DotNetSdkNotInstalledException.cs new file mode 100644 index 00000000000..253b93d322b --- /dev/null +++ b/src/Aspire.Cli/Exceptions/DotNetSdkNotInstalledException.cs @@ -0,0 +1,25 @@ +// 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.Exceptions; + +/// +/// Exception thrown when the .NET SDK is not installed or doesn't meet the minimum version requirement. +/// +internal sealed class DotNetSdkNotInstalledException : Exception +{ + public DotNetSdkNotInstalledException() + : base(".NET SDK is not installed or doesn't meet the minimum version requirement.") + { + } + + public DotNetSdkNotInstalledException(string message) + : base(message) + { + } + + public DotNetSdkNotInstalledException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/Aspire.Cli/Layout/LayoutConfiguration.cs b/src/Aspire.Cli/Layout/LayoutConfiguration.cs new file mode 100644 index 00000000000..1fc3e313000 --- /dev/null +++ b/src/Aspire.Cli/Layout/LayoutConfiguration.cs @@ -0,0 +1,199 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Shared; + +namespace Aspire.Cli.Layout; + +/// +/// Known layout component types. +/// +public enum LayoutComponent +{ + /// CLI executable. + Cli, + /// .NET runtime. + Runtime, + /// Pre-built AppHost Server. + AppHostServer, + /// Aspire Dashboard. + Dashboard, + /// Developer Control Plane. + Dcp, + /// NuGet Helper tool. + NuGetHelper, + /// Dev-certs tool. + DevCerts +} + +/// +/// Configuration for the Aspire bundle layout. +/// Specifies paths to all components in a self-contained bundle. +/// +public sealed class LayoutConfiguration +{ + /// + /// Bundle version (e.g., "13.2.0" or "dev" for local development). + /// + public string? Version { get; set; } + + /// + /// Target platform (e.g., "linux-x64", "win-x64"). + /// + public string? Platform { get; set; } + + /// + /// .NET runtime version included in the bundle (e.g., "10.0.0"). + /// + public string? RuntimeVersion { get; set; } + + /// + /// Root path of the layout. + /// + public string? LayoutPath { get; set; } + + /// + /// Component paths relative to LayoutPath. + /// + public LayoutComponents Components { get; set; } = new(); + + /// + /// List of integrations included in the bundle. + /// + public List BuiltInIntegrations { get; set; } = []; + + /// + /// Gets the absolute path to a component. + /// + public string? GetComponentPath(LayoutComponent component) + { + if (string.IsNullOrEmpty(LayoutPath)) + { + return null; + } + + var relativePath = component switch + { + LayoutComponent.Cli => Components.Cli, + LayoutComponent.Runtime => Components.Runtime, + LayoutComponent.AppHostServer => Components.ApphostServer, + LayoutComponent.Dashboard => Components.Dashboard, + LayoutComponent.Dcp => Components.Dcp, + LayoutComponent.NuGetHelper => Components.NugetHelper, + LayoutComponent.DevCerts => Components.DevCerts, + _ => null + }; + + return relativePath is not null ? Path.Combine(LayoutPath, relativePath) : null; + } + + /// + /// Gets the path to the dotnet muxer executable from the bundled runtime. + /// + public string? GetMuxerPath() + { + var runtimePath = GetComponentPath(LayoutComponent.Runtime); + if (runtimePath is null) + { + return null; + } + + var bundledPath = Path.Combine(runtimePath, BundleDiscovery.GetDotNetExecutableName()); + return File.Exists(bundledPath) ? bundledPath : null; + } + + /// + /// Gets the path to the dotnet executable. Alias for GetMuxerPath. + /// + public string? GetDotNetExePath() => GetMuxerPath(); + + /// + /// Gets the path to the DCP directory. + /// + public string? GetDcpPath() => GetComponentPath(LayoutComponent.Dcp); + + /// + /// Gets the path to the Dashboard directory. + /// + public string? GetDashboardPath() => GetComponentPath(LayoutComponent.Dashboard); + + /// + /// Gets the path to the AppHost Server executable. + /// + /// The path to aspire-server.exe. + public string? GetAppHostServerPath() + { + var serverPath = GetComponentPath(LayoutComponent.AppHostServer); + if (serverPath is null) + { + return null; + } + + return Path.Combine(serverPath, BundleDiscovery.GetExecutableFileName(BundleDiscovery.AppHostServerExecutableName)); + } + + /// + /// Gets the path to the NuGet Helper executable. + /// + /// The path to aspire-nuget.exe. + public string? GetNuGetHelperPath() + { + var helperPath = GetComponentPath(LayoutComponent.NuGetHelper); + if (helperPath is null) + { + return null; + } + + return Path.Combine(helperPath, BundleDiscovery.GetExecutableFileName(BundleDiscovery.NuGetHelperExecutableName)); + } + + /// + /// Gets the path to the dev-certs DLL (requires dotnet muxer to run). + /// + public string? GetDevCertsPath() + { + var devCertsPath = GetComponentPath(LayoutComponent.DevCerts); + return devCertsPath is not null ? Path.Combine(devCertsPath, BundleDiscovery.GetDllFileName(BundleDiscovery.DevCertsExecutableName)) : null; + } +} + +/// +/// Component paths within the layout. +/// +public sealed class LayoutComponents +{ + /// + /// Path to CLI executable (e.g., "aspire" or "aspire.exe"). + /// + public string? Cli { get; set; } = "aspire"; + + /// + /// Path to .NET runtime directory. + /// + public string? Runtime { get; set; } = BundleDiscovery.RuntimeDirectoryName; + + /// + /// Path to pre-built AppHost Server. + /// + public string? ApphostServer { get; set; } = BundleDiscovery.AppHostServerDirectoryName; + + /// + /// Path to Aspire Dashboard. + /// + public string? Dashboard { get; set; } = BundleDiscovery.DashboardDirectoryName; + + /// + /// Path to Developer Control Plane. + /// + public string? Dcp { get; set; } = BundleDiscovery.DcpDirectoryName; + + /// + /// Path to NuGet Helper tool. + /// + public string? NugetHelper { get; set; } = BundleDiscovery.NuGetHelperDirectoryName; + + /// + /// Path to dev-certs tool. + /// + public string? DevCerts { get; set; } = BundleDiscovery.DevCertsDirectoryName; +} diff --git a/src/Aspire.Cli/Layout/LayoutDiscovery.cs b/src/Aspire.Cli/Layout/LayoutDiscovery.cs new file mode 100644 index 00000000000..85673ef5486 --- /dev/null +++ b/src/Aspire.Cli/Layout/LayoutDiscovery.cs @@ -0,0 +1,283 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Shared; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Layout; + +/// +/// Service for discovering and loading Aspire bundle layouts. +/// Uses priority-based resolution: environment variables > relative paths from CLI location. +/// +public interface ILayoutDiscovery +{ + /// + /// Attempts to discover a valid layout configuration. + /// + /// Optional project directory (unused, kept for API compatibility). + /// Layout configuration if found and valid, null otherwise. + LayoutConfiguration? DiscoverLayout(string? projectDirectory = null); + + /// + /// Gets the path to a specific component, checking environment variable overrides first. + /// + string? GetComponentPath(LayoutComponent component, string? projectDirectory = null); + + /// + /// Checks if bundle mode is available and should be used. + /// + bool IsBundleModeAvailable(string? projectDirectory = null); +} + +/// +/// Implementation of layout discovery with priority-based resolution. +/// +public sealed class LayoutDiscovery : ILayoutDiscovery +{ + private readonly ILogger _logger; + + public LayoutDiscovery(ILogger logger) + { + _logger = logger; + } + + public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null) + { + // 1. Try environment variable for layout path + var envLayoutPath = Environment.GetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar); + if (!string.IsNullOrEmpty(envLayoutPath)) + { + _logger.LogDebug("Found ASPIRE_LAYOUT_PATH: {Path}", envLayoutPath); + var config = TryLoadLayoutFromPath(envLayoutPath); + if (config is not null) + { + return LogEnvironmentOverrides(config); + } + } + + // 2. Try relative paths from CLI executable + var relativeLayout = TryDiscoverRelativeLayout(); + if (relativeLayout is not null) + { + _logger.LogDebug("Discovered layout relative to CLI: {Path}", relativeLayout.LayoutPath); + return LogEnvironmentOverrides(relativeLayout); + } + + _logger.LogDebug("No bundle layout discovered"); + return null; + } + + public string? GetComponentPath(LayoutComponent component, string? projectDirectory = null) + { + // Check environment variable overrides first + var envPath = component switch + { + LayoutComponent.Runtime => Environment.GetEnvironmentVariable(BundleDiscovery.RuntimePathEnvVar), + LayoutComponent.Dcp => Environment.GetEnvironmentVariable(BundleDiscovery.DcpPathEnvVar), + LayoutComponent.Dashboard => Environment.GetEnvironmentVariable(BundleDiscovery.DashboardPathEnvVar), + LayoutComponent.AppHostServer => Environment.GetEnvironmentVariable(BundleDiscovery.AppHostServerPathEnvVar), + _ => null + }; + + if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath)) + { + return envPath; + } + + // Fall back to layout configuration + var layout = DiscoverLayout(projectDirectory); + return layout?.GetComponentPath(component); + } + + public bool IsBundleModeAvailable(string? projectDirectory = null) + { + // Check if user explicitly wants SDK mode + var useSdk = Environment.GetEnvironmentVariable(BundleDiscovery.UseGlobalDotNetEnvVar); + if (string.Equals(useSdk, "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(useSdk, "1", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("SDK mode forced via {EnvVar}", BundleDiscovery.UseGlobalDotNetEnvVar); + return false; + } + + var layout = DiscoverLayout(projectDirectory); + if (layout is null) + { + return false; + } + + // Validate that essential components exist + return ValidateLayout(layout); + } + + private LayoutConfiguration? TryLoadLayoutFromPath(string layoutPath) + { + _logger.LogDebug("TryLoadLayoutFromPath: {Path}", layoutPath); + + if (!Directory.Exists(layoutPath)) + { + _logger.LogDebug("Layout path does not exist: {Path}", layoutPath); + return null; + } + + _logger.LogDebug("Layout path exists, checking directory structure..."); + + // Log directory contents for debugging + try + { + var entries = Directory.GetFileSystemEntries(layoutPath).Select(Path.GetFileName).ToArray(); + _logger.LogDebug("Layout directory contents: {Contents}", string.Join(", ", entries)); + } + catch (Exception ex) + { + _logger.LogDebug("Could not list directory contents: {Error}", ex.Message); + } + + // Infer layout from directory structure (well-known relative paths) + return TryInferLayout(layoutPath); + } + + private LayoutConfiguration? TryDiscoverRelativeLayout() + { + // Get CLI executable location + var cliPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(cliPath)) + { + _logger.LogDebug("TryDiscoverRelativeLayout: ProcessPath is null or empty"); + return null; + } + + var cliDir = Path.GetDirectoryName(cliPath); + if (string.IsNullOrEmpty(cliDir)) + { + _logger.LogDebug("TryDiscoverRelativeLayout: Could not get directory from ProcessPath"); + return null; + } + + _logger.LogDebug("TryDiscoverRelativeLayout: CLI at {Path}, checking for layout...", cliDir); + + // Check if CLI is in a bundle layout + // First, check if components are siblings of the CLI (flat layout): + // {layout}/aspire + {layout}/runtime/ + {layout}/dashboard/ + ... + var layout = TryInferLayout(cliDir); + if (layout is not null) + { + return layout; + } + + // Next, check the parent directory (bin/ layout where CLI is in a subdirectory): + // {layout}/bin/aspire + {layout}/runtime/ + {layout}/dashboard/ + ... + var parentDir = Path.GetDirectoryName(cliDir); + if (!string.IsNullOrEmpty(parentDir)) + { + _logger.LogDebug("TryDiscoverRelativeLayout: Checking parent directory {Path}...", parentDir); + layout = TryInferLayout(parentDir); + if (layout is not null) + { + return layout; + } + } + + return null; + } + + private LayoutConfiguration? TryInferLayout(string layoutPath) + { + // Check for essential directories using BundleDiscovery constants + var runtimePath = Path.Combine(layoutPath, BundleDiscovery.RuntimeDirectoryName); + var dashboardPath = Path.Combine(layoutPath, BundleDiscovery.DashboardDirectoryName); + var dcpPath = Path.Combine(layoutPath, BundleDiscovery.DcpDirectoryName); + var serverPath = Path.Combine(layoutPath, BundleDiscovery.AppHostServerDirectoryName); + + _logger.LogDebug("TryInferLayout: Checking layout at {Path}", layoutPath); + _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.RuntimeDirectoryName, Directory.Exists(runtimePath) ? "exists" : "MISSING"); + _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.DashboardDirectoryName, Directory.Exists(dashboardPath) ? "exists" : "MISSING"); + _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.DcpDirectoryName, Directory.Exists(dcpPath) ? "exists" : "MISSING"); + _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.AppHostServerDirectoryName, Directory.Exists(serverPath) ? "exists" : "MISSING"); + + if (!Directory.Exists(runtimePath) || !Directory.Exists(dashboardPath) || + !Directory.Exists(dcpPath) || !Directory.Exists(serverPath)) + { + _logger.LogDebug("TryInferLayout: Layout rejected - missing required directories"); + return null; + } + + // Check for muxer + var muxerName = BundleDiscovery.GetDotNetExecutableName(); + var muxerPath = Path.Combine(runtimePath, muxerName); + _logger.LogDebug(" runtime/{Muxer}: {Exists}", muxerName, File.Exists(muxerPath) ? "exists" : "MISSING"); + + if (!File.Exists(muxerPath)) + { + _logger.LogDebug("TryInferLayout: Layout rejected - muxer not found"); + return null; + } + + _logger.LogDebug("TryInferLayout: Layout is valid"); + + // Infer a basic layout configuration + return new LayoutConfiguration + { + LayoutPath = layoutPath, + Components = new LayoutComponents() + }; + } + + private LayoutConfiguration LogEnvironmentOverrides(LayoutConfiguration config) + { + // Environment variables for specific components take precedence + // These will be checked at GetComponentPath time, but we note them here for logging + + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(BundleDiscovery.RuntimePathEnvVar))) + { + _logger.LogDebug("Runtime path override from {EnvVar}", BundleDiscovery.RuntimePathEnvVar); + } + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(BundleDiscovery.DcpPathEnvVar))) + { + _logger.LogDebug("DCP path override from {EnvVar}", BundleDiscovery.DcpPathEnvVar); + } + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(BundleDiscovery.DashboardPathEnvVar))) + { + _logger.LogDebug("Dashboard path override from {EnvVar}", BundleDiscovery.DashboardPathEnvVar); + } + + return config; + } + + private bool ValidateLayout(LayoutConfiguration layout) + { + // Check that muxer exists (global dotnet in dev mode, bundled in production) + var muxerPath = layout.GetMuxerPath(); + if (muxerPath is null || !File.Exists(muxerPath)) + { + _logger.LogDebug("Layout validation failed: muxer not found at {Path}", muxerPath); + return false; + } + + // Check that AppHostServer exists + var serverPath = layout.GetAppHostServerPath(); + if (serverPath is null || !File.Exists(serverPath)) + { + _logger.LogDebug("Layout validation failed: AppHostServer not found at {Path}", serverPath); + return false; + } + + // Require DCP and Dashboard for valid layouts + var dcpPath = layout.GetComponentPath(LayoutComponent.Dcp); + if (dcpPath is null || !Directory.Exists(dcpPath)) + { + _logger.LogDebug("Layout validation failed: DCP not found"); + return false; + } + + var dashboardPath = layout.GetComponentPath(LayoutComponent.Dashboard); + if (dashboardPath is null || !Directory.Exists(dashboardPath)) + { + _logger.LogDebug("Layout validation failed: Dashboard not found"); + return false; + } + + return true; + } +} diff --git a/src/Aspire.Cli/Layout/LayoutProcessRunner.cs b/src/Aspire.Cli/Layout/LayoutProcessRunner.cs new file mode 100644 index 00000000000..3b2f5e79192 --- /dev/null +++ b/src/Aspire.Cli/Layout/LayoutProcessRunner.cs @@ -0,0 +1,146 @@ +// 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 Aspire.Shared; + +namespace Aspire.Cli.Layout; + +/// +/// Helper to detect the current runtime identifier. +/// Delegates to shared BundleDiscovery for consistent behavior. +/// +internal static class RuntimeIdentifierHelper +{ + /// + /// Gets the current platform's runtime identifier. + /// + public static string GetCurrent() => BundleDiscovery.GetCurrentRuntimeIdentifier(); + + /// + /// Gets the archive extension for the current platform. + /// + public static string GetArchiveExtension() => BundleDiscovery.GetArchiveExtension(); +} + +/// +/// Utilities for running processes using the layout's .NET runtime. +/// Supports both native executables and framework-dependent DLLs. +/// +internal static class LayoutProcessRunner +{ + /// + /// Determines if a path refers to a DLL that needs dotnet to run. + /// + private static bool IsDll(string path) => path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase); + + /// + /// Runs a tool and captures output. Automatically detects if the tool + /// is a DLL (needs muxer) or native executable (runs directly). + /// + public static async Task<(int ExitCode, string Output, string Error)> RunAsync( + LayoutConfiguration layout, + string toolPath, + IEnumerable arguments, + string? workingDirectory = null, + IDictionary? environmentVariables = null, + CancellationToken ct = default) + { + using var process = CreateProcess(layout, toolPath, arguments, workingDirectory, environmentVariables, redirectOutput: true); + + process.Start(); + + var outputTask = process.StandardOutput.ReadToEndAsync(ct); + var errorTask = process.StandardError.ReadToEndAsync(ct); + + await process.WaitForExitAsync(ct); + + return (process.ExitCode, await outputTask, await errorTask); + } + + /// + /// Starts a process without waiting for it to exit. + /// Returns the Process object for the caller to manage. + /// + public static Process Start( + LayoutConfiguration layout, + string toolPath, + IEnumerable arguments, + string? workingDirectory = null, + IDictionary? environmentVariables = null, + bool redirectOutput = false) + { + var process = CreateProcess(layout, toolPath, arguments, workingDirectory, environmentVariables, redirectOutput); + process.Start(); + return process; + } + + /// + /// Creates a configured Process for running a bundle tool. + /// For DLLs, uses the layout's muxer (dotnet). For executables, runs directly. + /// + private static Process CreateProcess( + LayoutConfiguration layout, + string toolPath, + IEnumerable arguments, + string? workingDirectory, + IDictionary? environmentVariables, + bool redirectOutput) + { + var isDll = IsDll(toolPath); + var process = new Process(); + + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + + if (isDll) + { + // DLLs need the muxer to run + var muxerPath = layout.GetMuxerPath() + ?? throw new InvalidOperationException("Layout muxer not found. Cannot run framework-dependent tool."); + process.StartInfo.FileName = muxerPath; + process.StartInfo.ArgumentList.Add(toolPath); + } + else + { + // Native executables run directly + process.StartInfo.FileName = toolPath; + } + + if (redirectOutput) + { + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + } + + // Set DOTNET_ROOT to use the layout's runtime + var runtimePath = layout.GetComponentPath(LayoutComponent.Runtime); + if (runtimePath is not null) + { + process.StartInfo.Environment["DOTNET_ROOT"] = runtimePath; + process.StartInfo.Environment["DOTNET_MULTILEVEL_LOOKUP"] = "0"; + } + + // Add custom environment variables + if (environmentVariables is not null) + { + foreach (var (key, value) in environmentVariables) + { + process.StartInfo.Environment[key] = value; + } + } + + if (workingDirectory is not null) + { + process.StartInfo.WorkingDirectory = workingDirectory; + } + + // Add arguments + foreach (var arg in arguments) + { + process.StartInfo.ArgumentList.Add(arg); + } + + return process; + } +} diff --git a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs new file mode 100644 index 00000000000..f868ba5fd78 --- /dev/null +++ b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs @@ -0,0 +1,280 @@ +// 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.Serialization; +using Aspire.Cli.Configuration; +using Aspire.Cli.Layout; +using Microsoft.Extensions.Logging; +using NuGetPackage = Aspire.Shared.NuGetPackageCli; + +namespace Aspire.Cli.NuGet; + +/// +/// NuGet package cache implementation that uses the bundle's NuGetHelper tool +/// instead of the .NET SDK's `dotnet package search` command. +/// +internal sealed class BundleNuGetPackageCache : INuGetPackageCache +{ + private readonly ILayoutDiscovery _layoutDiscovery; + private readonly ILogger _logger; + private readonly IFeatures _features; + + // List of deprecated packages that should be filtered by default + private static readonly HashSet s_deprecatedPackages = new(StringComparer.OrdinalIgnoreCase) + { + "Aspire.Hosting.Dapr" + }; + + public BundleNuGetPackageCache( + ILayoutDiscovery layoutDiscovery, + ILogger logger, + IFeatures features) + { + _layoutDiscovery = layoutDiscovery; + _logger = logger; + _features = features; + } + + public async Task> GetTemplatePackagesAsync( + DirectoryInfo workingDirectory, + bool prerelease, + FileInfo? nugetConfigFile, + CancellationToken cancellationToken) + { + var packages = await SearchPackagesInternalAsync( + workingDirectory, + "Aspire.ProjectTemplates", + prerelease, + nugetConfigFile, + cancellationToken).ConfigureAwait(false); + + return packages.Where(p => p.Id.Equals("Aspire.ProjectTemplates", StringComparison.OrdinalIgnoreCase)); + } + + public async Task> GetIntegrationPackagesAsync( + DirectoryInfo workingDirectory, + bool prerelease, + FileInfo? nugetConfigFile, + CancellationToken cancellationToken) + { + var packages = await SearchPackagesInternalAsync( + workingDirectory, + "Aspire.Hosting", + prerelease, + nugetConfigFile, + cancellationToken).ConfigureAwait(false); + + return FilterPackages(packages, filter: null); + } + + public async Task> GetCliPackagesAsync( + DirectoryInfo workingDirectory, + bool prerelease, + FileInfo? nugetConfigFile, + CancellationToken cancellationToken) + { + var packages = await SearchPackagesInternalAsync( + workingDirectory, + "Aspire.Cli", + prerelease, + nugetConfigFile, + cancellationToken).ConfigureAwait(false); + + return packages.Where(p => p.Id.Equals("Aspire.Cli", StringComparison.OrdinalIgnoreCase)); + } + + public async Task> GetPackagesAsync( + DirectoryInfo workingDirectory, + string packageId, + Func? filter, + bool prerelease, + FileInfo? nugetConfigFile, + bool useCache, + CancellationToken cancellationToken) + { + var packages = await SearchPackagesInternalAsync( + workingDirectory, + packageId, + prerelease, + nugetConfigFile, + cancellationToken).ConfigureAwait(false); + + return FilterPackages(packages, filter); + } + + private async Task> SearchPackagesInternalAsync( + DirectoryInfo workingDirectory, + string query, + bool prerelease, + FileInfo? nugetConfigFile, + CancellationToken cancellationToken) + { + var layout = _layoutDiscovery.DiscoverLayout(); + if (layout is null) + { + throw new InvalidOperationException("Bundle layout not found. Cannot perform NuGet search in bundle mode."); + } + + var helperPath = layout.GetNuGetHelperPath(); + if (helperPath is null || !File.Exists(helperPath)) + { + throw new InvalidOperationException("NuGet helper tool not found at expected location."); + } + + // Build arguments for NuGetHelper search command + var args = new List + { + "search", + "--query", query, + "--take", "1000", + "--format", "json" + }; + + if (prerelease) + { + args.Add("--prerelease"); + } + + // Pass working directory for nuget.config discovery + args.Add("--working-dir"); + args.Add(workingDirectory.FullName); + + // If explicit nuget.config is provided, use it + if (nugetConfigFile is not null) + { + args.Add("--nuget-config"); + args.Add(nugetConfigFile.FullName); + } + + // Enable verbose output for debugging - goes to stderr so won't mix with JSON on stdout + if (_logger.IsEnabled(LogLevel.Debug)) + { + args.Add("--verbose"); + } + + _logger.LogDebug("Running NuGet search via NuGetHelper: {Query}", query); + _logger.LogDebug("NuGetHelper path: {HelperPath}", helperPath); + _logger.LogDebug("NuGetHelper args: {Args}", string.Join(" ", args)); + _logger.LogDebug("Working directory: {WorkingDir}", workingDirectory.FullName); + + var (exitCode, output, error) = await LayoutProcessRunner.RunAsync( + layout, + helperPath, + args, + workingDirectory: workingDirectory.FullName, + ct: cancellationToken).ConfigureAwait(false); + + // Log stderr output (verbose info from NuGetHelper) + if (!string.IsNullOrWhiteSpace(error)) + { + _logger.LogDebug("NuGetHelper stderr: {Error}", error); + } + + if (exitCode != 0) + { + _logger.LogError("NuGet search failed with exit code {ExitCode}", exitCode); + _logger.LogError("NuGet search stderr: {Error}", error); + _logger.LogError("NuGet search stdout: {Output}", output); + throw new NuGetPackageCacheException($"Package search failed: {error}"); + } + + _logger.LogDebug("NuGet search returned {Length} bytes", output?.Length ?? 0); + + try + { + if (string.IsNullOrEmpty(output)) + { + _logger.LogWarning("NuGet search returned empty output"); + return []; + } + + var result = JsonSerializer.Deserialize(output, BundleSearchJsonContext.Default.BundleSearchResult); + if (result?.Packages is null) + { + return []; + } + + // Convert to NuGetPackage format + return result.Packages.Select(p => new NuGetPackage + { + Id = p.Id, + Version = p.Version, + Source = p.Source ?? string.Empty + }).ToList(); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to parse search results"); + throw new NuGetPackageCacheException($"Failed to parse search results: {ex.Message}"); + } + } + + private IEnumerable FilterPackages(IEnumerable packages, Func? filter) + { + var effectiveFilter = (NuGetPackage p) => + { + if (filter is not null) + { + return filter(p.Id); + } + + var isOfficialPackage = IsOfficialOrCommunityToolkitPackage(p.Id); + + // Apply deprecated package filter unless the user wants to show deprecated packages + if (isOfficialPackage && !_features.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false)) + { + return !s_deprecatedPackages.Contains(p.Id); + } + + return isOfficialPackage; + }; + + return packages.Where(effectiveFilter); + } + + private static bool IsOfficialOrCommunityToolkitPackage(string packageName) + { + var isHostingOrCommunityToolkitNamespaced = packageName.StartsWith("Aspire.Hosting.", StringComparison.Ordinal) || + packageName.StartsWith("CommunityToolkit.Aspire.Hosting.", StringComparison.Ordinal) || + packageName.Equals("Aspire.ProjectTemplates", StringComparison.Ordinal) || + packageName.Equals("Aspire.Cli", StringComparison.Ordinal); + + var isExcluded = packageName.StartsWith("Aspire.Hosting.AppHost") || + packageName.StartsWith("Aspire.Hosting.Sdk") || + packageName.StartsWith("Aspire.Hosting.Orchestration") || + packageName.StartsWith("Aspire.Hosting.Testing") || + packageName.StartsWith("Aspire.Hosting.Msi"); + + return isHostingOrCommunityToolkitNamespaced && !isExcluded; + } +} + +#region JSON Models for NuGetHelper output + +internal sealed class BundleSearchResult +{ + public List? Packages { get; set; } + public int TotalHits { get; set; } +} + +internal sealed class BundlePackageInfo +{ + public string Id { get; set; } = ""; + public string Version { get; set; } = ""; + public string? Description { get; set; } + public string? Authors { get; set; } + public List? AllVersions { get; set; } + public string? Source { get; set; } + public bool Deprecated { get; set; } +} + +[JsonSerializable(typeof(BundleSearchResult))] +[JsonSerializable(typeof(BundlePackageInfo))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal sealed partial class BundleSearchJsonContext : JsonSerializerContext +{ +} + +#endregion + diff --git a/src/Aspire.Cli/NuGet/BundleNuGetService.cs b/src/Aspire.Cli/NuGet/BundleNuGetService.cs new file mode 100644 index 00000000000..e54e612559e --- /dev/null +++ b/src/Aspire.Cli/NuGet/BundleNuGetService.cs @@ -0,0 +1,209 @@ +// 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.Layout; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.NuGet; + +/// +/// Service for NuGet operations that works in bundle mode. +/// Uses the NuGetHelper tool via the layout runtime. +/// +public interface INuGetService +{ + /// + /// Restores packages to the cache and creates a flat layout. + /// + /// The packages to restore. + /// The target framework. + /// Additional NuGet sources. + /// Working directory for nuget.config discovery. + /// Cancellation token. + /// Path to the restored libs directory. + Task RestorePackagesAsync( + IEnumerable<(string Id, string Version)> packages, + string targetFramework = "net10.0", + IEnumerable? sources = null, + string? workingDirectory = null, + CancellationToken ct = default); +} + +/// +/// NuGet service implementation that uses the bundle's NuGetHelper tool. +/// +public sealed class BundleNuGetService : INuGetService +{ + private readonly ILayoutDiscovery _layoutDiscovery; + private readonly ILogger _logger; + private readonly string _cacheDirectory; + + public BundleNuGetService( + ILayoutDiscovery layoutDiscovery, + ILogger logger) + { + _layoutDiscovery = layoutDiscovery; + _logger = logger; + _cacheDirectory = GetCacheDirectory(); + } + + public async Task RestorePackagesAsync( + IEnumerable<(string Id, string Version)> packages, + string targetFramework = "net10.0", + IEnumerable? sources = null, + string? workingDirectory = null, + CancellationToken ct = default) + { + var layout = _layoutDiscovery.DiscoverLayout(); + if (layout is null) + { + throw new InvalidOperationException("Bundle layout not found. Cannot perform NuGet restore in bundle mode."); + } + + var helperPath = layout.GetNuGetHelperPath(); + if (helperPath is null || !File.Exists(helperPath)) + { + throw new InvalidOperationException($"NuGet helper tool not found."); + } + + var packageList = packages.ToList(); + if (packageList.Count == 0) + { + throw new ArgumentException("At least one package is required", nameof(packages)); + } + + // Compute a hash for the package set to create a unique restore location + var packageHash = ComputePackageHash(packageList, targetFramework); + var restoreDir = Path.Combine(_cacheDirectory, "restore", packageHash); + var objDir = Path.Combine(restoreDir, "obj"); + var libsDir = Path.Combine(restoreDir, "libs"); + var assetsPath = Path.Combine(objDir, "project.assets.json"); + + // Check if already restored + if (Directory.Exists(libsDir) && Directory.GetFiles(libsDir, "*.dll").Length > 0) + { + _logger.LogDebug("Using cached restore at {Path}", libsDir); + return libsDir; + } + + Directory.CreateDirectory(objDir); + + // Step 1: Restore packages + var restoreArgs = new List + { + "restore", + "--output", objDir, + "--framework", targetFramework + }; + + foreach (var (id, version) in packageList) + { + restoreArgs.Add("--package"); + restoreArgs.Add($"{id},{version}"); + } + + if (sources is not null) + { + foreach (var source in sources) + { + restoreArgs.Add("--source"); + restoreArgs.Add(source); + } + } + + // Pass working directory for nuget.config discovery + if (!string.IsNullOrEmpty(workingDirectory)) + { + restoreArgs.Add("--working-dir"); + restoreArgs.Add(workingDirectory); + } + + // Enable verbose output for debugging + if (_logger.IsEnabled(LogLevel.Debug)) + { + restoreArgs.Add("--verbose"); + } + + _logger.LogDebug("Restoring {Count} packages", packageList.Count); + _logger.LogDebug("NuGetHelper path: {HelperPath}", helperPath); + _logger.LogDebug("NuGetHelper args: {Args}", string.Join(" ", restoreArgs)); + + var (exitCode, output, error) = await LayoutProcessRunner.RunAsync( + layout, + helperPath, + restoreArgs, + ct: ct); + + // Log stderr output (verbose info from NuGetHelper) + if (!string.IsNullOrWhiteSpace(error)) + { + _logger.LogDebug("NuGetHelper restore stderr: {Error}", error); + } + + if (exitCode != 0) + { + _logger.LogError("Package restore failed with exit code {ExitCode}", exitCode); + _logger.LogError("Package restore stderr: {Error}", error); + _logger.LogError("Package restore stdout: {Output}", output); + throw new InvalidOperationException($"Package restore failed: {error}"); + } + + // Step 2: Create flat layout + var layoutArgs = new List + { + "layout", + "--assets", assetsPath, + "--output", libsDir, + "--framework", targetFramework + }; + + // Enable verbose output for debugging + if (_logger.IsEnabled(LogLevel.Debug)) + { + layoutArgs.Add("--verbose"); + } + + _logger.LogDebug("Creating layout from {AssetsPath}", assetsPath); + _logger.LogDebug("Layout args: {Args}", string.Join(" ", layoutArgs)); + + (exitCode, output, error) = await LayoutProcessRunner.RunAsync( + layout, + helperPath, + layoutArgs, + ct: ct); + + // Log stderr output (verbose info from NuGetHelper) + if (!string.IsNullOrWhiteSpace(error)) + { + _logger.LogDebug("NuGetHelper layout stderr: {Error}", error); + } + + if (exitCode != 0) + { + _logger.LogError("Layout creation failed with exit code {ExitCode}", exitCode); + _logger.LogError("Layout creation stderr: {Error}", error); + _logger.LogError("Layout creation stdout: {Output}", output); + throw new InvalidOperationException($"Layout creation failed: {error}"); + } + + _logger.LogDebug("Packages restored to {Path}", libsDir); + return libsDir; + } + + private static string ComputePackageHash(List<(string Id, string Version)> packages, string tfm) + { + var content = string.Join(";", packages.OrderBy(p => p.Id).Select(p => $"{p.Id}:{p.Version}")); + content += $";tfm:{tfm}"; + + // Use SHA256 for stable hash across processes/runtimes + var hashBytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(content)); + return Convert.ToHexString(hashBytes)[..16]; // Use first 16 chars (64 bits) for reasonable uniqueness + } + + private static string GetCacheDirectory() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, ".aspire", "packages"); + } +} + diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index ddba052208f..0456a85bf62 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -41,9 +41,12 @@ public Task> GetChannelsAsync(CancellationToken canc var prHives = executionContext.HivesDirectory.GetDirectories(); foreach (var prHive in prHives) { + // The packages subdirectory contains the actual .nupkg files + // Use forward slashes for cross-platform NuGet config compatibility + var packagesPath = Path.Combine(prHive.FullName, "packages").Replace('\\', '/'); var prChannel = PackageChannel.CreateExplicitChannel(prHive.Name, PackageChannelQuality.Prerelease, new[] { - new PackageMapping("Aspire*", prHive.FullName), + new PackageMapping("Aspire*", packagesPath), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") }, nuGetPackageCache); diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 052182ecd5f..bc023fcb88a 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -21,6 +21,7 @@ using Aspire.Cli.DotNet; using Aspire.Cli.Git; using Aspire.Cli.Interaction; +using Aspire.Cli.Layout; using Aspire.Cli.Mcp; using Aspire.Cli.Mcp.Docs; using Aspire.Cli.NuGet; @@ -219,11 +220,47 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddSingleton(); builder.Services.AddTelemetryServices(); builder.Services.AddTransient(); + + // Register certificate tool runner implementations - factory chooses based on layout availability + builder.Services.AddSingleton(sp => + { + var layoutDiscovery = sp.GetRequiredService(); + var layout = layoutDiscovery.DiscoverLayout(); + var loggerFactory = sp.GetRequiredService(); + + // Use bundle runner if layout exists and has dev-certs tool + if (layout is not null && layout.GetDevCertsPath() is string devCertsPath && File.Exists(devCertsPath)) + { + return new BundleCertificateToolRunner(layout, loggerFactory.CreateLogger()); + } + + // Fall back to SDK-based runner + return new SdkCertificateToolRunner(loggerFactory.CreateLogger()); + }); + builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddTransient(); - builder.Services.AddSingleton(); + + // Register both NuGetPackageCache implementations - factory chooses based on layout availability + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => + { + var layoutDiscovery = sp.GetRequiredService(); + var layout = layoutDiscovery.DiscoverLayout(); + + // Use bundle cache if layout exists and has NuGetHelper + if (layout is not null && layout.GetNuGetHelperPath() is string helperPath && File.Exists(helperPath)) + { + return sp.GetRequiredService(); + } + + // Fall back to SDK-based cache + return sp.GetRequiredService(); + }); + builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); @@ -234,6 +271,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(_ => new FirstTimeUseNoticeSentinel(GetUsersAspirePath())); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddMemoryCache(); @@ -243,6 +281,11 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Bundle layout services (for polyglot apphost without .NET SDK). + // Registered before NuGetPackageCache so the factory can choose implementation. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + // Git repository operations. builder.Services.AddSingleton(); @@ -664,3 +707,4 @@ public void Enrich(Profile profile) profile.Capabilities.Interactive = true; } } + diff --git a/src/Aspire.Cli/Projects/AppHostProjectFactory.cs b/src/Aspire.Cli/Projects/AppHostProjectFactory.cs index afd52fb0675..1b9559be1fe 100644 --- a/src/Aspire.Cli/Projects/AppHostProjectFactory.cs +++ b/src/Aspire.Cli/Projects/AppHostProjectFactory.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Configuration; +using Microsoft.Extensions.Logging; namespace Aspire.Cli.Projects; @@ -14,17 +15,20 @@ internal sealed class AppHostProjectFactory : IAppHostProjectFactory private readonly Func _guestProjectFactory; private readonly ILanguageDiscovery _languageDiscovery; private readonly IFeatures _features; + private readonly ILogger _logger; public AppHostProjectFactory( DotNetAppHostProject dotNetProject, Func guestProjectFactory, ILanguageDiscovery languageDiscovery, - IFeatures features) + IFeatures features, + ILogger logger) { _dotNetProject = dotNetProject; _guestProjectFactory = guestProjectFactory; _languageDiscovery = languageDiscovery; _features = features; + _logger = logger; } /// @@ -41,17 +45,31 @@ public IAppHostProject GetProject(LanguageInfo language) /// public IAppHostProject? TryGetProject(FileInfo appHostFile) { + _logger.LogDebug("TryGetProject called for file: {AppHostFile}", appHostFile.FullName); + var language = _languageDiscovery.GetLanguageByFile(appHostFile); if (language is null) { + _logger.LogDebug("No language found for file: {AppHostFile}", appHostFile.FullName); return null; } + _logger.LogDebug("Language detected: {LanguageId} for file: {AppHostFile}", language.LanguageId.Value, appHostFile.FullName); + // C# is always enabled, guest languages require feature flag - if (!language.LanguageId.Value.Equals(KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase) && - !_features.IsFeatureEnabled(KnownFeatures.PolyglotSupportEnabled, false)) + if (!language.LanguageId.Value.Equals(KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase)) { - return null; + var polyglotEnabled = _features.IsFeatureEnabled(KnownFeatures.PolyglotSupportEnabled, false); + _logger.LogDebug("Polyglot support enabled: {PolyglotEnabled}", polyglotEnabled); + + if (!polyglotEnabled) + { + _logger.LogWarning("Skipping {Language} apphost because polyglot support is disabled (features:polyglotSupportEnabled=false): {AppHostFile}", + language.DisplayName, appHostFile.FullName); + return null; + } + + _logger.LogDebug("Polyglot apphost accepted: {Language} at {AppHostFile}", language.DisplayName, appHostFile.FullName); } return GetProject(language); diff --git a/src/Aspire.Cli/Projects/AppHostServerProject.cs b/src/Aspire.Cli/Projects/AppHostServerProject.cs index 40bcebf8077..21602eed2b0 100644 --- a/src/Aspire.Cli/Projects/AppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/AppHostServerProject.cs @@ -1,16 +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 System.Diagnostics; -using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; -using System.Xml.Linq; using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; +using Aspire.Cli.Layout; +using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; -using Aspire.Cli.Utils; -using Aspire.Hosting; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Projects; @@ -20,766 +17,113 @@ namespace Aspire.Cli.Projects; /// internal interface IAppHostServerProjectFactory { - AppHostServerProject Create(string appPath); + IAppHostServerProject Create(string appPath); } /// -/// Factory implementation that creates AppHostServerProject instances with IPackagingService and IConfigurationService. +/// Factory implementation that creates IAppHostServerProject instances. +/// Chooses between DotNetBasedAppHostServerProject (dev mode) and PrebuiltAppHostServer (bundle mode). /// internal sealed class AppHostServerProjectFactory( IDotNetCliRunner dotNetCliRunner, IPackagingService packagingService, IConfigurationService configurationService, - ILogger logger) : IAppHostServerProjectFactory + ILayoutDiscovery layoutDiscovery, + BundleNuGetService bundleNuGetService, + ILoggerFactory loggerFactory) : IAppHostServerProjectFactory { - public AppHostServerProject Create(string appPath) => new AppHostServerProject(appPath, dotNetCliRunner, packagingService, configurationService, logger); -} - -/// -/// Manages the AppHost server project that hosts the Aspire.Hosting runtime for polyglot apphosts. -/// This project is dynamically generated and built to provide the Aspire infrastructure -/// (distributed application builder, resource management, dashboard, etc.) that polyglot apphosts -/// (TypeScript, Python, etc.) connect to via JSON-RPC to define and manage their resources. -/// -internal sealed class AppHostServerProject -{ - private const string ProjectHashFileName = ".projecthash"; - private const string FolderPrefix = ".aspire"; - private const string AppsFolder = "hosts"; - public const string ProjectFileName = "AppHostServer.csproj"; - private const string ProjectDllName = "AppHostServer.dll"; - private const string TargetFramework = "net10.0"; - - /// - /// Gets the default Aspire SDK version based on the CLI version. - /// - public static string DefaultSdkVersion => GetEffectiveVersion(); - - private static string GetEffectiveVersion() - { - var version = VersionHelper.GetDefaultTemplateVersion(); - - // Strip the commit SHA suffix (e.g., "9.2.0+abc123" -> "9.2.0") - var plusIndex = version.IndexOf('+'); - if (plusIndex > 0) - { - version = version[..plusIndex]; - } - - // Dev versions (e.g., "13.2.0-dev") don't exist on NuGet, fall back to latest stable - if (version.EndsWith("-dev", StringComparison.OrdinalIgnoreCase)) - { - // Use the latest stable version available on NuGet - // This should be updated when new stable versions are released - return "13.1.0"; - } - return version; - } - - /// - /// Path to local Aspire repo root (e.g., /path/to/aspire). - /// When set, uses direct project references instead of NuGet packages. - /// - public static string? LocalAspirePath = Environment.GetEnvironmentVariable("ASPIRE_REPO_ROOT"); - - public const string BuildFolder = "build"; - private const string AssemblyName = "AppHostServer"; - private readonly string _projectModelPath; - private readonly string _appPath; - private readonly string _userSecretsId; - private readonly IDotNetCliRunner _dotNetCliRunner; - private readonly IPackagingService _packagingService; - private readonly IConfigurationService _configurationService; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the AppHostServerProject class. - /// - /// Specifies the application path for the custom language. - /// The .NET CLI runner for executing dotnet commands. - /// The packaging service for channel resolution. - /// The configuration service for reading global settings. - /// The logger for diagnostic output. - /// Optional custom path for the project model directory. If not specified, uses a temp directory based on appPath hash. - public AppHostServerProject(string appPath, IDotNetCliRunner dotNetCliRunner, IPackagingService packagingService, IConfigurationService configurationService, ILogger logger, string? projectModelPath = null) - { - _appPath = Path.GetFullPath(appPath); - _appPath = new Uri(_appPath).LocalPath; - _appPath = OperatingSystem.IsWindows() ? _appPath.ToLowerInvariant() : _appPath; - _dotNetCliRunner = dotNetCliRunner; - _packagingService = packagingService; - _configurationService = configurationService; - _logger = logger; - - var pathHash = SHA256.HashData(Encoding.UTF8.GetBytes(_appPath)); - - if (projectModelPath is not null) - { - _projectModelPath = projectModelPath; - } - else - { - var pathDir = Convert.ToHexString(pathHash)[..12].ToLowerInvariant(); - _projectModelPath = Path.Combine(Path.GetTempPath(), FolderPrefix, AppsFolder, pathDir); - } - - // Create a stable UserSecretsId based on the app path hash - _userSecretsId = new Guid(pathHash[..16]).ToString(); - - Directory.CreateDirectory(_projectModelPath); - } - - public string ProjectModelPath => _projectModelPath; - public string AppPath => _appPath; - public string UserSecretsId => _userSecretsId; - public string BuildPath => Path.Combine(_projectModelPath, BuildFolder); - - /// - /// Gets the full path to the AppHost server project file. - /// - public string GetProjectFilePath() => Path.Combine(_projectModelPath, ProjectFileName); - - public string GetProjectHash() - { - var hashFilePath = Path.Combine(_projectModelPath, ProjectHashFileName); - - if (File.Exists(hashFilePath)) - { - return File.ReadAllText(hashFilePath); - } - - return string.Empty; - } - - public void SaveProjectHash(string hash) - { - var hashFilePath = Path.Combine(_projectModelPath, ProjectHashFileName); - File.WriteAllText(hashFilePath, hash); - } - - /// - /// Scaffolds the project files. - /// - /// The Aspire SDK version to use. - /// The package references to include. - /// Cancellation token. - /// Optional additional project references to include (e.g., integration projects for SDK generation). - /// A tuple containing the full path to the project file and the channel name used (if any). - public async Task<(string ProjectPath, string? ChannelName)> CreateProjectFilesAsync( - string sdkVersion, - IEnumerable<(string Name, string Version)> packages, - CancellationToken cancellationToken = default, - IEnumerable? additionalProjectReferences = null) + public IAppHostServerProject Create(string appPath) { - // Clean obj folder to ensure fresh NuGet restore (avoids stale cache when channel/SDK changes) - var objPath = Path.Combine(_projectModelPath, "obj"); - if (Directory.Exists(objPath)) - { - try - { - Directory.Delete(objPath, recursive: true); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to delete obj folder at {ObjPath}", objPath); - } - } - - // Create Program.cs that starts the RemoteHost server - // The server reads AtsAssemblies from appsettings.json to load integration assemblies - var programCs = """ - await Aspire.Hosting.RemoteHost.RemoteHostServer.RunAsync(args); - """; - - File.WriteAllText(Path.Combine(_projectModelPath, "Program.cs"), programCs); + // Normalize the path + var normalizedPath = Path.GetFullPath(appPath); + normalizedPath = new Uri(normalizedPath).LocalPath; + normalizedPath = OperatingSystem.IsWindows() ? normalizedPath.ToLowerInvariant() : normalizedPath; - // Create appsettings.json with the list of ATS assemblies - // These are the assemblies that will be scanned for [AspireExport] capabilities - // Include all packages since any package could contribute capabilities via [AspireExport] - // The code generation package for the language is already included in packages - var atsAssemblies = new List { "Aspire.Hosting" }; - foreach (var pkg in packages) - { - if (!atsAssemblies.Contains(pkg.Name, StringComparer.OrdinalIgnoreCase)) - { - atsAssemblies.Add(pkg.Name); - } - } - - // Add additional project references' assembly names - if (additionalProjectReferences is not null) - { - foreach (var projectPath in additionalProjectReferences) - { - var assemblyName = Path.GetFileNameWithoutExtension(projectPath); - if (!atsAssemblies.Contains(assemblyName, StringComparer.OrdinalIgnoreCase)) - { - atsAssemblies.Add(assemblyName); - } - } - } - - var assembliesJson = string.Join(",\n ", atsAssemblies.Select(a => $"\"{a}\"")); - var appSettingsJson = $$""" - { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Aspire.Hosting.Dcp": "Warning" - } - }, - "AtsAssemblies": [ - {{assembliesJson}} - ] - } - """; - - var appSettingsJsonPath = Path.Combine(_projectModelPath, "appsettings.json"); - File.WriteAllText(appSettingsJsonPath, appSettingsJson); - - // Handle nuget.config - copy user's config and merge channel sources - var nugetConfigPath = Path.Combine(_projectModelPath, "nuget.config"); - string? channelName = null; - - // First, copy user's nuget.config if it exists (to preserve private feeds/auth) - var userNugetConfig = FindNuGetConfig(_appPath); - if (userNugetConfig is not null) - { - File.Copy(userNugetConfig, nugetConfigPath, overwrite: true); - } - - // Get the appropriate channel from the packaging service (same logic as aspire new/init) - var channels = await _packagingService.GetChannelsAsync(cancellationToken); - - // Check for channel setting - project-local .aspire/settings.json takes precedence over global config. - // This is important for `aspire update` scenarios where the user switches channels: - // UpdatePackagesAsync saves the new channel to project-local settings, then calls BuildAndGenerateSdkAsync - // which eventually calls this method. We must read from project-local to use the newly selected channel. - var localConfigPath = AspireJsonConfiguration.GetFilePath(_appPath); - var localConfig = AspireJsonConfiguration.Load(_appPath); - var configuredChannelName = localConfig?.Channel; - - _logger.LogDebug("Channel resolution: localConfigPath={LocalConfigPath}, exists={Exists}, channel={Channel}", - localConfigPath, File.Exists(localConfigPath), configuredChannelName ?? "(null)"); - - // Fall back to global config if no project-local channel is set - if (string.IsNullOrEmpty(configuredChannelName)) - { - configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); - _logger.LogDebug("Fell back to global config channel: {Channel}", configuredChannelName ?? "(null)"); - } - - PackageChannel? channel; - if (!string.IsNullOrEmpty(configuredChannelName)) - { - // Use the configured channel if specified - channel = channels.FirstOrDefault(c => string.Equals(c.Name, configuredChannelName, StringComparison.OrdinalIgnoreCase)); - _logger.LogDebug("Looking for channel '{ChannelName}' in {Count} channels, found={Found}", - configuredChannelName, channels.Count(), channel is not null); - } - else - { - // Fall back to first explicit channel (staging/PR) - channel = channels.FirstOrDefault(c => c.Type == PackageChannelType.Explicit); - _logger.LogDebug("No configured channel, using first explicit channel: {Channel}", channel?.Name ?? "(none)"); - } - - // NuGetConfigMerger creates or updates the config with channel sources/mappings - if (channel is not null) - { - await NuGetConfigMerger.CreateOrUpdateAsync( - new DirectoryInfo(_projectModelPath), - channel, - cancellationToken: cancellationToken); - - // Track the channel name to return to caller - channelName = channel.Name; - } - - // Note: We don't create launchSettings.json here. Environment variables - // (ports, OTLP endpoints, etc.) are read from the user's apphost.run.json - // and passed directly to Run() at runtime. + // Generate socket path based on app path hash (deterministic for same project) + var pathHash = SHA256.HashData(Encoding.UTF8.GetBytes(normalizedPath)); + var socketName = Convert.ToHexString(pathHash)[..12].ToLowerInvariant() + ".sock"; - // Create the project file based on mode (local dev vs production) - XDocument doc; - if (LocalAspirePath is not null) + string socketPath; + if (OperatingSystem.IsWindows()) { - doc = CreateDevModeProjectFile(packages); + // Windows uses named pipes + socketPath = socketName; } else { - doc = CreateProductionProjectFile(sdkVersion, packages); + // Unix uses domain sockets + var socketDir = Path.Combine(Path.GetTempPath(), ".aspire", "sockets"); + Directory.CreateDirectory(socketDir); + socketPath = Path.Combine(socketDir, socketName); } - // Add additional project references (e.g., integration projects for SDK generation) - if (additionalProjectReferences is not null) + // Priority 1: Check for dev mode (ASPIRE_REPO_ROOT or running from Aspire source repo) + var repoRoot = DetectAspireRepoRoot(); + if (repoRoot is not null) { - var additionalProjectRefs = additionalProjectReferences - .Select(path => new XElement("ProjectReference", - new XAttribute("Include", path), - new XElement("IsAspireProjectResource", "false"))) - .ToList(); - - if (additionalProjectRefs.Count > 0) - { - doc.Root!.Add(new XElement("ItemGroup", additionalProjectRefs)); - } + return new DotNetBasedAppHostServerProject( + appPath, + socketPath, + repoRoot, + dotNetCliRunner, + packagingService, + configurationService, + loggerFactory.CreateLogger()); } - // Add appsettings.json to be copied to output directory - // This is required for the RemoteHostServer to find AtsAssemblies configuration - doc.Root!.Add(new XElement("ItemGroup", - new XElement("None", - new XAttribute("Include", "appsettings.json"), - new XAttribute("CopyToOutputDirectory", "PreserveNewest")))); - - // For dev mode, create Directory.Packages.props to enable central package management - // This ensures transitive dependencies use versions from the repo's Directory.Packages.props - if (LocalAspirePath is not null) + // Priority 2: Check if we have a bundle layout with a pre-built AppHost server + var layout = layoutDiscovery.DiscoverLayout(); + if (layout is not null && layout.GetAppHostServerPath() is string serverPath && File.Exists(serverPath)) { - var repoRoot = Path.GetFullPath(LocalAspirePath); - var repoDirectoryPackagesProps = Path.Combine(repoRoot, "Directory.Packages.props"); - var directoryPackagesProps = $""" - - - true - true - - - - """; - var directoryPackagesPropsPath = Path.Combine(_projectModelPath, "Directory.Packages.props"); - File.WriteAllText(directoryPackagesPropsPath, directoryPackagesProps); + return new PrebuiltAppHostServer( + appPath, + socketPath, + layout, + bundleNuGetService, + packagingService, + configurationService, + loggerFactory.CreateLogger()); } - var projectFileName = Path.Combine(_projectModelPath, ProjectFileName); - doc.Save(projectFileName); - - return (projectFileName, channelName); + throw new InvalidOperationException( + "No Aspire AppHost server is available. Either set the ASPIRE_REPO_ROOT environment variable " + + "to the root of the Aspire repository for development, or ensure the Aspire CLI is installed " + + "with a valid bundle layout."); } /// - /// Creates a project file for local development using project references. - /// Used when ASPIRE_REPO_ROOT is set. + /// Detects the Aspire repository root for dev mode. + /// Checks ASPIRE_REPO_ROOT env var first, then walks up from the CLI executable + /// looking for a git repo containing Aspire.slnx. /// - private XDocument CreateDevModeProjectFile(IEnumerable<(string Name, string Version)> packages) + private static string? DetectAspireRepoRoot() { - var repoRoot = Path.GetFullPath(LocalAspirePath!) + Path.DirectorySeparatorChar; - - // Determine OS/architecture for DCP package name (matches Directory.Build.props logic) - var (buildOs, buildArch) = GetBuildPlatform(); - var dcpPackageName = $"microsoft.developercontrolplane.{buildOs}-{buildArch}"; - var dcpVersion = GetDcpVersionFromRepo(repoRoot, buildOs, buildArch); - - var template = $""" - - - exe - {TargetFramework} - {AssemblyName} - {BuildFolder} - {_userSecretsId} - true - false - false - enable - enable - 0 - false - false - false - $(NoWarn);1701;1702;1591;CS8019;CS1591;CS1573;CS0168;CS0219;CS8618;CS8625;CS1998;CS1999 - - {repoRoot} - true - true - 42.42.42 - - $([MSBuild]::EnsureTrailingSlash('$(NuGetPackageRoot)')){dcpPackageName}/{dcpVersion}/tools/ - {repoRoot}artifacts/bin/Aspire.Dashboard/Debug/net8.0/ - - - - - - - """; - - var doc = XDocument.Parse(template); - - // Add project references for Aspire.Hosting.* packages, NuGet for others - var projectRefGroup = new XElement("ItemGroup"); - var addedProjects = new HashSet(StringComparer.OrdinalIgnoreCase); - var otherPackages = new List<(string Name, string Version)>(); - - foreach (var pkg in packages) + // Check explicit environment variable + var envRoot = Environment.GetEnvironmentVariable("ASPIRE_REPO_ROOT"); + if (!string.IsNullOrEmpty(envRoot) && Directory.Exists(envRoot)) { - if (!pkg.Name.StartsWith("Aspire.Hosting", StringComparison.OrdinalIgnoreCase)) - { - otherPackages.Add(pkg); - continue; - } - - if (addedProjects.Contains(pkg.Name)) - { - continue; - } - - // Look for the project in src/ - var projectPath = Path.Combine(repoRoot, "src", pkg.Name, $"{pkg.Name}.csproj"); - if (File.Exists(projectPath)) - { - addedProjects.Add(pkg.Name); - projectRefGroup.Add(new XElement("ProjectReference", - new XAttribute("Include", projectPath), - new XElement("IsAspireProjectResource", "false"))); - } - else - { - _logger.LogWarning("Could not find local project for {PackageName}, falling back to NuGet", pkg.Name); - otherPackages.Add(pkg); - } + return envRoot; } - if (projectRefGroup.HasElements) + // Auto-detect: walk up from the CLI executable looking for .git + Aspire.slnx + var cliPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(cliPath)) { - doc.Root!.Add(projectRefGroup); - } - - if (otherPackages.Count > 0) - { - doc.Root!.Add(new XElement("ItemGroup", - otherPackages.Select(p => new XElement("PackageReference", - new XAttribute("Include", p.Name), - new XAttribute("Version", p.Version))))); - } - - // Add imports for in-repo AppHost building - var appHostInTargets = Path.Combine(repoRoot, "src", "Aspire.Hosting.AppHost", "build", "Aspire.Hosting.AppHost.in.targets"); - var sdkInTargets = Path.Combine(repoRoot, "src", "Aspire.AppHost.Sdk", "SDK", "Sdk.in.targets"); - - if (File.Exists(appHostInTargets)) - { - doc.Root!.Add(new XElement("Import", new XAttribute("Project", appHostInTargets))); - } - if (File.Exists(sdkInTargets)) - { - doc.Root!.Add(new XElement("Import", new XAttribute("Project", sdkInTargets))); - } - - // Add Dashboard and RemoteHost project references - var dashboardProject = Path.Combine(repoRoot, "src", "Aspire.Dashboard", "Aspire.Dashboard.csproj"); - if (File.Exists(dashboardProject)) - { - doc.Root!.Add(new XElement("ItemGroup", - new XElement("ProjectReference", new XAttribute("Include", dashboardProject)))); - } - - var remoteHostProject = Path.Combine(repoRoot, "src", "Aspire.Hosting.RemoteHost", "Aspire.Hosting.RemoteHost.csproj"); - if (File.Exists(remoteHostProject)) - { - doc.Root!.Add(new XElement("ItemGroup", - new XElement("ProjectReference", new XAttribute("Include", remoteHostProject)))); - } - - // Disable Aspire SDK code generation (must come after imports) - doc.Root!.Add(new XElement("Target", new XAttribute("Name", "_CSharpWriteHostProjectMetadataSources"))); - doc.Root!.Add(new XElement("Target", new XAttribute("Name", "_CSharpWriteProjectMetadataSources"))); - - return doc; - } - - /// - /// Creates a project file for production using NuGet packages. - /// - private XDocument CreateProductionProjectFile(string sdkVersion, IEnumerable<(string Name, string Version)> packages) - { - var template = $""" - - - exe - {TargetFramework} - {AssemblyName} - {BuildFolder} - {_userSecretsId} - true - - - - - - """; - - var doc = XDocument.Parse(template); - - // Add package references - SDK provides Aspire.Hosting.AppHost (which brings Aspire.Hosting) - // We need to add: RemoteHost, code gen package, and any integration packages - var explicitPackages = packages - .Where(p => !p.Name.Equals("Aspire.Hosting", StringComparison.OrdinalIgnoreCase) && - !p.Name.Equals("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase)) - .ToList(); - - // Always add RemoteHost - required for the RPC server - explicitPackages.Add(("Aspire.Hosting.RemoteHost", sdkVersion)); - - var packageRefs = explicitPackages.Select(p => new XElement("PackageReference", - new XAttribute("Include", p.Name), - new XAttribute("Version", p.Version))); - doc.Root!.Add(new XElement("ItemGroup", packageRefs)); - - return doc; - } - - /// - /// Restores and builds the project dependencies. - /// - /// A tuple containing the success status and an OutputCollector with build output. - public async Task<(bool Success, OutputCollector Output)> BuildAsync(CancellationToken cancellationToken = default) - { - var outputCollector = new OutputCollector(); - var projectFile = new FileInfo(Path.Combine(_projectModelPath, ProjectFileName)); - - var options = new DotNetCliRunnerInvocationOptions - { - StandardOutputCallback = outputCollector.AppendOutput, - StandardErrorCallback = outputCollector.AppendError - }; - - var exitCode = await _dotNetCliRunner.BuildAsync(projectFile, options, cancellationToken); - - return (exitCode == 0, outputCollector); - } - - /// - /// Runs the AppHost server. - /// - /// The Unix domain socket path for JSON-RPC communication. - /// The PID of the host process for orphan detection. - /// Optional environment variables from apphost.run.json or launchSettings.json. - /// Optional additional command-line arguments (e.g., for publish/deploy). - /// Whether to enable debug logging in the AppHost server. - /// A tuple containing the started process and an OutputCollector for capturing output. - public (Process Process, OutputCollector OutputCollector) Run(string socketPath, int hostPid, IReadOnlyDictionary? launchSettingsEnvVars = null, string[]? additionalArgs = null, bool debug = false) - { - var assemblyPath = Path.Combine(BuildPath, ProjectDllName); - var dotnetExe = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; - - var startInfo = new ProcessStartInfo(dotnetExe) - { - WorkingDirectory = _projectModelPath, - WindowStyle = ProcessWindowStyle.Minimized, - UseShellExecute = false, - CreateNoWindow = true - }; - startInfo.ArgumentList.Add("exec"); - startInfo.ArgumentList.Add(assemblyPath); - - // Add the separator and any additional arguments (for publish/deploy) - if (additionalArgs is { Length: > 0 }) - { - startInfo.ArgumentList.Add("--"); - foreach (var arg in additionalArgs) - { - startInfo.ArgumentList.Add(arg); - } - } - - // Pass environment variables for socket path and parent PID - startInfo.Environment["REMOTE_APP_HOST_SOCKET_PATH"] = socketPath; - startInfo.Environment["REMOTE_APP_HOST_PID"] = hostPid.ToString(System.Globalization.CultureInfo.InvariantCulture); - // Also set ASPIRE_CLI_PID so the auxiliary backchannel can report it for stop command - startInfo.Environment[KnownConfigNames.CliProcessId] = hostPid.ToString(System.Globalization.CultureInfo.InvariantCulture); - - // Apply environment variables from apphost.run.json / launchSettings.json - if (launchSettingsEnvVars != null) - { - foreach (var (key, value) in launchSettingsEnvVars) - { - startInfo.Environment[key] = value; - } - } - - // Enable debug logging if requested - if (debug) - { - startInfo.Environment["Logging__LogLevel__Default"] = "Debug"; - _logger.LogDebug("Enabling debug logging for AppHostServer"); - } - - startInfo.RedirectStandardOutput = true; - startInfo.RedirectStandardError = true; - - var process = Process.Start(startInfo)!; - - // Collect output for error diagnostics and log at debug level - var outputCollector = new OutputCollector(); - process.OutputDataReceived += (sender, e) => - { - if (e.Data is not null) - { - _logger.LogDebug("AppHostServer({ProcessId}) stdout: {Line}", process.Id, e.Data); - outputCollector.AppendOutput(e.Data); - } - }; - process.ErrorDataReceived += (sender, e) => - { - if (e.Data is not null) - { - _logger.LogDebug("AppHostServer({ProcessId}) stderr: {Line}", process.Id, e.Data); - outputCollector.AppendError(e.Data); - } - }; - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - return (process, outputCollector); - } - - /// - /// Gets the socket path for the AppHost server based on the app path. - /// On Windows, returns just the pipe name (named pipes don't use file paths). - /// On Unix/macOS, returns the full socket file path. - /// - public string GetSocketPath() - { - var pathHash = SHA256.HashData(Encoding.UTF8.GetBytes(_appPath)); - var socketName = Convert.ToHexString(pathHash)[..12].ToLowerInvariant() + ".sock"; - - // On Windows, named pipes use just a name, not a file path. - // The .NET NamedPipeServerStream and clients will automatically - // use the \\.\pipe\ prefix. - if (OperatingSystem.IsWindows()) - { - return socketName; - } - - // On Unix/macOS, use Unix domain sockets with a file path - var socketDir = Path.Combine(Path.GetTempPath(), FolderPrefix, "sockets"); - Directory.CreateDirectory(socketDir); - - return Path.Combine(socketDir, socketName); - } - - /// - /// Gets a project-level NuGet config path using dotnet nuget config paths command. - /// Only returns configs that are within the project directory tree, not global user configs. - /// - private static string? FindNuGetConfig(string workingDirectory) - { - try - { - var startInfo = new ProcessStartInfo("dotnet") - { - Arguments = "nuget config paths", - WorkingDirectory = workingDirectory, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = Process.Start(startInfo); - if (process is null) - { - return null; - } - - var output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(); - - if (process.ExitCode != 0) - { - return null; - } - - // Find a config that's in the project directory or a parent directory (not global user config). - // Global configs (e.g., ~/.nuget/NuGet/NuGet.Config) will be found by dotnet anyway. - var configPaths = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); - var workingDirFullPath = Path.GetFullPath(workingDirectory); - - // Get user profile path to exclude global NuGet configs - var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var globalNuGetPath = Path.Combine(userProfile, ".nuget"); - - foreach (var configPath in configPaths) - { - if (File.Exists(configPath)) - { - var configFullPath = Path.GetFullPath(configPath); - var configDir = Path.GetDirectoryName(configFullPath); - - // Skip global NuGet configs (they're in ~/.nuget) - if (configFullPath.StartsWith(globalNuGetPath, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - // Check if the working directory is within or below the config's directory - // (i.e., the config is in a parent directory of the project) - if (configDir is not null && workingDirFullPath.StartsWith(configDir, StringComparison.OrdinalIgnoreCase)) - { - return configPath; - } - } - } - return null; } - catch - { - return null; - } - } - /// - /// Gets the OS and architecture identifiers for the DCP package name. - /// - private static (string Os, string Arch) GetBuildPlatform() - { - // OS mapping (matches MSBuild logic in Directory.Build.props) - var os = OperatingSystem.IsLinux() ? "linux" - : OperatingSystem.IsMacOS() ? "darwin" - : "windows"; - - // Architecture mapping - var arch = RuntimeInformation.OSArchitecture switch - { - Architecture.X86 => "386", - Architecture.Arm64 => "arm64", - _ => "amd64" - }; - - return (os, arch); - } - - /// - /// Reads the DCP version from eng/Versions.props in the repo. - /// - private static string GetDcpVersionFromRepo(string repoRoot, string buildOs, string buildArch) - { - const string fallbackVersion = "0.21.1"; - - try + var dir = Path.GetDirectoryName(cliPath); + while (dir is not null) { - var versionsPropsPath = Path.Combine(repoRoot, "eng", "Versions.props"); - if (!File.Exists(versionsPropsPath)) + if (Directory.Exists(Path.Combine(dir, ".git")) && + File.Exists(Path.Combine(dir, "Aspire.slnx"))) { - return fallbackVersion; + return dir; } - var doc = XDocument.Load(versionsPropsPath); - - // Property name format: MicrosoftDeveloperControlPlane{os}{arch}Version - // e.g., MicrosoftDeveloperControlPlanedarwinarm64Version - var propertyName = $"MicrosoftDeveloperControlPlane{buildOs}{buildArch}Version"; - - var version = doc.Descendants(propertyName).FirstOrDefault()?.Value; - return version ?? fallbackVersion; - } - catch - { - return fallbackVersion; + dir = Path.GetDirectoryName(dir); } + + return null; } } diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index 434b0a09ffc..35a9fb06ee6 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -106,26 +106,21 @@ public async Task CreateAsync( CancellationToken cancellationToken) { var appHostServerProject = _projectFactory.Create(appHostPath); - var socketPath = appHostServerProject.GetSocketPath(); - // Create project files and get channel info - var (_, channelName) = await appHostServerProject.CreateProjectFilesAsync(sdkVersion, packages, cancellationToken); - - // Build the project - var (buildSuccess, buildOutput) = await appHostServerProject.BuildAsync(cancellationToken); - if (!buildSuccess) + // Prepare the server (create files + build for dev mode, restore packages for prebuilt mode) + var prepareResult = await appHostServerProject.PrepareAsync(sdkVersion, packages, cancellationToken); + if (!prepareResult.Success) { return new AppHostServerSessionResult( Success: false, Session: null, - BuildOutput: buildOutput, - ChannelName: channelName); + BuildOutput: prepareResult.Output, + ChannelName: prepareResult.ChannelName); } // Start the server process var currentPid = Environment.ProcessId; - var (serverProcess, serverOutput) = appHostServerProject.Run( - socketPath, + var (socketPath, serverProcess, serverOutput) = appHostServerProject.Run( currentPid, launchSettingsEnvVars, debug: debug); @@ -140,7 +135,7 @@ public async Task CreateAsync( return new AppHostServerSessionResult( Success: true, Session: session, - BuildOutput: buildOutput, - ChannelName: channelName); + BuildOutput: prepareResult.Output, + ChannelName: prepareResult.ChannelName); } } diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 0877339bdec..c2f96d8a5cd 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -5,6 +5,7 @@ using Aspire.Cli.Certificates; using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; +using Aspire.Cli.Exceptions; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; @@ -28,6 +29,7 @@ internal sealed class DotNetAppHostProject : IAppHostProject private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private readonly IProjectUpdater _projectUpdater; + private readonly IDotNetSdkInstaller _sdkInstaller; private readonly RunningInstanceManager _runningInstanceManager; private readonly Diagnostics.FileLoggerProvider _fileLoggerProvider; @@ -41,6 +43,7 @@ public DotNetAppHostProject( AspireCliTelemetry telemetry, IFeatures features, IProjectUpdater projectUpdater, + IDotNetSdkInstaller sdkInstaller, ILogger logger, Diagnostics.FileLoggerProvider fileLoggerProvider, TimeProvider? timeProvider = null) @@ -51,6 +54,7 @@ public DotNetAppHostProject( _telemetry = telemetry; _features = features; _projectUpdater = projectUpdater; + _sdkInstaller = sdkInstaller; _logger = logger; _fileLoggerProvider = fileLoggerProvider; _timeProvider = timeProvider ?? TimeProvider.System; @@ -180,6 +184,14 @@ private static bool IsPossiblyUnbuildableAppHost(FileInfo projectFile) /// public async Task RunAsync(AppHostProjectContext context, CancellationToken cancellationToken) { + // .NET projects require the SDK to be installed + if (!await SdkInstallHelper.EnsureSdkInstalledAsync(_sdkInstaller, _interactionService, _features, _telemetry, cancellationToken: cancellationToken)) + { + // Signal build failure so RunCommand doesn't wait forever + context.BuildCompletionSource?.TrySetResult(false); + return ExitCodeConstants.SdkNotInstalled; + } + var effectiveAppHostFile = context.AppHostFile; var isExtensionHost = ExtensionHelper.IsExtensionHost(_interactionService, out _, out var extensionBackchannel); @@ -208,7 +220,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken try { - var certResult = await _certificateService.EnsureCertificatesTrustedAsync(_runner, cancellationToken); + var certResult = await _certificateService.EnsureCertificatesTrustedAsync(cancellationToken); // Apply any environment variables returned by the certificate service (e.g., SSL_CERT_DIR on Linux) foreach (var kvp in certResult.EnvironmentVariables) @@ -339,6 +351,14 @@ private static void ConfigureSingleFileEnvironment(FileInfo appHostFile, Diction /// public async Task PublishAsync(PublishContext context, CancellationToken cancellationToken) { + // .NET projects require the SDK to be installed + if (!await SdkInstallHelper.EnsureSdkInstalledAsync(_sdkInstaller, _interactionService, _features, _telemetry, cancellationToken: cancellationToken)) + { + // Throw an exception that will be caught by the command and result in SdkNotInstalled exit code + // This is cleaner than trying to signal through the backchannel pattern + throw new DotNetSdkNotInstalledException(); + } + var effectiveAppHostFile = context.AppHostFile; var isSingleFileAppHost = effectiveAppHostFile.Extension != ".csproj"; var env = new Dictionary(context.EnvironmentVariables); diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs new file mode 100644 index 00000000000..5ac830a3d53 --- /dev/null +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -0,0 +1,639 @@ +// 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 System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using Aspire.Cli.Configuration; +using Aspire.Cli.DotNet; +using Aspire.Cli.Packaging; +using Aspire.Cli.Utils; +using Aspire.Hosting; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Projects; + +/// +/// AppHost server project for local Aspire development that uses the .NET SDK to build. +/// Uses project references to the local Aspire repository (ASPIRE_REPO_ROOT). +/// +internal sealed class DotNetBasedAppHostServerProject : IAppHostServerProject +{ + private const string ProjectHashFileName = ".projecthash"; + private const string FolderPrefix = ".aspire"; + private const string AppsFolder = "hosts"; + public const string ProjectFileName = "AppHostServer.csproj"; + private const string ProjectDllName = "AppHostServer.dll"; + private const string TargetFramework = "net10.0"; + public const string BuildFolder = "build"; + private const string AssemblyName = "AppHostServer"; + + /// + /// Gets the default Aspire SDK version based on the CLI version. + /// + public static string DefaultSdkVersion => GetEffectiveVersion(); + + private static string GetEffectiveVersion() + { + var version = VersionHelper.GetDefaultTemplateVersion(); + + // Strip the commit SHA suffix (e.g., "9.2.0+abc123" -> "9.2.0") + var plusIndex = version.IndexOf('+'); + if (plusIndex > 0) + { + version = version[..plusIndex]; + } + + // Dev versions (e.g., "13.2.0-dev") don't exist on NuGet, fall back to latest stable + if (version.EndsWith("-dev", StringComparison.OrdinalIgnoreCase)) + { + return "13.1.0"; + } + return version; + } + + private readonly string _projectModelPath; + private readonly string _appPath; + private readonly string _socketPath; + private readonly string _userSecretsId; + private readonly string _repoRoot; + private readonly IDotNetCliRunner _dotNetCliRunner; + private readonly IPackagingService _packagingService; + private readonly IConfigurationService _configurationService; + private readonly ILogger _logger; + + public DotNetBasedAppHostServerProject( + string appPath, + string socketPath, + string repoRoot, + IDotNetCliRunner dotNetCliRunner, + IPackagingService packagingService, + IConfigurationService configurationService, + ILogger logger, + string? projectModelPath = null) + { + _appPath = Path.GetFullPath(appPath); + _appPath = new Uri(_appPath).LocalPath; + _appPath = OperatingSystem.IsWindows() ? _appPath.ToLowerInvariant() : _appPath; + _socketPath = socketPath; + _repoRoot = Path.GetFullPath(repoRoot) + Path.DirectorySeparatorChar; + _dotNetCliRunner = dotNetCliRunner; + _packagingService = packagingService; + _configurationService = configurationService; + _logger = logger; + + var pathHash = SHA256.HashData(Encoding.UTF8.GetBytes(_appPath)); + + if (projectModelPath is not null) + { + _projectModelPath = projectModelPath; + } + else + { + var pathDir = Convert.ToHexString(pathHash)[..12].ToLowerInvariant(); + _projectModelPath = Path.Combine(Path.GetTempPath(), FolderPrefix, AppsFolder, pathDir); + } + + // Create a stable UserSecretsId based on the app path hash + _userSecretsId = new Guid(pathHash[..16]).ToString(); + + Directory.CreateDirectory(_projectModelPath); + } + + /// + public string AppPath => _appPath; + + public string ProjectModelPath => _projectModelPath; + public string UserSecretsId => _userSecretsId; + public string BuildPath => Path.Combine(_projectModelPath, BuildFolder); + + /// + /// Gets the full path to the AppHost server project file. + /// + public string GetProjectFilePath() => Path.Combine(_projectModelPath, ProjectFileName); + + public string GetProjectHash() + { + var hashFilePath = Path.Combine(_projectModelPath, ProjectHashFileName); + + if (File.Exists(hashFilePath)) + { + return File.ReadAllText(hashFilePath); + } + + return string.Empty; + } + + public void SaveProjectHash(string hash) + { + var hashFilePath = Path.Combine(_projectModelPath, ProjectHashFileName); + File.WriteAllText(hashFilePath, hash); + } + + /// + /// Creates the project .csproj content using project references to the local Aspire repository. + /// + private XDocument CreateProjectFile(IEnumerable<(string Name, string Version)> packages) + { + // Determine OS/architecture for DCP package name + var (buildOs, buildArch) = GetBuildPlatform(); + var dcpPackageName = $"microsoft.developercontrolplane.{buildOs}-{buildArch}"; + var dcpVersion = GetDcpVersionFromRepo(_repoRoot, buildOs, buildArch); + + var template = $""" + + + exe + {TargetFramework} + {AssemblyName} + {BuildFolder} + {_userSecretsId} + true + false + false + enable + enable + 0 + false + false + false + $(NoWarn);1701;1702;1591;CS8019;CS1591;CS1573;CS0168;CS0219;CS8618;CS8625;CS1998;CS1999 + + {_repoRoot} + true + true + 42.42.42 + + $(NuGetPackageRoot){dcpPackageName}/{dcpVersion}/tools/ + {_repoRoot}artifacts/bin/Aspire.Dashboard/Debug/net8.0/ + + + + + + + """; + + var doc = XDocument.Parse(template); + + // Add project references for Aspire.Hosting.* packages, NuGet for others + var projectRefGroup = new XElement("ItemGroup"); + var addedProjects = new HashSet(StringComparer.OrdinalIgnoreCase); + var otherPackages = new List<(string Name, string Version)>(); + + foreach (var (name, version) in packages) + { + if (name.StartsWith("Aspire.Hosting", StringComparison.OrdinalIgnoreCase)) + { + var projectPath = Path.Combine(_repoRoot, "src", name, $"{name}.csproj"); + if (File.Exists(projectPath) && addedProjects.Add(name)) + { + projectRefGroup.Add(new XElement("ProjectReference", + new XAttribute("Include", projectPath), + new XElement("IsAspireProjectResource", "false"))); + } + } + else + { + otherPackages.Add((name, version)); + } + } + + // Always add Aspire.Hosting project reference + var hostingPath = Path.Combine(_repoRoot, "src", "Aspire.Hosting", "Aspire.Hosting.csproj"); + if (File.Exists(hostingPath) && addedProjects.Add("Aspire.Hosting")) + { + projectRefGroup.Add(new XElement("ProjectReference", + new XAttribute("Include", hostingPath), + new XElement("IsAspireProjectResource", "false"))); + } + + if (projectRefGroup.HasElements) + { + doc.Root!.Add(projectRefGroup); + } + + if (otherPackages.Count > 0) + { + doc.Root!.Add(new XElement("ItemGroup", + otherPackages.Select(p => new XElement("PackageReference", + new XAttribute("Include", p.Name), + new XAttribute("Version", p.Version))))); + } + + // Add imports for in-repo AppHost building + var appHostInTargets = Path.Combine(_repoRoot, "src", "Aspire.Hosting.AppHost", "build", "Aspire.Hosting.AppHost.in.targets"); + var sdkInTargets = Path.Combine(_repoRoot, "src", "Aspire.AppHost.Sdk", "SDK", "Sdk.in.targets"); + + if (File.Exists(appHostInTargets)) + { + doc.Root!.Add(new XElement("Import", new XAttribute("Project", appHostInTargets))); + } + if (File.Exists(sdkInTargets)) + { + doc.Root!.Add(new XElement("Import", new XAttribute("Project", sdkInTargets))); + } + + // Add Dashboard and RemoteHost project references + var dashboardProject = Path.Combine(_repoRoot, "src", "Aspire.Dashboard", "Aspire.Dashboard.csproj"); + if (File.Exists(dashboardProject)) + { + doc.Root!.Add(new XElement("ItemGroup", + new XElement("ProjectReference", new XAttribute("Include", dashboardProject)))); + } + + var remoteHostProject = Path.Combine(_repoRoot, "src", "Aspire.Hosting.RemoteHost", "Aspire.Hosting.RemoteHost.csproj"); + if (File.Exists(remoteHostProject)) + { + doc.Root!.Add(new XElement("ItemGroup", + new XElement("ProjectReference", new XAttribute("Include", remoteHostProject)))); + } + + // Disable Aspire SDK code generation + doc.Root!.Add(new XElement("Target", new XAttribute("Name", "_CSharpWriteHostProjectMetadataSources"))); + doc.Root!.Add(new XElement("Target", new XAttribute("Name", "_CSharpWriteProjectMetadataSources"))); + + return doc; + } + + /// + /// Scaffolds the project files. + /// + public async Task<(string ProjectPath, string? ChannelName)> CreateProjectFilesAsync( + IEnumerable<(string Name, string Version)> packages, + CancellationToken cancellationToken = default, + IEnumerable? additionalProjectReferences = null) + { + // Clean obj folder to ensure fresh NuGet restore + var objPath = Path.Combine(_projectModelPath, "obj"); + if (Directory.Exists(objPath)) + { + try + { + Directory.Delete(objPath, recursive: true); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to delete obj folder at {ObjPath}", objPath); + } + } + + // Create Program.cs + var programCs = """ + await Aspire.Hosting.RemoteHost.RemoteHostServer.RunAsync(args); + """; + File.WriteAllText(Path.Combine(_projectModelPath, "Program.cs"), programCs); + + // Create appsettings.json with ATS assemblies + var atsAssemblies = new List { "Aspire.Hosting" }; + foreach (var pkg in packages) + { + if (!atsAssemblies.Contains(pkg.Name, StringComparer.OrdinalIgnoreCase)) + { + atsAssemblies.Add(pkg.Name); + } + } + + if (additionalProjectReferences is not null) + { + foreach (var projectPath in additionalProjectReferences) + { + var assemblyName = Path.GetFileNameWithoutExtension(projectPath); + if (!atsAssemblies.Contains(assemblyName, StringComparer.OrdinalIgnoreCase)) + { + atsAssemblies.Add(assemblyName); + } + } + } + + var assembliesJson = string.Join(",\n ", atsAssemblies.Select(a => $"\"{a}\"")); + var appSettingsJson = $$""" + { + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + }, + "AtsAssemblies": [ + {{assembliesJson}} + ] + } + """; + File.WriteAllText(Path.Combine(_projectModelPath, "appsettings.json"), appSettingsJson); + + // Handle NuGet config and channel resolution + string? channelName = null; + var nugetConfigPath = Path.Combine(_projectModelPath, "nuget.config"); + + var userNugetConfig = FindNuGetConfig(_appPath); + if (userNugetConfig is not null) + { + File.Copy(userNugetConfig, nugetConfigPath, overwrite: true); + } + + var channels = await _packagingService.GetChannelsAsync(cancellationToken); + var localConfig = AspireJsonConfiguration.Load(_appPath); + var configuredChannelName = localConfig?.Channel; + + if (string.IsNullOrEmpty(configuredChannelName)) + { + configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); + } + + PackageChannel? channel; + if (!string.IsNullOrEmpty(configuredChannelName)) + { + channel = channels.FirstOrDefault(c => string.Equals(c.Name, configuredChannelName, StringComparison.OrdinalIgnoreCase)); + } + else + { + channel = channels.FirstOrDefault(c => c.Type == PackageChannelType.Explicit); + } + + if (channel is not null) + { + await NuGetConfigMerger.CreateOrUpdateAsync( + new DirectoryInfo(_projectModelPath), + channel, + cancellationToken: cancellationToken); + channelName = channel.Name; + } + + // Create the project file + var doc = CreateProjectFile(packages); + + // Add additional project references + if (additionalProjectReferences is not null) + { + var additionalProjectRefs = additionalProjectReferences + .Select(path => new XElement("ProjectReference", + new XAttribute("Include", path), + new XElement("IsAspireProjectResource", "false"))) + .ToList(); + + if (additionalProjectRefs.Count > 0) + { + doc.Root!.Add(new XElement("ItemGroup", additionalProjectRefs)); + } + } + + // Add appsettings.json to output + doc.Root!.Add(new XElement("ItemGroup", + new XElement("None", + new XAttribute("Include", "appsettings.json"), + new XAttribute("CopyToOutputDirectory", "PreserveNewest")))); + + // Create Directory.Packages.props to enable central package management + // This ensures transitive dependencies use versions from the repo's Directory.Packages.props + var repoDirectoryPackagesProps = Path.Combine(_repoRoot, "Directory.Packages.props"); + var directoryPackagesProps = $""" + + + true + true + + + + """; + File.WriteAllText(Path.Combine(_projectModelPath, "Directory.Packages.props"), directoryPackagesProps); + + var projectFileName = Path.Combine(_projectModelPath, ProjectFileName); + + // Log the full project XML for debugging + _logger.LogDebug("Generated AppHostServer project file:\n{ProjectXml}", doc.ToString()); + + doc.Save(projectFileName); + + return (projectFileName, channelName); + } + + /// + /// Restores and builds the project. + /// + public async Task<(bool Success, OutputCollector Output)> BuildAsync(CancellationToken cancellationToken = default) + { + var outputCollector = new OutputCollector(); + var projectFile = new FileInfo(Path.Combine(_projectModelPath, ProjectFileName)); + + var options = new DotNetCliRunnerInvocationOptions + { + StandardOutputCallback = outputCollector.AppendOutput, + StandardErrorCallback = outputCollector.AppendError + }; + + var exitCode = await _dotNetCliRunner.BuildAsync(projectFile, options, cancellationToken); + + return (exitCode == 0, outputCollector); + } + + /// + public async Task PrepareAsync( + string sdkVersion, + IEnumerable<(string Name, string Version)> packages, + CancellationToken cancellationToken = default) + { + var (_, channelName) = await CreateProjectFilesAsync(packages, cancellationToken); + var (buildSuccess, buildOutput) = await BuildAsync(cancellationToken); + + if (!buildSuccess) + { + return new AppHostServerPrepareResult( + Success: false, + Output: buildOutput, + ChannelName: channelName, + NeedsCodeGeneration: false); + } + + return new AppHostServerPrepareResult( + Success: true, + Output: buildOutput, + ChannelName: channelName, + NeedsCodeGeneration: true); + } + + /// + public string GetInstanceIdentifier() => GetProjectFilePath(); + + /// + public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + int hostPid, + IReadOnlyDictionary? environmentVariables = null, + string[]? additionalArgs = null, + bool debug = false) + { + var assemblyPath = Path.Combine(BuildPath, ProjectDllName); + var dotnetExe = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + + var startInfo = new ProcessStartInfo(dotnetExe) + { + WorkingDirectory = _projectModelPath, + WindowStyle = ProcessWindowStyle.Minimized, + UseShellExecute = false, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add("exec"); + startInfo.ArgumentList.Add(assemblyPath); + + if (additionalArgs is { Length: > 0 }) + { + startInfo.ArgumentList.Add("--"); + foreach (var arg in additionalArgs) + { + startInfo.ArgumentList.Add(arg); + } + } + + startInfo.Environment["REMOTE_APP_HOST_SOCKET_PATH"] = _socketPath; + startInfo.Environment["REMOTE_APP_HOST_PID"] = hostPid.ToString(System.Globalization.CultureInfo.InvariantCulture); + startInfo.Environment[KnownConfigNames.CliProcessId] = hostPid.ToString(System.Globalization.CultureInfo.InvariantCulture); + + // Dev mode uses debug builds which require Development environment + // for the dashboard to resolve static web assets correctly + startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "Development"; + + if (environmentVariables is not null) + { + foreach (var (key, value) in environmentVariables) + { + startInfo.Environment[key] = value; + } + } + + if (debug) + { + startInfo.Environment["Logging__LogLevel__Default"] = "Debug"; + _logger.LogDebug("Enabling debug logging for AppHostServer"); + } + + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + + var process = Process.Start(startInfo)!; + + var outputCollector = new OutputCollector(); + process.OutputDataReceived += (sender, e) => + { + if (e.Data is not null) + { + _logger.LogDebug("AppHostServer({ProcessId}) stdout: {Line}", process.Id, e.Data); + outputCollector.AppendOutput(e.Data); + } + }; + process.ErrorDataReceived += (sender, e) => + { + if (e.Data is not null) + { + _logger.LogDebug("AppHostServer({ProcessId}) stderr: {Line}", process.Id, e.Data); + outputCollector.AppendError(e.Data); + } + }; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + return (_socketPath, process, outputCollector); + } + + private static string? FindNuGetConfig(string workingDirectory) + { + try + { + var startInfo = new ProcessStartInfo("dotnet") + { + Arguments = "nuget config paths", + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process is null) + { + return null; + } + + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + return null; + } + + var configPaths = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + var workingDirFullPath = Path.GetFullPath(workingDirectory); + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var globalNuGetPath = Path.Combine(userProfile, ".nuget"); + + foreach (var configPath in configPaths) + { + if (File.Exists(configPath)) + { + var configFullPath = Path.GetFullPath(configPath); + var configDir = Path.GetDirectoryName(configFullPath); + + if (configDir is not null && + !configDir.StartsWith(globalNuGetPath, StringComparison.OrdinalIgnoreCase) && + (workingDirFullPath.StartsWith(configDir, StringComparison.OrdinalIgnoreCase) || + configDir.StartsWith(workingDirFullPath, StringComparison.OrdinalIgnoreCase))) + { + return configFullPath; + } + } + } + + return null; + } + catch + { + return null; + } + } + + private static (string Os, string Arch) GetBuildPlatform() + { + var os = OperatingSystem.IsLinux() ? "linux" + : OperatingSystem.IsMacOS() ? "darwin" + : "windows"; + + var arch = RuntimeInformation.OSArchitecture switch + { + Architecture.X86 => "386", + Architecture.X64 => "amd64", + Architecture.Arm64 => "arm64", + _ => "amd64" + }; + + return (os, arch); + } + + private static string GetDcpVersionFromRepo(string repoRoot, string buildOs, string buildArch) + { + const string fallbackVersion = "0.21.1"; + + try + { + var versionsPropsPath = Path.Combine(repoRoot, "eng", "Versions.props"); + if (!File.Exists(versionsPropsPath)) + { + return fallbackVersion; + } + + var doc = XDocument.Load(versionsPropsPath); + + var propertyName = $"MicrosoftDeveloperControlPlane{buildOs}{buildArch}Version"; + + var version = doc.Descendants(propertyName).FirstOrDefault()?.Value; + return version ?? fallbackVersion; + } + catch + { + return fallbackVersion; + } + } +} diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 434b5977525..87177f18b46 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -98,8 +98,8 @@ private string GetEffectiveSdkVersion() return configuredVersion; } - _logger.LogDebug("Using default SDK version: {Version}", AppHostServerProject.DefaultSdkVersion); - return AppHostServerProject.DefaultSdkVersion; + _logger.LogDebug("Using default SDK version: {Version}", DotNetBasedAppHostServerProject.DefaultSdkVersion); + return DotNetBasedAppHostServerProject.DefaultSdkVersion; } // ═══════════════════════════════════════════════════════════════ @@ -145,27 +145,16 @@ public bool CanHandle(FileInfo appHostFile) } /// - /// Creates project files and builds the AppHost server. + /// Prepares the AppHost server (creates files and builds for dev mode, restores packages for prebuilt mode). /// - private static async Task<(bool Success, OutputCollector Output, string? ChannelName)> BuildAppHostServerAsync( - AppHostServerProject appHostServerProject, + private static async Task<(bool Success, OutputCollector? Output, string? ChannelName, bool NeedsCodeGen)> PrepareAppHostServerAsync( + IAppHostServerProject appHostServerProject, string sdkVersion, List<(string Name, string Version)> packages, CancellationToken cancellationToken) { - var outputCollector = new OutputCollector(); - - var (_, channelName) = await appHostServerProject.CreateProjectFilesAsync(sdkVersion, packages, cancellationToken); - var (buildSuccess, buildOutput) = await appHostServerProject.BuildAsync(cancellationToken); - if (!buildSuccess) - { - foreach (var (_, line) in buildOutput.GetLines()) - { - outputCollector.AppendOutput(line); - } - } - - return (buildSuccess, outputCollector, channelName); + var result = await appHostServerProject.PrepareAsync(sdkVersion, packages, cancellationToken); + return (result.Success, result.Output, result.ChannelName, result.NeedsCodeGeneration); } /// @@ -179,19 +168,21 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Cancellatio var packages = await GetAllPackagesAsync(config, cancellationToken); var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName); - var socketPath = appHostServerProject.GetSocketPath(); - var (buildSuccess, buildOutput, _) = await BuildAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); + var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); if (!buildSuccess) { - _interactionService.DisplayLines(buildOutput.GetLines()); - _interactionService.DisplayError("Failed to build AppHost server."); + if (buildOutput is not null) + { + _interactionService.DisplayLines(buildOutput.GetLines()); + } + _interactionService.DisplayError("Failed to prepare AppHost server."); return; } // Step 2: Start the AppHost server temporarily for code generation var currentPid = Environment.ProcessId; - var (serverProcess, _) = appHostServerProject.Run(socketPath, currentPid, new Dictionary()); + var (socketPath, serverProcess, _) = appHostServerProject.Run(currentPid); try { @@ -268,7 +259,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken Dictionary certEnvVars; try { - var certResult = await _certificateService.EnsureCertificatesTrustedAsync(_runner, cancellationToken); + var certResult = await _certificateService.EnsureCertificatesTrustedAsync(cancellationToken); certEnvVars = new Dictionary(certResult.EnvironmentVariables); } catch @@ -284,20 +275,19 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken var packages = await GetAllPackagesAsync(config, cancellationToken); var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName); - var socketPath = appHostServerProject.GetSocketPath(); var buildResult = await _interactionService.ShowStatusAsync( ":gear: Preparing Aspire server...", async () => { - // Build the AppHost server - var (buildSuccess, buildOutput, channelName) = await BuildAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); - if (!buildSuccess) + // Prepare the AppHost server (build for dev mode, restore for prebuilt) + var (prepareSuccess, prepareOutput, channelName, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); + if (!prepareSuccess) { - return (Success: false, Output: buildOutput, Error: "Failed to build app host.", ChannelName: (string?)null, NeedsCodeGen: false); + return (Success: false, Output: prepareOutput, Error: "Failed to prepare app host.", ChannelName: (string?)null, NeedsCodeGen: false); } - return (Success: true, Output: buildOutput, Error: (string?)null, ChannelName: channelName, NeedsCodeGen: NeedsGeneration(directory.FullName, packages)); + return (Success: true, Output: prepareOutput, Error: (string?)null, ChannelName: channelName, NeedsCodeGen: needsCodeGen); }); // Save the channel to settings.json if available (config already has SdkVersion) @@ -342,7 +332,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // Start the AppHost server process var currentPid = Environment.ProcessId; - var (appHostServerProcess, appHostServerOutputCollector) = appHostServerProject.Run(socketPath, currentPid, launchSettingsEnvVars, debug: context.Debug); + var (socketPath, appHostServerProcess, appHostServerOutputCollector) = appHostServerProject.Run(currentPid, launchSettingsEnvVars, debug: context.Debug); // The backchannel completion source is the contract with RunCommand // We signal this when the backchannel is ready, RunCommand uses it for UX @@ -572,28 +562,24 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca var packages = await GetAllPackagesAsync(config, cancellationToken); var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName); - var jsonRpcSocketPath = appHostServerProject.GetSocketPath(); - // Build the AppHost server - var (buildSuccess, buildOutput, _) = await BuildAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); - if (!buildSuccess) + // Prepare the AppHost server (build for dev mode, restore for prebuilt) + var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); + if (!prepareSuccess) { // Set OutputCollector so PipelineCommandBase can display errors - context.OutputCollector = buildOutput; + context.OutputCollector = prepareOutput; // Signal the backchannel completion source so the caller doesn't wait forever context.BackchannelCompletionSource?.TrySetException( - new InvalidOperationException("The app host build failed.")); + new InvalidOperationException("The app host preparation failed.")); return ExitCodeConstants.FailedToBuildArtifacts; } // Store output collector in context for exception handling - context.OutputCollector = buildOutput; - - // Check if code generation is needed (we'll do it after server starts) - var needsCodeGen = NeedsGeneration(directory.FullName, packages); + context.OutputCollector = prepareOutput; // Read launchSettings.json if it exists - var launchSettingsEnvVars = ReadLaunchSettingsEnvironmentVariables(directory) ?? new Dictionary(); + var launchSettingsEnvVars = ReadLaunchSettingsEnvironmentVariables(directory) ?? []; // Generate a backchannel socket path for CLI to connect to AppHost server var backchannelSocketPath = GetBackchannelSocketPath(); @@ -603,7 +589,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca // Step 2: Start the AppHost server process (it opens the backchannel for progress reporting) var currentPid = Environment.ProcessId; - var (appHostServerProcess, appHostServerOutputCollector) = appHostServerProject.Run(jsonRpcSocketPath, currentPid, launchSettingsEnvVars, debug: context.Debug); + var (jsonRpcSocketPath, appHostServerProcess, appHostServerOutputCollector) = appHostServerProject.Run(currentPid, launchSettingsEnvVars, debug: context.Debug); // Start connecting to the backchannel if (context.BackchannelCompletionSource is not null) @@ -960,7 +946,7 @@ public async Task CheckAndHandleRunningInstanceAsync(File } var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName); - var genericAppHostPath = appHostServerProject.GetProjectFilePath(); + var genericAppHostPath = appHostServerProject.GetInstanceIdentifier(); // Find matching sockets for this AppHost var matchingSockets = AppHostHelper.FindMatchingSockets(genericAppHostPath, homeDirectory.FullName); @@ -978,43 +964,6 @@ public async Task CheckAndHandleRunningInstanceAsync(File return results.All(r => r) ? RunningInstanceResult.InstanceStopped : RunningInstanceResult.StopFailed; } - /// - /// Checks if code generation is needed based on the current state. - /// - private bool NeedsGeneration(string appPath, IEnumerable<(string PackageId, string Version)> packages) - { - // In dev mode (ASPIRE_REPO_ROOT set), always regenerate to pick up code changes - if (!string.IsNullOrEmpty(_configuration["ASPIRE_REPO_ROOT"])) - { - _logger.LogDebug("Dev mode detected (ASPIRE_REPO_ROOT set), skipping generation cache"); - return true; - } - - return CheckNeedsGeneration(appPath, packages.ToList()); - } - - /// - /// Checks if code generation is needed by comparing the hash of current packages - /// with the stored hash from previous generation. - /// - private static bool CheckNeedsGeneration(string appPath, List<(string PackageId, string Version)> packages) - { - var generatedPath = Path.Combine(appPath, GeneratedFolderName); - var hashPath = Path.Combine(generatedPath, ".codegen-hash"); - - // If hash file doesn't exist, generation is needed - if (!File.Exists(hashPath)) - { - return true; - } - - // Compare stored hash with current packages hash - var storedHash = File.ReadAllText(hashPath).Trim(); - var currentHash = ComputePackagesHash(packages); - - return !string.Equals(storedHash, currentHash, StringComparison.OrdinalIgnoreCase); - } - /// /// Generates SDK code by calling the AppHost server's generateCode RPC method. /// diff --git a/src/Aspire.Cli/Projects/IAppHostServerProject.cs b/src/Aspire.Cli/Projects/IAppHostServerProject.cs new file mode 100644 index 00000000000..351dc2402af --- /dev/null +++ b/src/Aspire.Cli/Projects/IAppHostServerProject.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. + +using System.Diagnostics; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Projects; + +/// +/// Result of preparing an AppHost server for running. +/// +/// Whether preparation succeeded. +/// Build/preparation output for display on failure. +/// The NuGet channel used (SDK mode only, null for bundle mode). +/// Whether code generation is needed for the guest language. +internal sealed record AppHostServerPrepareResult( + bool Success, + OutputCollector? Output, + string? ChannelName = null, + bool NeedsCodeGeneration = false); + +/// +/// Represents an AppHost server that can be prepared and run. +/// This abstraction allows for different implementations: +/// - SDK mode: dynamically generates and builds a .NET project +/// - Bundle mode: uses a pre-built server from the Aspire bundle +/// +internal interface IAppHostServerProject +{ + /// + /// Gets the path to the user's app (the polyglot apphost directory). + /// + string AppPath { get; } + + /// + /// Prepares the AppHost server for running. + /// For SDK mode: creates project files and builds the project. + /// For bundle mode: restores integration packages from NuGet. + /// + /// The Aspire SDK version to use. + /// The integration packages required by the app host. + /// Cancellation token. + /// The preparation result indicating success/failure and any output. + Task PrepareAsync( + string sdkVersion, + IEnumerable<(string Name, string Version)> packages, + CancellationToken cancellationToken = default); + + /// + /// Runs the AppHost server process. + /// + /// The host process ID (CLI) for orphan detection. + /// Environment variables to pass to the server. + /// Additional command-line arguments. + /// Whether to enable debug logging. + /// The socket path, server process, and an output collector for stdout/stderr. + (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + int hostPid, + IReadOnlyDictionary? environmentVariables = null, + string[]? additionalArgs = null, + bool debug = false); + + /// + /// Gets a unique identifier path for this AppHost, used for running instance detection. + /// For SDK mode: returns the generated project file path. + /// For prebuilt mode: returns the app path. + /// + /// A path that uniquely identifies this AppHost. + string GetInstanceIdentifier(); +} diff --git a/src/Aspire.Cli/Projects/IAppHostServerSession.cs b/src/Aspire.Cli/Projects/IAppHostServerSession.cs index 7df78e9ed1e..843ad10b97c 100644 --- a/src/Aspire.Cli/Projects/IAppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/IAppHostServerSession.cs @@ -62,10 +62,10 @@ Task CreateAsync( /// /// Whether the build was successful. /// The session if successful, null otherwise. -/// The build output for error diagnostics. +/// The build output for error diagnostics, may be null. /// The NuGet channel name used, if any. internal record AppHostServerSessionResult( bool Success, IAppHostServerSession? Session, - OutputCollector BuildOutput, + OutputCollector? BuildOutput, string? ChannelName); diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs new file mode 100644 index 00000000000..ee528bedb4d --- /dev/null +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -0,0 +1,383 @@ +// 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.Security.Cryptography; +using System.Text; +using Aspire.Cli.Configuration; +using Aspire.Cli.Layout; +using Aspire.Cli.NuGet; +using Aspire.Cli.Packaging; +using Aspire.Cli.Utils; +using Aspire.Hosting; +using Aspire.Shared; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Projects; + +/// +/// Manages a pre-built AppHost server from the Aspire bundle layout. +/// This is used when running in bundle mode (without .NET SDK) to avoid +/// dynamic project generation and building. +/// +internal sealed class PrebuiltAppHostServer : IAppHostServerProject +{ + private readonly string _appPath; + private readonly string _socketPath; + private readonly LayoutConfiguration _layout; + private readonly BundleNuGetService _nugetService; + private readonly IPackagingService _packagingService; + private readonly IConfigurationService _configurationService; + private readonly ILogger _logger; + private readonly string _workingDirectory; + + // Path to restored integration libraries (set during PrepareAsync) + private string? _integrationLibsPath; + + /// + /// Initializes a new instance of the PrebuiltAppHostServer class. + /// + /// The path to the user's polyglot app host. + /// The socket path for JSON-RPC communication. + /// The bundle layout configuration. + /// The NuGet service for restoring integration packages. + /// The packaging service for channel resolution. + /// The configuration service for reading channel settings. + /// The logger for diagnostic output. + public PrebuiltAppHostServer( + string appPath, + string socketPath, + LayoutConfiguration layout, + BundleNuGetService nugetService, + IPackagingService packagingService, + IConfigurationService configurationService, + ILogger logger) + { + _appPath = Path.GetFullPath(appPath); + _socketPath = socketPath; + _layout = layout; + _nugetService = nugetService; + _packagingService = packagingService; + _configurationService = configurationService; + _logger = logger; + + // Create a working directory for this app host session + var pathHash = SHA256.HashData(Encoding.UTF8.GetBytes(_appPath)); + var pathDir = Convert.ToHexString(pathHash)[..12].ToLowerInvariant(); + _workingDirectory = Path.Combine(Path.GetTempPath(), ".aspire", "bundle-hosts", pathDir); + Directory.CreateDirectory(_workingDirectory); + } + + /// + public string AppPath => _appPath; + + /// + /// Gets the path to the pre-built AppHost server (exe or DLL). + /// + public string GetServerPath() + { + var serverPath = _layout.GetAppHostServerPath(); + if (serverPath is null || !File.Exists(serverPath)) + { + throw new InvalidOperationException("Pre-built AppHost server not found in layout."); + } + + return serverPath; + } + + /// + public async Task PrepareAsync( + string sdkVersion, + IEnumerable<(string Name, string Version)> packages, + CancellationToken cancellationToken = default) + { + var packageList = packages.ToList(); + + try + { + // Generate appsettings.json with ATS assemblies for the server to scan + await GenerateAppSettingsAsync(packageList, cancellationToken); + + // Resolve the configured channel (local settings.json → global config fallback) + var channelName = await ResolveChannelNameAsync(cancellationToken); + + // Restore integration packages + if (packageList.Count > 0) + { + _logger.LogDebug("Restoring {Count} integration packages", packageList.Count); + + // Get NuGet sources filtered to the resolved channel + var sources = await GetNuGetSourcesAsync(channelName, cancellationToken); + + // Pass apphost directory for nuget.config discovery + var appHostDirectory = Path.GetDirectoryName(_appPath); + + _integrationLibsPath = await _nugetService.RestorePackagesAsync( + packageList, + "net10.0", + sources: sources, + workingDirectory: appHostDirectory, + ct: cancellationToken); + } + + return new AppHostServerPrepareResult( + Success: true, + Output: null, + ChannelName: channelName, + NeedsCodeGeneration: true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to prepare prebuilt AppHost server"); + var output = new OutputCollector(); + output.AppendError($"Failed to prepare: {ex.Message}"); + return new AppHostServerPrepareResult( + Success: false, + Output: output, + ChannelName: null, + NeedsCodeGeneration: false); + } + } + + /// + /// Resolves the configured channel name from local settings.json or global config. + /// + private async Task ResolveChannelNameAsync(CancellationToken cancellationToken) + { + // Check local settings.json first + var localConfig = AspireJsonConfiguration.Load(Path.GetDirectoryName(_appPath)!); + var channelName = localConfig?.Channel; + + // Fall back to global config + if (string.IsNullOrEmpty(channelName)) + { + channelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); + } + + if (!string.IsNullOrEmpty(channelName)) + { + _logger.LogDebug("Resolved channel: {Channel}", channelName); + } + + return channelName; + } + + /// + /// Gets NuGet sources from the resolved channel, or all explicit channels if no channel is configured. + /// + private async Task?> GetNuGetSourcesAsync(string? channelName, CancellationToken cancellationToken) + { + var sources = new List(); + + try + { + var channels = await _packagingService.GetChannelsAsync(cancellationToken); + + IEnumerable explicitChannels; + if (!string.IsNullOrEmpty(channelName)) + { + // Filter to the configured channel + var matchingChannel = channels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); + explicitChannels = matchingChannel is not null ? [matchingChannel] : channels.Where(c => c.Type == PackageChannelType.Explicit); + } + else + { + // No channel configured, use all explicit channels + explicitChannels = channels.Where(c => c.Type == PackageChannelType.Explicit); + } + + foreach (var channel in explicitChannels) + { + if (channel.Mappings is null) + { + continue; + } + + foreach (var mapping in channel.Mappings) + { + if (!sources.Contains(mapping.Source, StringComparer.OrdinalIgnoreCase)) + { + sources.Add(mapping.Source); + _logger.LogDebug("Using channel '{Channel}' NuGet source: {Source}", channel.Name, mapping.Source); + } + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get package channels, relying on nuget.config and nuget.org fallback"); + } + + return sources.Count > 0 ? sources : null; + } + + /// + public (string SocketPath, Process Process, OutputCollector OutputCollector) Run( + int hostPid, + IReadOnlyDictionary? environmentVariables = null, + string[]? additionalArgs = null, + bool debug = false) + { + var serverPath = GetServerPath(); + + // Get runtime path for DOTNET_ROOT + var runtimePath = _layout.GetDotNetExePath(); + var runtimeDir = runtimePath is not null ? Path.GetDirectoryName(runtimePath) : null; + + // Bundle always uses single-file executables - run directly + var startInfo = new ProcessStartInfo(serverPath) + { + WorkingDirectory = _workingDirectory, + WindowStyle = ProcessWindowStyle.Minimized, + UseShellExecute = false, + CreateNoWindow = true + }; + + // Set DOTNET_ROOT so the executable can find the runtime + if (runtimeDir is not null) + { + startInfo.Environment["DOTNET_ROOT"] = runtimeDir; + startInfo.Environment["DOTNET_MULTILEVEL_LOOKUP"] = "0"; + } + + // Add arguments to point to our appsettings.json + startInfo.ArgumentList.Add("--"); + startInfo.ArgumentList.Add("--contentRoot"); + startInfo.ArgumentList.Add(_workingDirectory); + + // Add any additional arguments + if (additionalArgs is { Length: > 0 }) + { + foreach (var arg in additionalArgs) + { + startInfo.ArgumentList.Add(arg); + } + } + + // Configure environment + startInfo.Environment["REMOTE_APP_HOST_SOCKET_PATH"] = _socketPath; + startInfo.Environment["REMOTE_APP_HOST_PID"] = hostPid.ToString(System.Globalization.CultureInfo.InvariantCulture); + startInfo.Environment[KnownConfigNames.CliProcessId] = hostPid.ToString(System.Globalization.CultureInfo.InvariantCulture); + + // Also set ASPIRE_RUNTIME_PATH so DashboardEventHandlers knows which dotnet to use + if (runtimeDir is not null) + { + startInfo.Environment[BundleDiscovery.RuntimePathEnvVar] = runtimeDir; + } + + // Pass the integration libs path so the server can resolve assemblies via AssemblyLoader + if (_integrationLibsPath is not null) + { + _logger.LogDebug("Setting ASPIRE_INTEGRATION_LIBS_PATH to {Path}", _integrationLibsPath); + startInfo.Environment["ASPIRE_INTEGRATION_LIBS_PATH"] = _integrationLibsPath; + } + else + { + _logger.LogWarning("Integration libs path is null - assemblies may not resolve correctly"); + } + + // Set DCP and Dashboard paths from the layout + var dcpPath = _layout.GetDcpPath(); + if (dcpPath is not null) + { + startInfo.Environment[BundleDiscovery.DcpPathEnvVar] = dcpPath; + } + + var dashboardPath = _layout.GetDashboardPath(); + if (dashboardPath is not null) + { + // Bundle uses single-file executables + var dashboardExe = Path.Combine(dashboardPath, BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)); + startInfo.Environment[BundleDiscovery.DashboardPathEnvVar] = dashboardExe; + } + + // Apply environment variables from apphost.run.json + if (environmentVariables is not null) + { + foreach (var (key, value) in environmentVariables) + { + startInfo.Environment[key] = value; + } + } + + if (debug) + { + startInfo.Environment["Logging__LogLevel__Default"] = "Debug"; + } + + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + + var process = Process.Start(startInfo)!; + + var outputCollector = new OutputCollector(); + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + { + _logger.LogDebug("PrebuiltAppHostServer({ProcessId}) stdout: {Line}", process.Id, e.Data); + outputCollector.AppendOutput(e.Data); + } + }; + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + { + _logger.LogDebug("PrebuiltAppHostServer({ProcessId}) stderr: {Line}", process.Id, e.Data); + outputCollector.AppendError(e.Data); + } + }; + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + return (_socketPath, process, outputCollector); + } + + /// + public string GetInstanceIdentifier() => _appPath; + + private async Task GenerateAppSettingsAsync( + List<(string Name, string Version)> packages, + CancellationToken cancellationToken) + { + // Build the list of ATS assemblies (for [AspireExport] scanning) + // Skip SDK-only packages that don't have runtime DLLs + var atsAssemblies = new List { "Aspire.Hosting" }; + foreach (var (name, _) in packages) + { + // Skip SDK packages that don't produce runtime assemblies + if (name.Equals("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("Aspire.AppHost.Sdk", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!atsAssemblies.Contains(name, StringComparer.OrdinalIgnoreCase)) + { + atsAssemblies.Add(name); + } + } + + var assembliesJson = string.Join(",\n ", atsAssemblies.Select(a => $"\"{a}\"")); + var appSettingsJson = $$""" + { + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + }, + "AtsAssemblies": [ + {{assembliesJson}} + ] + } + """; + + await File.WriteAllTextAsync( + Path.Combine(_workingDirectory, "appsettings.json"), + appSettingsJson, + cancellationToken); + } +} diff --git a/src/Aspire.Cli/Projects/ProjectUpdater.cs b/src/Aspire.Cli/Projects/ProjectUpdater.cs index 450fe1cd1bd..7ae71fd8cc7 100644 --- a/src/Aspire.Cli/Projects/ProjectUpdater.cs +++ b/src/Aspire.Cli/Projects/ProjectUpdater.cs @@ -626,7 +626,7 @@ private static bool IsUpdatablePackage(string packageId) return false; } - return packageId.StartsWith("Aspire."); + return packageId.StartsWith("Aspire.", StringComparison.Ordinal); } private static CentralPackageManagementInfo DetectCentralPackageManagement(FileInfo projectFile) diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index e934da30917..4120a219f1a 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -5,7 +5,6 @@ using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; using Aspire.Cli.Projects; -using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; using Semver; @@ -71,21 +70,23 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Cancellat } var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName); - var socketPath = appHostServerProject.GetSocketPath(); - var (buildSuccess, buildOutput, channelName) = await _interactionService.ShowStatusAsync( + var prepareResult = await _interactionService.ShowStatusAsync( ":gear: Preparing Aspire server...", - () => BuildAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken)); - if (!buildSuccess) + () => appHostServerProject.PrepareAsync(config.SdkVersion!, packages, cancellationToken)); + if (!prepareResult.Success) { - _interactionService.DisplayLines(buildOutput.GetLines()); + if (prepareResult.Output is not null) + { + _interactionService.DisplayLines(prepareResult.Output.GetLines()); + } _interactionService.DisplayError("Failed to build AppHost server."); return; } // Step 2: Start the server temporarily for scaffolding and code generation var currentPid = Environment.ProcessId; - var (serverProcess, _) = appHostServerProject.Run(socketPath, currentPid, new Dictionary()); + var (socketPath, serverProcess, _) = appHostServerProject.Run(currentPid, new Dictionary()); try { @@ -129,9 +130,9 @@ await GenerateCodeViaRpcAsync( cancellationToken); // Save channel and language to settings.json - if (channelName is not null) + if (prepareResult.ChannelName is not null) { - config.Channel = channelName; + config.Channel = prepareResult.ChannelName; } config.Language = language.LanguageId; config.Save(directory.FullName); @@ -153,27 +154,6 @@ await GenerateCodeViaRpcAsync( } } - private static async Task<(bool Success, OutputCollector Output, string? ChannelName)> BuildAppHostServerAsync( - AppHostServerProject appHostServerProject, - string sdkVersion, - List<(string Name, string Version)> packages, - CancellationToken cancellationToken) - { - var outputCollector = new OutputCollector(); - - var (_, channelName) = await appHostServerProject.CreateProjectFilesAsync(sdkVersion, packages, cancellationToken); - var (buildSuccess, buildOutput) = await appHostServerProject.BuildAsync(cancellationToken); - if (!buildSuccess) - { - foreach (var (_, line) in buildOutput.GetLines()) - { - outputCollector.AppendOutput(line); - } - } - - return (buildSuccess, outputCollector, channelName); - } - private async Task InstallDependenciesAsync( DirectoryInfo directory, LanguageInfo language, @@ -231,7 +211,7 @@ private async Task ResolveSdkVersionAsync(CancellationToken cancellation var channelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); if (string.IsNullOrEmpty(channelName)) { - return AppHostServerProject.DefaultSdkVersion; + return DotNetBasedAppHostServerProject.DefaultSdkVersion; } // Find the matching channel @@ -240,7 +220,7 @@ private async Task ResolveSdkVersionAsync(CancellationToken cancellation if (channel is null) { _logger.LogWarning("Configured channel '{Channel}' not found, using default SDK version", channelName); - return AppHostServerProject.DefaultSdkVersion; + return DotNetBasedAppHostServerProject.DefaultSdkVersion; } // Get template packages from the channel to determine SDK version @@ -252,7 +232,7 @@ private async Task ResolveSdkVersionAsync(CancellationToken cancellation if (latestPackage is null) { _logger.LogWarning("No packages found in channel '{Channel}', using default SDK version", channelName); - return AppHostServerProject.DefaultSdkVersion; + return DotNetBasedAppHostServerProject.DefaultSdkVersion; } _logger.LogDebug("Resolved SDK version {Version} from channel {Channel}", latestPackage.Version, channelName); diff --git a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs index cbb90cbd393..bf4a3248bc7 100644 --- a/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/DotNetTemplateFactory.cs @@ -25,7 +25,8 @@ internal class DotNetTemplateFactory( INewCommandPrompter prompter, CliExecutionContext executionContext, IFeatures features, - IConfigurationService configurationService) + IConfigurationService configurationService, + ICliHostEnvironment hostEnvironment) : ITemplateFactory { // Template-specific options @@ -51,7 +52,8 @@ internal class DotNetTemplateFactory( public IEnumerable GetTemplates() { var showAllTemplates = features.IsFeatureEnabled(KnownFeatures.ShowAllTemplates, false); - return GetTemplatesCore(showAllTemplates); + var nonInteractive = !hostEnvironment.SupportsInteractiveInput; + return GetTemplatesCore(showAllTemplates, nonInteractive); } public IEnumerable GetInitTemplates() @@ -484,7 +486,7 @@ private async Task ApplyTemplateAsync(CallbackTemplate template, } // Trust certificates (result not used since we're not launching an AppHost) - _ = await certificateService.EnsureCertificatesTrustedAsync(runner, cancellationToken); + _ = await certificateService.EnsureCertificatesTrustedAsync(cancellationToken); // For explicit channels, optionally create or update a NuGet.config. If none exists in the current // working directory, create one in the newly created project's output directory. diff --git a/src/Aspire.Cli/Utils/BundleDownloader.cs b/src/Aspire.Cli/Utils/BundleDownloader.cs new file mode 100644 index 00000000000..6d40ca8f5ee --- /dev/null +++ b/src/Aspire.Cli/Utils/BundleDownloader.cs @@ -0,0 +1,705 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Formats.Tar; +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using Aspire.Cli.Interaction; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Utils; + +/// +/// Handles downloading and updating the Aspire Bundle. +/// +internal interface IBundleDownloader +{ + /// + /// Downloads the latest bundle version. + /// + /// Cancellation token. + /// Path to the downloaded bundle archive. + Task DownloadLatestBundleAsync(CancellationToken cancellationToken); + + /// + /// Gets the latest available bundle version. + /// + /// Cancellation token. + /// The latest version string. + Task GetLatestVersionAsync(CancellationToken cancellationToken); + + /// + /// Gets whether a bundle update is available. + /// + /// Current bundle version. + /// Cancellation token. + /// True if an update is available. + Task IsUpdateAvailableAsync(string currentVersion, CancellationToken cancellationToken); + + /// + /// Applies a downloaded bundle update to the specified installation directory. + /// Handles file locking by staging updates and using atomic swaps where possible. + /// + /// Path to the downloaded bundle archive. + /// Target installation directory. + /// Cancellation token. + /// Result indicating success or if a restart is required. + Task ApplyUpdateAsync(string archivePath, string installPath, CancellationToken cancellationToken); +} + +/// +/// Result of applying a bundle update. +/// +internal sealed class BundleUpdateResult +{ + /// + /// Whether the update was successfully applied. + /// + public bool Success { get; init; } + + /// + /// Whether a restart is required to complete the update. + /// + public bool RestartRequired { get; init; } + + /// + /// Path to a script that should be run to complete the update (Windows only). + /// + public string? PendingUpdateScript { get; init; } + + /// + /// Error message if the update failed. + /// + public string? ErrorMessage { get; init; } + + /// + /// The new version that was installed. + /// + public string? InstalledVersion { get; init; } + + public static BundleUpdateResult Succeeded(string version) => new() + { + Success = true, + InstalledVersion = version + }; + + public static BundleUpdateResult RequiresRestart(string scriptPath) => new() + { + Success = true, + RestartRequired = true, + PendingUpdateScript = scriptPath + }; + + public static BundleUpdateResult Failed(string error) => new() + { + Success = false, + ErrorMessage = error + }; +} + +internal sealed class BundleDownloader : IBundleDownloader +{ + private const string GitHubRepo = "dotnet/aspire"; + private const string GitHubReleasesApi = $"https://api.github.com/repos/{GitHubRepo}/releases"; + private const int DownloadTimeoutSeconds = 600; + private const int ApiTimeoutSeconds = 30; + private const string PendingUpdateDir = ".pending-update"; + private const string BackupDir = ".backup"; + + private static readonly HttpClient s_httpClient = new() + { + DefaultRequestHeaders = + { + { "User-Agent", "aspire-bundle-updater/1.0" }, + { "Accept", "application/vnd.github+json" } + } + }; + + private readonly ILogger _logger; + private readonly IInteractionService _interactionService; + + public BundleDownloader( + ILogger logger, + IInteractionService interactionService) + { + _logger = logger; + _interactionService = interactionService; + } + + public async Task GetLatestVersionAsync(CancellationToken cancellationToken) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(ApiTimeoutSeconds)); + + var response = await s_httpClient.GetStringAsync($"{GitHubReleasesApi}/latest", cts.Token); + using var doc = JsonDocument.Parse(response); + + if (doc.RootElement.TryGetProperty("tag_name", out var tagName)) + { + var version = tagName.GetString(); + // Remove 'v' prefix if present + if (version?.StartsWith("v", StringComparison.OrdinalIgnoreCase) == true) + { + version = version[1..]; + } + return version; + } + + return null; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to get latest version from GitHub"); + return null; + } + } + + public async Task IsUpdateAvailableAsync(string currentVersion, CancellationToken cancellationToken) + { + var latestVersion = await GetLatestVersionAsync(cancellationToken); + if (string.IsNullOrEmpty(latestVersion)) + { + return false; + } + + // Try to parse as semver and compare + if (Version.TryParse(NormalizeVersion(currentVersion), out var current) && + Version.TryParse(NormalizeVersion(latestVersion), out var latest)) + { + return latest > current; + } + + // Fall back to string comparison + return !string.Equals(currentVersion, latestVersion, StringComparison.OrdinalIgnoreCase); + } + + public async Task DownloadLatestBundleAsync(CancellationToken cancellationToken) + { + var version = await GetLatestVersionAsync(cancellationToken); + if (string.IsNullOrEmpty(version)) + { + throw new InvalidOperationException("Failed to determine latest bundle version"); + } + + var rid = GetRuntimeIdentifier(); + var extension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "zip" : "tar.gz"; + var filename = $"aspire-bundle-{version}-{rid}.{extension}"; + var downloadUrl = $"https://github.com/{GitHubRepo}/releases/download/v{version}/{filename}"; + + _logger.LogDebug("Downloading bundle from {Url}", downloadUrl); + + // Create temp directory + var tempDir = Directory.CreateTempSubdirectory("aspire-bundle-download").FullName; + var archivePath = Path.Combine(tempDir, filename); + + try + { + await _interactionService.ShowStatusAsync($"Downloading Aspire Bundle v{version}...", async () => + { + const int maxRetries = 3; + for (var attempt = 1; attempt <= maxRetries; attempt++) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(DownloadTimeoutSeconds)); + + using var response = await s_httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cts.Token); + response.EnsureSuccessStatusCode(); + + await using var contentStream = await response.Content.ReadAsStreamAsync(cts.Token); + await using var fileStream = new FileStream(archivePath, FileMode.Create, FileAccess.Write, FileShare.None); + await contentStream.CopyToAsync(fileStream, cts.Token); + + return 0; + } + catch (HttpRequestException) when (attempt < maxRetries) + { + _logger.LogDebug("Download attempt {Attempt} failed, retrying...", attempt); + await Task.Delay(TimeSpan.FromSeconds(attempt * 2), cancellationToken); + } + } + + return 0; + }); + + // Try to download and validate checksum + var checksumUrl = $"{downloadUrl}.sha512"; + + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(30)); + + var checksumContent = await s_httpClient.GetStringAsync(checksumUrl, cts.Token); + await ValidateChecksumAsync(archivePath, checksumContent, cancellationToken); + _interactionService.DisplayMessage("check_mark", "Checksum validated"); + } + catch (HttpRequestException) + { + // Checksum file may not exist for all releases + _logger.LogDebug("Checksum file not available, skipping validation"); + } + + return archivePath; + } + catch + { + // Clean up temp directory on failure + CleanupDirectory(tempDir); + throw; + } + } + + public async Task ApplyUpdateAsync(string archivePath, string installPath, CancellationToken cancellationToken) + { + var stagingPath = Path.Combine(installPath, PendingUpdateDir); + var backupPath = Path.Combine(installPath, BackupDir); + + try + { + // Step 1: Extract to staging directory + _interactionService.DisplayMessage("package", "Extracting update..."); + CleanupDirectory(stagingPath); + Directory.CreateDirectory(stagingPath); + + await ExtractArchiveAsync(archivePath, stagingPath, cancellationToken); + + // Read version from extracted layout.json + var version = await ReadVersionFromLayoutAsync(stagingPath); + + // Step 2: Try atomic swap approach first + if (await TryAtomicSwapAsync(installPath, stagingPath, backupPath)) + { + _interactionService.DisplaySuccess($"Updated to version {version ?? "unknown"}"); + CleanupDirectory(backupPath); + return BundleUpdateResult.Succeeded(version ?? "unknown"); + } + + // Step 3: If atomic swap fails (files locked), try incremental update + var lockedFiles = await TryIncrementalUpdateAsync(installPath, stagingPath); + + if (lockedFiles.Count == 0) + { + _interactionService.DisplaySuccess($"Updated to version {version ?? "unknown"}"); + CleanupDirectory(stagingPath); + return BundleUpdateResult.Succeeded(version ?? "unknown"); + } + + // Step 4: If files are locked (Windows), create a pending update script + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var scriptPath = CreatePendingUpdateScript(installPath, stagingPath, lockedFiles); + _interactionService.DisplayMessage("warning", "Some files are in use. Update will complete on next restart."); + _interactionService.DisplayMessage("information", $"Or run: {scriptPath}"); + return BundleUpdateResult.RequiresRestart(scriptPath); + } + + // On Unix, locked files are less common but handle gracefully + _interactionService.DisplayMessage("warning", $"Could not update {lockedFiles.Count} locked files. Please close Aspire and try again."); + return BundleUpdateResult.Failed($"Files locked: {string.Join(", ", lockedFiles.Take(5))}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to apply bundle update"); + return BundleUpdateResult.Failed(ex.Message); + } + } + + private Task TryAtomicSwapAsync(string installPath, string stagingPath, string backupPath) + { + // On Unix, we can try to do an atomic directory swap using rename + // On Windows, this typically fails if any files are in use + + try + { + CleanupDirectory(backupPath); + + // Get list of items to move (excluding staging and backup dirs) + var itemsToBackup = Directory.EnumerateFileSystemEntries(installPath) + .Where(p => !p.EndsWith(PendingUpdateDir) && !p.EndsWith(BackupDir)) + .ToList(); + + if (itemsToBackup.Count == 0) + { + // Fresh install, just move staging contents + foreach (var item in Directory.EnumerateFileSystemEntries(stagingPath)) + { + var destPath = Path.Combine(installPath, Path.GetFileName(item)); + if (Directory.Exists(item)) + { + Directory.Move(item, destPath); + } + else + { + File.Move(item, destPath); + } + } + return Task.FromResult(true); + } + + // Create backup directory + Directory.CreateDirectory(backupPath); + + // Try to move all existing items to backup + foreach (var item in itemsToBackup) + { + var destPath = Path.Combine(backupPath, Path.GetFileName(item)); + if (Directory.Exists(item)) + { + Directory.Move(item, destPath); + } + else + { + File.Move(item, destPath); + } + } + + // Move staged items to install location + foreach (var item in Directory.EnumerateFileSystemEntries(stagingPath)) + { + var destPath = Path.Combine(installPath, Path.GetFileName(item)); + if (Directory.Exists(item)) + { + Directory.Move(item, destPath); + } + else + { + File.Move(item, destPath); + } + } + + return Task.FromResult(true); + } + catch (IOException ex) when (IsFileLockedException(ex)) + { + _logger.LogDebug(ex, "Atomic swap failed due to locked files, falling back to incremental update"); + + // Restore from backup if partial swap occurred + if (Directory.Exists(backupPath)) + { + foreach (var item in Directory.EnumerateFileSystemEntries(backupPath)) + { + var destPath = Path.Combine(installPath, Path.GetFileName(item)); + if (!File.Exists(destPath) && !Directory.Exists(destPath)) + { + try + { + if (Directory.Exists(item)) + { + Directory.Move(item, destPath); + } + else + { + File.Move(item, destPath); + } + } + catch + { + // Best effort restore + } + } + } + } + + return Task.FromResult(false); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogDebug(ex, "Atomic swap failed due to permission issues"); + return Task.FromResult(false); + } + } + + private Task> TryIncrementalUpdateAsync(string installPath, string stagingPath) + { + var lockedFiles = new List(); + + foreach (var sourceFile in Directory.EnumerateFiles(stagingPath, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(stagingPath, sourceFile); + + // Validate no path traversal sequences to prevent writing outside install directory + if (relativePath.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(relativePath)) + { + _logger.LogWarning("Skipping file with suspicious path: {Path}", relativePath); + continue; + } + + var destFile = Path.Combine(installPath, relativePath); + + // Additional safety: ensure destination is within install path + var normalizedDest = Path.GetFullPath(destFile); + var normalizedInstall = Path.GetFullPath(installPath); + if (!normalizedDest.StartsWith(normalizedInstall, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Skipping file that would escape install directory: {Path}", relativePath); + continue; + } + + // Ensure destination directory exists + var destDir = Path.GetDirectoryName(destFile); + if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) + { + Directory.CreateDirectory(destDir); + } + + // Try to update the file with retry logic + var updated = FileAccessRetrier.TryFileOperation(() => + { + if (File.Exists(destFile)) + { + // Try rename-move-delete pattern (works even for running executables on Unix) + // Use GUID for unique backup filename to avoid collisions + var backupFile = $"{destFile}.old.{Guid.NewGuid():N}"; + FileAccessRetrier.RetryOnFileAccessFailure(() => + { + // Handle case where backup file already exists (shouldn't happen with GUID, but be safe) + if (File.Exists(backupFile)) + { + FileAccessRetrier.SafeDeleteFile(backupFile); + } + File.Move(destFile, backupFile); + }, maxRetries: 3); + + try + { + File.Move(sourceFile, destFile); + // Clean up backup + FileAccessRetrier.SafeDeleteFile(backupFile); + } + catch + { + // Restore backup on failure + if (File.Exists(backupFile) && !File.Exists(destFile)) + { + File.Move(backupFile, destFile); + } + throw; + } + } + else + { + File.Move(sourceFile, destFile); + } + }); + + if (updated) + { + // Set executable permissions on Unix + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + SetExecutablePermissionIfNeeded(destFile); + } + } + else + { + _logger.LogDebug("File locked, will update later: {File}", relativePath); + lockedFiles.Add(relativePath); + } + } + + return Task.FromResult(lockedFiles); + } + + private static string CreatePendingUpdateScript(string installPath, string stagingPath, List lockedFiles) + { + var scriptPath = Path.Combine(installPath, "complete-update.cmd"); + + var script = $""" + @echo off + echo Completing Aspire Bundle update... + echo Waiting for locked files to be released... + + REM Wait a moment for processes to exit + timeout /t 2 /nobreak > nul + + REM Try to copy locked files + """; + + foreach (var file in lockedFiles) + { + // Skip files with path traversal sequences (silently - this is a static method) + if (file.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(file)) + { + continue; + } + + var sourceFile = Path.Combine(stagingPath, file); + var destFile = Path.Combine(installPath, file); + script += $""" + + copy /Y "{sourceFile}" "{destFile}" > nul 2>&1 + if errorlevel 1 ( + echo Failed to update: {file} + ) else ( + echo Updated: {file} + ) + """; + } + + script += $""" + + REM Cleanup staging directory + rmdir /S /Q "{stagingPath}" > nul 2>&1 + + echo. + echo Update complete. You can delete this script. + del "%~f0" + """; + + File.WriteAllText(scriptPath, script); + return scriptPath; + } + + private static bool IsFileLockedException(IOException ex) + { + // Check for common file-in-use error codes + const int ERROR_SHARING_VIOLATION = 32; + const int ERROR_LOCK_VIOLATION = 33; + + var hResult = ex.HResult & 0xFFFF; + return hResult == ERROR_SHARING_VIOLATION || hResult == ERROR_LOCK_VIOLATION; + } + + private async Task ReadVersionFromLayoutAsync(string path) + { + var layoutJsonPath = Path.Combine(path, "layout.json"); + if (!File.Exists(layoutJsonPath)) + { + return null; + } + + try + { + var json = await File.ReadAllTextAsync(layoutJsonPath); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("version", out var versionProp)) + { + return versionProp.GetString(); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to read version from layout.json"); + } + + return null; + } + + private static async Task ExtractArchiveAsync(string archivePath, string destinationPath, CancellationToken cancellationToken) + { + if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + ZipFile.ExtractToDirectory(archivePath, destinationPath, overwriteFiles: true); + } + else if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) + { + await using var fileStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read); + await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress); + await TarFile.ExtractToDirectoryAsync(gzipStream, destinationPath, overwriteFiles: true, cancellationToken); + } + else + { + throw new NotSupportedException($"Unsupported archive format: {archivePath}"); + } + } + + private static string GetRuntimeIdentifier() + { + var os = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "win" + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx" + : "linux"; + + var arch = RuntimeInformation.OSArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + _ => throw new PlatformNotSupportedException($"Unsupported architecture: {RuntimeInformation.OSArchitecture}") + }; + + return $"{os}-{arch}"; + } + + private static string NormalizeVersion(string version) + { + // Remove prerelease suffixes for comparison + var dashIndex = version.IndexOf('-'); + if (dashIndex > 0) + { + version = version[..dashIndex]; + } + + // Ensure we have at least major.minor.patch + var parts = version.Split('.'); + return parts.Length switch + { + 1 => $"{parts[0]}.0.0", + 2 => $"{parts[0]}.{parts[1]}.0", + _ => version + }; + } + + private async Task ValidateChecksumAsync(string archivePath, string checksumContent, CancellationToken cancellationToken) + { + var expectedChecksum = checksumContent + .Split(' ', StringSplitOptions.RemoveEmptyEntries)[0] + .Trim() + .ToUpperInvariant(); + + await using var stream = File.OpenRead(archivePath); + var actualHash = await SHA512.HashDataAsync(stream, cancellationToken); + var actualChecksum = Convert.ToHexString(actualHash); + + if (!string.Equals(expectedChecksum, actualChecksum, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Checksum validation failed. Expected: {expectedChecksum}, Actual: {actualChecksum}"); + } + + _logger.LogDebug("Checksum validation passed"); + } + + private void SetExecutablePermissionIfNeeded(string filePath) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + // Set executable bit for known executables + var fileName = Path.GetFileName(filePath); + if (fileName == "aspire" || fileName == "dotnet" || fileName.EndsWith(".sh")) + { + try + { + var mode = File.GetUnixFileMode(filePath); + mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + File.SetUnixFileMode(filePath, mode); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to set executable permission on {FilePath}", filePath); + } + } + } + + private void CleanupDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to cleanup directory {Path}", path); + } + } +} diff --git a/src/Aspire.Cli/Utils/FileAccessRetrier.cs b/src/Aspire.Cli/Utils/FileAccessRetrier.cs new file mode 100644 index 00000000000..bbd37012012 --- /dev/null +++ b/src/Aspire.Cli/Utils/FileAccessRetrier.cs @@ -0,0 +1,200 @@ +// 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.Utils; + +/// +/// Provides retry logic for file operations that may fail due to transient file locks. +/// Based on patterns from dotnet/sdk FileAccessRetrier. +/// +internal static class FileAccessRetrier +{ + /// + /// Retries an action on file access failure (IOException, UnauthorizedAccessException). + /// + /// The action to perform. + /// Maximum number of retry attempts. + /// Initial delay in milliseconds (doubles with each retry). + public static void RetryOnFileAccessFailure(Action action, int maxRetries = 10, int initialDelayMs = 10) + { + var remainingRetries = maxRetries; + var delayMs = initialDelayMs; + + while (true) + { + try + { + action(); + return; + } + catch (IOException) when (remainingRetries > 0) + { + Thread.Sleep(delayMs); + delayMs *= 2; + remainingRetries--; + } + catch (UnauthorizedAccessException) when (remainingRetries > 0) + { + Thread.Sleep(delayMs); + delayMs *= 2; + remainingRetries--; + } + } + } + + /// + /// Retries an async action on file access failure. + /// + /// The async action to perform. + /// Maximum number of retry attempts. + /// Initial delay in milliseconds (doubles with each retry). + /// Cancellation token. + public static async Task RetryOnFileAccessFailureAsync( + Func action, + int maxRetries = 10, + int initialDelayMs = 10, + CancellationToken cancellationToken = default) + { + var remainingRetries = maxRetries; + var delayMs = initialDelayMs; + + while (true) + { + try + { + await action(); + return; + } + catch (IOException) when (remainingRetries > 0) + { + await Task.Delay(delayMs, cancellationToken); + delayMs *= 2; + remainingRetries--; + } + catch (UnauthorizedAccessException) when (remainingRetries > 0) + { + await Task.Delay(delayMs, cancellationToken); + delayMs *= 2; + remainingRetries--; + } + } + } + + /// + /// Safely moves a file, handling the case where the destination exists. + /// On failure, retries with exponential backoff. + /// + /// Source file path. + /// Destination file path. + /// Whether to overwrite the destination if it exists. + public static void SafeMoveFile(string sourcePath, string destPath, bool overwrite = true) + { + RetryOnFileAccessFailure(() => + { + if (overwrite && File.Exists(destPath)) + { + File.Delete(destPath); + } + File.Move(sourcePath, destPath); + }); + } + + /// + /// Safely copies a file with retry on access failure. + /// + /// Source file path. + /// Destination file path. + /// Whether to overwrite the destination if it exists. + public static void SafeCopyFile(string sourcePath, string destPath, bool overwrite = true) + { + RetryOnFileAccessFailure(() => + { + File.Copy(sourcePath, destPath, overwrite); + }); + } + + /// + /// Safely deletes a file with retry on access failure. + /// + /// File path to delete. + public static void SafeDeleteFile(string path) + { + if (!File.Exists(path)) + { + return; + } + + RetryOnFileAccessFailure(() => + { + File.Delete(path); + }); + } + + /// + /// Safely deletes a directory with retry on access failure. + /// + /// Directory path to delete. + /// Whether to delete recursively. + public static void SafeDeleteDirectory(string path, bool recursive = true) + { + if (!Directory.Exists(path)) + { + return; + } + + RetryOnFileAccessFailure(() => + { + Directory.Delete(path, recursive); + }); + } + + /// + /// Safely moves a directory with retry on access failure. + /// + /// Source directory path. + /// Destination directory path. + public static void SafeMoveDirectory(string sourcePath, string destPath) + { + RetryOnFileAccessFailure(() => + { + Directory.Move(sourcePath, destPath); + }); + } + + /// + /// Tries to perform a file operation, returning false if it fails due to file locking. + /// + /// The action to attempt. + /// True if the action succeeded, false if it failed due to file locking. + public static bool TryFileOperation(Action action) + { + try + { + action(); + return true; + } + catch (IOException ex) when (IsFileLockedException(ex)) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + /// + /// Checks if an IOException is due to file locking. + /// + /// The exception to check. + /// True if the exception indicates the file is locked. + public static bool IsFileLockedException(IOException ex) + { + // Windows error codes for file locking + const int ERROR_SHARING_VIOLATION = 32; + const int ERROR_LOCK_VIOLATION = 33; + + var hResult = ex.HResult & 0xFFFF; + return hResult == ERROR_SHARING_VIOLATION || hResult == ERROR_LOCK_VIOLATION; + } +} diff --git a/src/Aspire.Cli/Utils/TransactionalAction.cs b/src/Aspire.Cli/Utils/TransactionalAction.cs new file mode 100644 index 00000000000..c40c16af31f --- /dev/null +++ b/src/Aspire.Cli/Utils/TransactionalAction.cs @@ -0,0 +1,151 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Based on dotnet/sdk src/Cli/dotnet/TransactionalAction.cs + +using System.Runtime.CompilerServices; +using System.Transactions; + +namespace Aspire.Cli.Utils; + +/// +/// Provides transactional file operations with automatic rollback on failure. +/// Based on patterns from dotnet/sdk. +/// +public sealed class TransactionalAction +{ + static TransactionalAction() + { + DisableTransactionTimeoutUpperLimit(); + } + + private class EnlistmentNotification(Action? commit, Action? rollback) : IEnlistmentNotification + { + private Action? _commit = commit; + private Action? _rollback = rollback; + + public void Commit(Enlistment enlistment) + { + if (_commit != null) + { + _commit(); + _commit = null; + } + + enlistment.Done(); + } + + public void InDoubt(Enlistment enlistment) + { + Rollback(enlistment); + } + + public void Prepare(PreparingEnlistment enlistment) + { + enlistment.Prepared(); + } + + public void Rollback(Enlistment enlistment) + { + if (_rollback != null) + { + _rollback(); + _rollback = null; + } + + enlistment.Done(); + } + } + + /// + /// Runs an action with transactional semantics. + /// If the action throws, the rollback action is executed. + /// + /// Return type. + /// The action to perform. + /// Optional action to run on successful commit. + /// Optional action to run on rollback. + /// The result of the action. + public static T Run( + Func action, + Action? commit = null, + Action? rollback = null) + { + ArgumentNullException.ThrowIfNull(action); + + // This automatically inherits any ambient transaction + // If a transaction is inherited, completing this scope will be a no-op + T result = default!; + try + { + using var scope = new TransactionScope( + TransactionScopeOption.Required, + TimeSpan.Zero); + + Transaction.Current!.EnlistVolatile( + new EnlistmentNotification(commit, rollback), + EnlistmentOptions.None); + + result = action(); + + scope.Complete(); + + return result; + } + catch (TransactionAbortedException) + { + throw; + } + } + + /// + /// AOT-compatible accessor for TransactionManager.s_cachedMaxTimeout private field. + /// This is a workaround for https://github.com/dotnet/sdk/issues/21101. + /// Uses UnsafeAccessorType (.NET 10+) to access static fields on static classes. + /// + [UnsafeAccessor(UnsafeAccessorKind.StaticField, Name = "s_cachedMaxTimeout")] + private static extern ref bool CachedMaxTimeoutField( + [UnsafeAccessorType("System.Transactions.TransactionManager, System.Transactions.Local")] object? manager); + + /// + /// AOT-compatible accessor for TransactionManager.s_maximumTimeout private field. + /// This is a workaround for https://github.com/dotnet/sdk/issues/21101. + /// Uses UnsafeAccessorType (.NET 10+) to access static fields on static classes. + /// + [UnsafeAccessor(UnsafeAccessorKind.StaticField, Name = "s_maximumTimeout")] + private static extern ref TimeSpan MaximumTimeoutField( + [UnsafeAccessorType("System.Transactions.TransactionManager, System.Transactions.Local")] object? manager); + + /// + /// Disables the transaction timeout upper limit using AOT-compatible accessors. + /// This is a workaround for https://github.com/dotnet/sdk/issues/21101. + /// Use the proper API once available. + /// + public static void DisableTransactionTimeoutUpperLimit() + { + CachedMaxTimeoutField(null) = true; + MaximumTimeoutField(null) = TimeSpan.Zero; + } + + /// + /// Runs an action with transactional semantics. + /// If the action throws, the rollback action is executed. + /// + /// The action to perform. + /// Optional action to run on successful commit. + /// Optional action to run on rollback. + public static void Run( + Action action, + Action? commit = null, + Action? rollback = null) + { + Run( + action: () => + { + action(); + return null; + }, + commit: commit, + rollback: rollback); + } +} diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index fa1447d8f10..c2502681dcc 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -25,6 +25,9 @@ See https://devblogs.microsoft.com/dotnet/enhancing-razor-productivity-with-new-features/ --> use-roslyn-tokenizer + + + true diff --git a/src/Aspire.Hosting.RemoteHost/Aspire.Hosting.RemoteHost.csproj b/src/Aspire.Hosting.RemoteHost/Aspire.Hosting.RemoteHost.csproj index 430ab86e470..1184e98c9aa 100644 --- a/src/Aspire.Hosting.RemoteHost/Aspire.Hosting.RemoteHost.csproj +++ b/src/Aspire.Hosting.RemoteHost/Aspire.Hosting.RemoteHost.csproj @@ -1,14 +1,36 @@ - $(DefaultTargetFramework) - true + Exe + net10.0 enable enable + aspire-server Remote host server for polyglot Aspire AppHosts. aspire hosting polyglot + + + true false + + + false + false + + + $(NoWarn);IL3000 + + + Major + + + none + false + + + true + true @@ -24,4 +46,9 @@ + + + + + diff --git a/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs b/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs index 7c6ea4237af..b2e9dda9120 100644 --- a/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs +++ b/src/Aspire.Hosting.RemoteHost/AssemblyLoader.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Reflection; +using System.Runtime.Loader; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -13,14 +14,41 @@ namespace Aspire.Hosting.RemoteHost; internal sealed class AssemblyLoader { private readonly Lazy> _assemblies; + private readonly string? _integrationLibsPath; public AssemblyLoader(IConfiguration configuration, ILogger logger) { + // ASPIRE_INTEGRATION_LIBS_PATH is set by the CLI when running guest (polyglot) apphosts + // that require additional hosting integration packages. See docs/specs/bundle.md for details. + var libsPath = configuration["ASPIRE_INTEGRATION_LIBS_PATH"]; + if (!string.IsNullOrEmpty(libsPath) && Directory.Exists(libsPath)) + { + _integrationLibsPath = libsPath; + AssemblyLoadContext.Default.Resolving += ResolveAssemblyFromIntegrationLibs; + logger.LogDebug("Registered assembly resolver for integration libs at {Path}", libsPath); + } + _assemblies = new Lazy>(() => LoadAssemblies(configuration, logger)); } public IReadOnlyList GetAssemblies() => _assemblies.Value; + private Assembly? ResolveAssemblyFromIntegrationLibs(AssemblyLoadContext context, AssemblyName assemblyName) + { + if (_integrationLibsPath is null || assemblyName.Name is null) + { + return null; + } + + var assemblyPath = Path.Combine(_integrationLibsPath, $"{assemblyName.Name}.dll"); + if (File.Exists(assemblyPath)) + { + return context.LoadFromAssemblyPath(assemblyPath); + } + + return null; + } + private static List LoadAssemblies(IConfiguration configuration, ILogger logger) { var assemblyNames = configuration.GetSection("AtsAssemblies").Get() ?? []; diff --git a/src/Aspire.Hosting.RemoteHost/Program.cs b/src/Aspire.Hosting.RemoteHost/Program.cs new file mode 100644 index 00000000000..5018057dae5 --- /dev/null +++ b/src/Aspire.Hosting.RemoteHost/Program.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// This is the entry point for the pre-built AppHost server used in bundle mode. +// It runs the RemoteHostServer which listens on a Unix socket for JSON-RPC +// connections from polyglot app hosts (TypeScript, Python, etc.) + +await Aspire.Hosting.RemoteHost.RemoteHostServer.RunAsync(args).ConfigureAwait(false); diff --git a/src/Aspire.Hosting.RemoteHost/appsettings.json b/src/Aspire.Hosting.RemoteHost/appsettings.json new file mode 100644 index 00000000000..7baa03ac9c2 --- /dev/null +++ b/src/Aspire.Hosting.RemoteHost/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + }, + "AtsAssemblies": [ + "Aspire.Hosting" + ] +} diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index d112cec4833..6d65b91a9d4 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -4,13 +4,14 @@ $(DefaultTargetFramework) $(DefineConstants);ASPIRE_EVENTSOURCE true - $(NoWarn);CS8002 + $(NoWarn);CS8002;IL3000 true aspire hosting orchestration Core abstractions for the Aspire application model. + @@ -118,6 +119,7 @@ + diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 8d714d230b6..90e0bf54cc5 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -17,6 +17,7 @@ using Aspire.Hosting.Eventing; using Aspire.Hosting.Lifecycle; using Aspire.Hosting.Utils; +using Aspire.Shared; using Aspire.Shared.ConsoleLogs; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; @@ -290,60 +291,64 @@ private void AddDashboardResource(DistributedApplicationModel model) // Create custom runtime config with AppHost's framework versions var customRuntimeConfigPath = CreateCustomRuntimeConfig(fullyQualifiedDashboardPath); - // Find the dashboard DLL path - string dashboardDll; - if (string.Equals(".dll", Path.GetExtension(fullyQualifiedDashboardPath), StringComparison.OrdinalIgnoreCase)) - { - // Dashboard path is already a DLL - dashboardDll = fullyQualifiedDashboardPath; + // Determine if this is a single-file executable or DLL-based deployment + // Single-file: run the exe directly with custom runtime config + // DLL-based: run via dotnet exec + var isSingleFileExe = IsSingleFileExecutable(fullyQualifiedDashboardPath); + + ExecutableResource dashboardResource; + + if (isSingleFileExe) + { + // Single-file executable - run directly + dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, fullyQualifiedDashboardPath, dashboardWorkingDirectory ?? ""); + + // Set DOTNET_ROOT so the single-file app can find the shared framework + var dotnetRoot = BundleDiscovery.GetDotNetRoot(); + if (!string.IsNullOrEmpty(dotnetRoot)) + { + dashboardResource.Annotations.Add(new EnvironmentCallbackAnnotation(env => + { + env["DOTNET_ROOT"] = dotnetRoot; + env["DOTNET_MULTILEVEL_LOOKUP"] = "0"; + })); + } } else { - // For executables, the corresponding DLL is named after the base executable name - // Handle Windows (.exe), Unix (no extension), and direct DLL cases - var directory = Path.GetDirectoryName(fullyQualifiedDashboardPath)!; - var fileName = Path.GetFileName(fullyQualifiedDashboardPath); - - string baseName; - if (fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) - { - // Windows executable: remove .exe extension - baseName = fileName.Substring(0, fileName.Length - 4); - } - else if (fileName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + // DLL-based deployment - find the DLL and run via dotnet exec + string dashboardDll; + if (string.Equals(".dll", Path.GetExtension(fullyQualifiedDashboardPath), StringComparison.OrdinalIgnoreCase)) { - // Already a DLL: use as-is dashboardDll = fullyQualifiedDashboardPath; - if (!File.Exists(dashboardDll)) - { - distributedApplicationLogger.LogError("Dashboard DLL not found: {Path}", dashboardDll); - } - return; } else { - // Unix executable (no extension) or other: use full filename as base - baseName = fileName; + // For executables with separate DLLs + var directory = Path.GetDirectoryName(fullyQualifiedDashboardPath)!; + var fileName = Path.GetFileName(fullyQualifiedDashboardPath); + var baseName = fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? fileName.Substring(0, fileName.Length - 4) + : fileName; + dashboardDll = Path.Combine(directory, $"{baseName}.dll"); } - dashboardDll = Path.Combine(directory, $"{baseName}.dll"); - if (!File.Exists(dashboardDll)) { distributedApplicationLogger.LogError("Dashboard DLL not found: {Path}", dashboardDll); } - } - // Always use dotnet exec with the custom runtime config - var dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, "dotnet", dashboardWorkingDirectory ?? ""); + var dotnetExecutable = BundleDiscovery.GetDotNetExecutablePath(); + dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, dotnetExecutable, dashboardWorkingDirectory ?? ""); - dashboardResource.Annotations.Add(new CommandLineArgsCallbackAnnotation(args => - { - args.Add("exec"); - args.Add("--runtimeconfig"); - args.Add(customRuntimeConfigPath); - args.Add(dashboardDll); - })); + dashboardResource.Annotations.Add(new CommandLineArgsCallbackAnnotation(args => + { + args.Add("exec"); + args.Add("--runtimeconfig"); + args.Add(customRuntimeConfigPath); + args.Add(dashboardDll); + })); + } nameGenerator.EnsureDcpInstancesPopulated(dashboardResource); @@ -921,6 +926,50 @@ public async ValueTask DisposeAsync() } } } + + /// + /// Determines if the given path is a single-file executable (no accompanying DLL). + /// + private static bool IsSingleFileExecutable(string path) + { + // Single-file apps are executables without a corresponding DLL + var extension = Path.GetExtension(path); + + // Must be an exe (Windows) or no extension (Unix) + if (!extension.Equals(".exe", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(extension)) + { + return false; + } + + // The executable itself must exist to be considered a single-file exe + if (!File.Exists(path)) + { + return false; + } + + // On Unix, verify the file is executable + if (!OperatingSystem.IsWindows()) + { + var fileInfo = new FileInfo(path); + // Check if file has any execute permission (owner, group, or other) + var mode = fileInfo.UnixFileMode; + if ((mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) == 0) + { + return false; + } + } + + // Check if there's a corresponding DLL + var directory = Path.GetDirectoryName(path)!; + var fileName = Path.GetFileName(path); + var baseName = fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? fileName.Substring(0, fileName.Length - 4) + : fileName; + var dllPath = Path.Combine(directory, $"{baseName}.dll"); + + // If no DLL exists alongside the executable, it's a single-file executable + return !File.Exists(dllPath); + } } internal sealed class DashboardLogMessage diff --git a/src/Aspire.Hosting/Dcp/DcpOptions.cs b/src/Aspire.Hosting/Dcp/DcpOptions.cs index 4cb636d9677..fd2191c96ca 100644 --- a/src/Aspire.Hosting/Dcp/DcpOptions.cs +++ b/src/Aspire.Hosting/Dcp/DcpOptions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Reflection; +using Aspire.Shared; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; @@ -146,9 +147,20 @@ public void Configure(DcpOptions options) var dcpPublisherConfiguration = configuration.GetSection(DcpPublisher); var assemblyMetadata = appOptions.Assembly?.GetCustomAttributes(); - if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(options.CliPath)])) + // Priority 1: Check configuration first (env vars are automatically bound via IConfiguration) + // BundleDiscovery env vars: ASPIRE_DCP_PATH, ASPIRE_DASHBOARD_PATH + var configDcpPath = configuration[BundleDiscovery.DcpPathEnvVar]; + var configDashboardPath = configuration[BundleDiscovery.DashboardPathEnvVar]; + + if (!string.IsNullOrEmpty(configDcpPath)) + { + // Configuration/environment variable override - set DCP paths from bundle + options.CliPath = BundleDiscovery.GetDcpExecutablePath(configDcpPath); + options.ExtensionsPath = Path.Combine(configDcpPath, "ext"); + } + else if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(options.CliPath)])) { - // If an explicit path to DCP was provided from configuration, don't try to resolve via assembly attributes + // If an explicit path to DCP was provided from configuration options.CliPath = dcpPublisherConfiguration[nameof(options.CliPath)]; if (Path.GetDirectoryName(options.CliPath) is string dcpDir && !string.IsNullOrEmpty(dcpDir)) { @@ -157,17 +169,24 @@ public void Configure(DcpOptions options) } else { + // Resolve via assembly metadata attributes (NuGet packages) options.CliPath = GetMetadataValue(assemblyMetadata, DcpCliPathMetadataKey); options.ExtensionsPath = GetMetadataValue(assemblyMetadata, DcpExtensionsPathMetadataKey); } - if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(options.DashboardPath)])) + if (!string.IsNullOrEmpty(configDashboardPath)) + { + // Configuration/environment variable override - set Dashboard path from bundle + options.DashboardPath = configDashboardPath; + } + else if (!string.IsNullOrEmpty(dcpPublisherConfiguration[nameof(options.DashboardPath)])) { - // If an explicit path to DCP was provided from configuration, don't try to resolve via assembly attributes + // If an explicit path to Dashboard was provided from configuration options.DashboardPath = dcpPublisherConfiguration[nameof(options.DashboardPath)]; } else { + // Resolve via assembly metadata attributes (NuGet packages) options.DashboardPath = GetMetadataValue(assemblyMetadata, DashboardPathMetadataKey); } diff --git a/src/Shared/BundleDiscovery.cs b/src/Shared/BundleDiscovery.cs new file mode 100644 index 00000000000..726b22d0e98 --- /dev/null +++ b/src/Shared/BundleDiscovery.cs @@ -0,0 +1,501 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// This file is source-linked into multiple projects: +// - Aspire.Hosting +// - Aspire.Cli +// Do not add project-specific dependencies. + +using System.Runtime.InteropServices; + +namespace Aspire.Shared; + +/// +/// Shared logic for discovering Aspire bundle components. +/// Used by both CLI and Aspire.Hosting to ensure consistent discovery behavior. +/// +internal static class BundleDiscovery +{ + // ═══════════════════════════════════════════════════════════════════════ + // ENVIRONMENT VARIABLE CONSTANTS + // ═══════════════════════════════════════════════════════════════════════ + + /// + /// Environment variable for the root of the bundle layout. + /// + public const string LayoutPathEnvVar = "ASPIRE_LAYOUT_PATH"; + + /// + /// Environment variable for overriding the DCP path. + /// + public const string DcpPathEnvVar = "ASPIRE_DCP_PATH"; + + /// + /// Environment variable for overriding the Dashboard path. + /// + public const string DashboardPathEnvVar = "ASPIRE_DASHBOARD_PATH"; + + /// + /// Environment variable for overriding the .NET runtime path. + /// + public const string RuntimePathEnvVar = "ASPIRE_RUNTIME_PATH"; + + /// + /// Environment variable for overriding the AppHost Server path. + /// + public const string AppHostServerPathEnvVar = "ASPIRE_APPHOST_SERVER_PATH"; + + /// + /// Environment variable to force SDK mode (skip bundle detection). + /// + public const string UseGlobalDotNetEnvVar = "ASPIRE_USE_GLOBAL_DOTNET"; + + /// + /// Environment variable indicating development mode (Aspire repo checkout). + /// + public const string RepoRootEnvVar = "ASPIRE_REPO_ROOT"; + + // ═══════════════════════════════════════════════════════════════════════ + // BUNDLE LAYOUT DIRECTORY NAMES + // ═══════════════════════════════════════════════════════════════════════ + + /// + /// Directory name for DCP in the bundle layout. + /// + public const string DcpDirectoryName = "dcp"; + + /// + /// Directory name for Dashboard in the bundle layout. + /// + public const string DashboardDirectoryName = "dashboard"; + + /// + /// Directory name for .NET runtime in the bundle layout. + /// + public const string RuntimeDirectoryName = "runtime"; + + /// + /// Directory name for AppHost Server in the bundle layout. + /// + public const string AppHostServerDirectoryName = "aspire-server"; + + /// + /// Directory name for NuGet Helper tool in the bundle layout. + /// + public const string NuGetHelperDirectoryName = "tools/aspire-nuget"; + + /// + /// Directory name for dev-certs tool in the bundle layout. + /// + public const string DevCertsDirectoryName = "tools/dev-certs"; + + // ═══════════════════════════════════════════════════════════════════════ + // EXECUTABLE NAMES (without path, just the file name) + // ═══════════════════════════════════════════════════════════════════════ + + /// + /// Executable name for the AppHost Server. + /// + public const string AppHostServerExecutableName = "aspire-server"; + + /// + /// Executable name for the Dashboard. + /// + public const string DashboardExecutableName = "Aspire.Dashboard"; + + /// + /// Executable name for the NuGet Helper tool. + /// + public const string NuGetHelperExecutableName = "aspire-nuget"; + + /// + /// Executable name for the dev-certs tool. + /// + public const string DevCertsExecutableName = "dotnet-dev-certs"; + + // ═══════════════════════════════════════════════════════════════════════ + // DISCOVERY METHODS + // ═══════════════════════════════════════════════════════════════════════ + + /// + /// Attempts to discover DCP from a base directory. + /// Checks for the expected bundle layout structure. + /// + /// The base directory to search from (e.g., CLI location or entry assembly directory). + /// The full path to the DCP executable if found. + /// The full path to the DCP extensions directory if found. + /// The full path to the DCP bin directory if found. + /// True if DCP was found, false otherwise. + public static bool TryDiscoverDcpFromDirectory( + string baseDirectory, + out string? dcpCliPath, + out string? dcpExtensionsPath, + out string? dcpBinPath) + { + dcpCliPath = null; + dcpExtensionsPath = null; + dcpBinPath = null; + + if (string.IsNullOrEmpty(baseDirectory) || !Directory.Exists(baseDirectory)) + { + return false; + } + + var dcpDir = Path.Combine(baseDirectory, DcpDirectoryName); + var dcpExePath = GetDcpExecutablePath(dcpDir); + + if (File.Exists(dcpExePath)) + { + dcpCliPath = dcpExePath; + dcpExtensionsPath = Path.Combine(dcpDir, "ext"); + dcpBinPath = Path.Combine(dcpExtensionsPath, "bin"); + return true; + } + + return false; + } + + /// + /// Attempts to discover Dashboard from a base directory. + /// + /// The base directory to search from. + /// The full path to the Dashboard directory if found. + /// True if Dashboard was found, false otherwise. + public static bool TryDiscoverDashboardFromDirectory( + string baseDirectory, + out string? dashboardPath) + { + dashboardPath = null; + + if (string.IsNullOrEmpty(baseDirectory) || !Directory.Exists(baseDirectory)) + { + return false; + } + + var dashboardDir = Path.Combine(baseDirectory, DashboardDirectoryName); + var dashboardExe = Path.Combine(dashboardDir, GetExecutableFileName(DashboardExecutableName)); + + if (File.Exists(dashboardExe)) + { + dashboardPath = dashboardDir; + return true; + } + + return false; + } + + /// + /// Attempts to discover DCP relative to the entry assembly. + /// This is used by Aspire.Hosting when no environment variables are set. + /// + public static bool TryDiscoverDcpFromEntryAssembly( + out string? dcpCliPath, + out string? dcpExtensionsPath, + out string? dcpBinPath) + { + dcpCliPath = null; + dcpExtensionsPath = null; + dcpBinPath = null; + + var baseDir = GetEntryAssemblyDirectory(); + if (baseDir is null) + { + return false; + } + + return TryDiscoverDcpFromDirectory(baseDir, out dcpCliPath, out dcpExtensionsPath, out dcpBinPath); + } + + /// + /// Attempts to discover Dashboard relative to the entry assembly. + /// This is used by Aspire.Hosting when no environment variables are set. + /// + public static bool TryDiscoverDashboardFromEntryAssembly(out string? dashboardPath) + { + dashboardPath = null; + + var baseDir = GetEntryAssemblyDirectory(); + if (baseDir is null) + { + return false; + } + + return TryDiscoverDashboardFromDirectory(baseDir, out dashboardPath); + } + + /// + /// Attempts to discover .NET runtime from a base directory. + /// Checks for the expected bundle layout structure with dotnet executable. + /// + /// The base directory to search from. + /// The full path to the runtime directory if found. + /// True if runtime was found, false otherwise. + public static bool TryDiscoverRuntimeFromDirectory(string baseDirectory, out string? runtimePath) + { + runtimePath = null; + + if (string.IsNullOrEmpty(baseDirectory) || !Directory.Exists(baseDirectory)) + { + return false; + } + + var runtimeDir = Path.Combine(baseDirectory, RuntimeDirectoryName); + var dotnetPath = Path.Combine(runtimeDir, GetDotNetExecutableName()); + + if (File.Exists(dotnetPath)) + { + runtimePath = runtimeDir; + return true; + } + + return false; + } + + /// + /// Attempts to discover .NET runtime relative to the entry assembly. + /// This is used by Aspire.Hosting when no environment variables are set. + /// + public static bool TryDiscoverRuntimeFromEntryAssembly(out string? runtimePath) + { + runtimePath = null; + + var baseDir = GetEntryAssemblyDirectory(); + if (baseDir is null) + { + return false; + } + + return TryDiscoverRuntimeFromDirectory(baseDir, out runtimePath); + } + + /// + /// Attempts to discover DCP relative to the current process. + /// This is used by CLI to find DCP in the bundle layout. + /// + public static bool TryDiscoverDcpFromProcessPath( + out string? dcpCliPath, + out string? dcpExtensionsPath, + out string? dcpBinPath) + { + dcpCliPath = null; + dcpExtensionsPath = null; + dcpBinPath = null; + + var baseDir = GetProcessDirectory(); + if (baseDir is null) + { + return false; + } + + return TryDiscoverDcpFromDirectory(baseDir, out dcpCliPath, out dcpExtensionsPath, out dcpBinPath); + } + + /// + /// Attempts to discover Dashboard relative to the current process. + /// + public static bool TryDiscoverDashboardFromProcessPath(out string? dashboardPath) + { + dashboardPath = null; + + var baseDir = GetProcessDirectory(); + if (baseDir is null) + { + return false; + } + + return TryDiscoverDashboardFromDirectory(baseDir, out dashboardPath); + } + + /// + /// Attempts to discover .NET runtime relative to the current process. + /// + public static bool TryDiscoverRuntimeFromProcessPath(out string? runtimePath) + { + runtimePath = null; + + var baseDir = GetProcessDirectory(); + if (baseDir is null) + { + return false; + } + + return TryDiscoverRuntimeFromDirectory(baseDir, out runtimePath); + } + + // ═══════════════════════════════════════════════════════════════════════ + // HELPER METHODS + // ═══════════════════════════════════════════════════════════════════════ + + /// + /// Gets the full path to the DCP executable given a DCP directory. + /// + public static string GetDcpExecutablePath(string dcpDirectory) + { + var exeName = GetDcpExecutableName(); + return Path.Combine(dcpDirectory, exeName); + } + + /// + /// Gets the platform-specific DCP executable name. + /// + public static string GetDcpExecutableName() + { + return OperatingSystem.IsWindows() ? "dcp.exe" : "dcp"; + } + + /// + /// Gets the platform-specific dotnet executable name. + /// + public static string GetDotNetExecutableName() + { + return OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + } + + /// + /// Gets the platform-specific executable name with extension. + /// + /// The base executable name without extension (e.g., "aspire-server"). + /// The executable name with platform-appropriate extension. + public static string GetExecutableFileName(string baseName) + { + return OperatingSystem.IsWindows() ? $"{baseName}.exe" : baseName; + } + + /// + /// Gets the platform-specific DLL name. + /// + /// The base name without extension (e.g., "aspire-server"). + /// The DLL name (e.g., "aspire-server.dll"). + public static string GetDllFileName(string baseName) + { + return $"{baseName}.dll"; + } + + /// + /// Gets the full path to the dotnet executable from the bundled runtime, or "dotnet" if not available. + /// Resolution order: environment variable → disk discovery → PATH fallback. + /// + /// Full path to bundled dotnet executable, or "dotnet" to use PATH resolution. + public static string GetDotNetExecutablePath() + { + // 1. Check environment variable (set by CLI for guest apphosts) + var runtimePath = Environment.GetEnvironmentVariable(RuntimePathEnvVar); + if (!string.IsNullOrEmpty(runtimePath)) + { + var dotnetPath = Path.Combine(runtimePath, GetDotNetExecutableName()); + if (File.Exists(dotnetPath)) + { + return dotnetPath; + } + } + + // 2. Try disk discovery (for future installed bundle scenario) + if (TryDiscoverRuntimeFromEntryAssembly(out var discoveredRuntimePath) && discoveredRuntimePath is not null) + { + var dotnetPath = Path.Combine(discoveredRuntimePath, GetDotNetExecutableName()); + if (File.Exists(dotnetPath)) + { + return dotnetPath; + } + } + + // 3. Fall back to PATH-based resolution + return "dotnet"; + } + + /// + /// Gets the DOTNET_ROOT path for the bundled runtime. + /// This is the directory containing the dotnet executable and shared frameworks. + /// + /// The DOTNET_ROOT path if available, otherwise null. + public static string? GetDotNetRoot() + { + // 1. Check environment variable (set by CLI for guest apphosts) + var runtimePath = Environment.GetEnvironmentVariable(RuntimePathEnvVar); + if (!string.IsNullOrEmpty(runtimePath) && Directory.Exists(runtimePath)) + { + return runtimePath; + } + + // 2. Try disk discovery (for future installed bundle scenario) + if (TryDiscoverRuntimeFromEntryAssembly(out var discoveredRuntimePath) && discoveredRuntimePath is not null) + { + return discoveredRuntimePath; + } + + return null; + } + + /// + /// Gets the current platform's runtime identifier. + /// + public static string GetCurrentRuntimeIdentifier() + { + var arch = RuntimeInformation.OSArchitecture switch + { + Architecture.X64 => "x64", + Architecture.X86 => "x86", + Architecture.Arm64 => "arm64", + Architecture.Arm => "arm", + _ => "x64" + }; + + if (OperatingSystem.IsWindows()) + { + return $"win-{arch}"; + } + + if (OperatingSystem.IsMacOS()) + { + return $"osx-{arch}"; + } + + if (OperatingSystem.IsLinux()) + { + return $"linux-{arch}"; + } + + return $"unknown-{arch}"; + } + + /// + /// Gets the archive extension for the current platform. + /// + public static string GetArchiveExtension() + { + return OperatingSystem.IsWindows() ? ".zip" : ".tar.gz"; + } + + /// + /// Gets the directory containing the entry assembly, if available. + /// For native AOT or single-file apps, uses AppContext.BaseDirectory or ProcessPath fallback. + /// + private static string? GetEntryAssemblyDirectory() + { + // For native AOT and single-file apps, Assembly.Location returns empty + // Use AppContext.BaseDirectory as the primary fallback + var baseDir = AppContext.BaseDirectory; + if (!string.IsNullOrEmpty(baseDir) && Directory.Exists(baseDir)) + { + // Remove trailing separator if present + return baseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + // Final fallback: try process path + return GetProcessDirectory(); + } + + /// + /// Gets the directory containing the current process executable. + /// + private static string? GetProcessDirectory() + { + var processPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(processPath)) + { + return null; + } + + return Path.GetDirectoryName(processPath); + } +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs new file mode 100644 index 00000000000..2a0bceb3862 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.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.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end tests for running .NET csproj AppHost projects using the Aspire bundle. +/// Validates that the bundle correctly provides DCP and Dashboard paths to the hosting +/// infrastructure when running SDK-based app hosts (not just polyglot/guest app hosts). +/// +public sealed class BundleSmokeTests(ITestOutputHelper output) +{ + [Fact] + public async Task CreateAndRunAspireStarterProjectWithBundle() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndRunAspireStarterProjectWithBundle)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find($"Enter the output path: (./BundleStarterApp): "); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find($"Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find($"Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find($"Do you want to create a test project?"); + + // Verify the dashboard is actually reachable, not just that the URL was printed. + // When the dashboard path bug was present, the URL appeared on screen but curling + // it returned connection refused because the dashboard process failed to start. + var waitForDashboardCurlSuccess = new CellPatternSearcher() + .Find("dashboard-http-200"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + // Install the full bundle (not just CLI) so that ASPIRE_LAYOUT_PATH is set. + // For .NET csproj app hosts, the hosting infrastructure resolves DCP and Dashboard + // paths through NuGet assembly metadata, NOT through bundle env vars. + sequenceBuilder.InstallAspireBundleFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireBundleEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() // select first template (Starter App) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Type("BundleStarterApp") + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter) + // Start AppHost in detached mode and capture JSON output + .Type("aspire run --detach --format json | tee /tmp/aspire-detach.json") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)) + // Verify the dashboard is reachable by extracting the URL from the detach output + // and curling it. Extract just the base URL (https://localhost:PORT) using sed, which is + // portable across macOS (BSD) and Linux (GNU) unlike grep -oP. + .Type("DASHBOARD_URL=$(sed -n 's/.*\"dashboardUrl\"[[:space:]]*:[[:space:]]*\"\\(https:\\/\\/localhost:[0-9]*\\).*/\\1/p' /tmp/aspire-detach.json | head -1)") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("curl -ksSL -o /dev/null -w 'dashboard-http-%{http_code}' \"$DASHBOARD_URL\" || echo 'dashboard-http-failed'") + .Enter() + .WaitUntil(s => waitForDashboardCurlSuccess.Search(s).Count > 0, TimeSpan.FromSeconds(15)) + .WaitForSuccessPrompt(counter) + // Clean up: use aspire stop to gracefully shut down the detached AppHost. + .Type("aspire stop") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index 072a525208d..9f8b7c9fe96 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -251,6 +251,8 @@ internal static Hex1bTerminalInputSequenceBuilder ExecuteCallback( /// /// Enables polyglot support feature flag using the aspire config set command. /// This allows the CLI to create TypeScript and Python AppHosts. + /// Uses the global (-g) flag to ensure the setting persists across CLI invocations, + /// even when aspire init creates a new local settings.json file. /// /// The sequence builder. /// The sequence counter for prompt detection. @@ -260,7 +262,7 @@ internal static Hex1bTerminalInputSequenceBuilder EnablePolyglotSupport( SequenceCounter counter) { return builder - .Type("aspire config set features.polyglotSupportEnabled true") + .Type("aspire config set features.polyglotSupportEnabled true -g") .Enter() .WaitForSuccessPrompt(counter); } @@ -452,4 +454,79 @@ internal static Hex1bTerminalInputSequenceBuilder VerifyFileDoesNotContain( } }); } + + /// + /// 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 bundle 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 bundle script from that SHA + command = $"$ref = (gh api repos/dotnet/aspire/pulls/{prNumber} --jq '.head.sha'); " + + $"iex \"& {{ $(irm https://raw.githubusercontent.com/dotnet/aspire/$ref/eng/scripts/get-aspire-cli-bundle-pr.ps1) }} {prNumber}\""; + } + else + { + // Bash: Get PR head SHA, then fetch and run bundle script from that SHA + command = $"ref=$(gh api repos/dotnet/aspire/pulls/{prNumber} --jq '.head.sha') && " + + $"curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/$ref/eng/scripts/get-aspire-cli-bundle-pr.sh | bash -s -- {prNumber}"; + } + + return builder + .Type(command) + .Enter() + .WaitForSuccessPrompt(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 + /// is discoverable regardless of which version of the install script ran + /// (the script is fetched from raw.githubusercontent.com which has CDN caching). + /// The CLI auto-discovers bundle components (runtime, dashboard, DCP, AppHost server) + /// in the parent directory via relative path resolution. + /// + /// The sequence builder. + /// The sequence counter for prompt detection. + /// The builder for chaining. + internal static Hex1bTerminalInputSequenceBuilder SourceAspireBundleEnvironment( + this Hex1bTerminalInputSequenceBuilder builder, + SequenceCounter counter) + { + if (OperatingSystem.IsWindows()) + { + // PowerShell environment setup for bundle + return builder + .Type("$env:PATH=\"$HOME\\.aspire\\bin;$HOME\\.aspire;$env:PATH\"; $env:ASPIRE_PLAYGROUND='true'; $env:DOTNET_CLI_TELEMETRY_OPTOUT='true'; $env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE='true'; $env:DOTNET_GENERATE_ASPNET_CERTIFICATE='false'") + .Enter() + .WaitForSuccessPrompt(counter); + } + + // Bash environment setup for bundle + // Add both ~/.aspire/bin (new layout) and ~/.aspire (old layout) to PATH + // The install script is downloaded from raw.githubusercontent.com which has CDN caching, + // so the old version may still be served for a while after push. + return builder + .Type("export PATH=~/.aspire/bin:~/.aspire:$PATH ASPIRE_PLAYGROUND=true TERM=xterm DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false") + .Enter() + .WaitForSuccessPrompt(counter); + } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs index 1a94f2b127d..348df5ee732 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs @@ -51,19 +51,6 @@ public async Task CreateTypeScriptAppHostWithViteApp() var waitingForPackageAdded = new CellPatternSearcher() .Find("The package Aspire.Hosting.JavaScript::"); - // In CI, aspire add shows a version selection prompt (but aspire new does not when channel is set) - var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() - .Find("Select a version of Aspire.Hosting.JavaScript"); - - // Pattern to confirm PR version is selected - var waitingForPrVersionSelected = new CellPatternSearcher() - .Find($"> pr-{prNumber}"); - - // Pattern to confirm specific version with short SHA is selected (e.g., "> 9.3.0-dev.g1234567") - var shortSha = commitSha[..7]; // First 7 characters of commit SHA - var waitingForShaVersionSelected = new CellPatternSearcher() - .Find($"g{shortSha}"); - // Pattern for aspire run ready var waitForCtrlCMessage = new CellPatternSearcher() .Find("Press CTRL+C to stop the apphost and exit."); @@ -75,8 +62,10 @@ public async Task CreateTypeScriptAppHostWithViteApp() if (isCI) { - sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); - sequenceBuilder.SourceAspireCliEnvironment(counter); + // Polyglot tests require the bundle (not just CLI) because the AppHost server + // is bundled and cannot be obtained via NuGet packages in SDK-based fallback mode + sequenceBuilder.InstallAspireBundleFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireBundleEnvironment(counter); sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); } @@ -111,23 +100,11 @@ public async Task CreateTypeScriptAppHostWithViteApp() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); // Step 4: Add Aspire.Hosting.JavaScript package + // When channel is set (CI) and there's only one channel with one version, + // the version is auto-selected without prompting. sequenceBuilder .Type("aspire add Aspire.Hosting.JavaScript") - .Enter(); - - // In CI, aspire add shows a version selection prompt (unlike aspire new which auto-selects when channel is set) - if (isCI) - { - // First prompt: Select the PR channel (pr-XXXXX) - sequenceBuilder - .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) - .WaitUntil(s => waitingForPrVersionSelected.Search(s).Count > 0, TimeSpan.FromSeconds(5)) - .Enter() // select PR channel - .WaitUntil(s => waitingForShaVersionSelected.Search(s).Count > 0, TimeSpan.FromSeconds(10)) - .Enter(); - } - - sequenceBuilder + .Enter() .WaitUntil(s => waitingForPackageAdded.Search(s).Count > 0, TimeSpan.FromMinutes(2)) .WaitForSuccessPrompt(counter); diff --git a/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj b/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj index b43d26fbdc8..421fde25b5e 100644 --- a/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj +++ b/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj @@ -19,6 +19,7 @@ + diff --git a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs new file mode 100644 index 00000000000..11bbdd1f332 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs @@ -0,0 +1,130 @@ +// 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.Backchannel; + +namespace Aspire.Cli.Tests.Backchannel; + +public class ResourceSnapshotMapperTests +{ + [Fact] + public void MapToResourceJson_WithNullCollectionProperties_DoesNotThrow() + { + // Arrange - simulate a snapshot deserialized from JSON where collection properties are null + var snapshot = new ResourceSnapshot + { + Name = "test-resource", + DisplayName = "test-resource", + ResourceType = "Project", + State = "Running", + Urls = null!, + Volumes = null!, + HealthReports = null!, + EnvironmentVariables = null!, + Properties = null!, + Relationships = null!, + Commands = null! + }; + + var allSnapshots = new List { snapshot }; + + // Act & Assert - should not throw NullReferenceException + var result = ResourceSnapshotMapper.MapToResourceJson(snapshot, allSnapshots); + + Assert.NotNull(result); + Assert.Equal("test-resource", result.Name); + Assert.Empty(result.Urls!); + Assert.Empty(result.Volumes!); + Assert.Empty(result.HealthReports!); + Assert.Empty(result.Environment!); + Assert.Empty(result.Properties!); + Assert.Empty(result.Relationships!); + Assert.Empty(result.Commands!); + } + + [Fact] + public void MapToResourceJson_WithPopulatedProperties_MapsCorrectly() + { + // Arrange + var snapshot = new ResourceSnapshot + { + Name = "frontend", + DisplayName = "frontend", + ResourceType = "Project", + State = "Running", + Urls = + [ + new ResourceSnapshotUrl { Name = "http", Url = "http://localhost:5000" } + ], + Commands = + [ + new ResourceSnapshotCommand { Name = "resource-stop", State = "Enabled", Description = "Stop" }, + new ResourceSnapshotCommand { Name = "resource-start", State = "Disabled", Description = "Start" } + ], + EnvironmentVariables = + [ + new ResourceSnapshotEnvironmentVariable { Name = "ASPNETCORE_ENVIRONMENT", Value = "Development", IsFromSpec = true }, + new ResourceSnapshotEnvironmentVariable { Name = "INTERNAL_VAR", Value = "hidden", IsFromSpec = false } + ] + }; + + var allSnapshots = new List { snapshot }; + + // Act + var result = ResourceSnapshotMapper.MapToResourceJson(snapshot, allSnapshots, dashboardBaseUrl: "http://localhost:18080"); + + // Assert + Assert.Equal("frontend", result.Name); + Assert.Single(result.Urls!); + Assert.Equal("http://localhost:5000", result.Urls![0].Url); + + // Only enabled commands should be included + Assert.Single(result.Commands!); + Assert.Equal("resource-stop", result.Commands![0].Name); + + // Only IsFromSpec environment variables should be included + Assert.Single(result.Environment!); + Assert.Equal("ASPNETCORE_ENVIRONMENT", result.Environment![0].Name); + + // Dashboard URL should be generated + Assert.NotNull(result.DashboardUrl); + Assert.Contains("localhost:18080", result.DashboardUrl); + } + + [Fact] + public void MapToResourceJsonList_WithNullCollectionProperties_DoesNotThrow() + { + // Arrange + var snapshots = new List + { + new ResourceSnapshot + { + Name = "resource1", + DisplayName = "resource1", + ResourceType = "Project", + State = "Running", + Urls = null!, + Volumes = null!, + HealthReports = null!, + EnvironmentVariables = null!, + Properties = null!, + Relationships = null!, + Commands = null! + }, + new ResourceSnapshot + { + Name = "resource2", + DisplayName = "resource2", + ResourceType = "Container", + State = "Starting" + } + }; + + // Act & Assert - should not throw + var result = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardBaseUrl: "http://localhost:18080"); + + Assert.Equal(2, result.Count); + Assert.Equal("resource1", result[0].Name); + Assert.Equal("resource2", result[1].Name); + } +} diff --git a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs index 31ddae68a91..892a901813d 100644 --- a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs @@ -4,7 +4,6 @@ using System.Runtime.InteropServices; using Aspire.Cli.Certificates; using Aspire.Cli.DotNet; -using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.InternalTesting; @@ -19,27 +18,27 @@ public async Task EnsureCertificatesTrustedAsync_WithFullyTrustedCert_ReturnsEmp using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.DotNetCliRunnerFactory = sp => + options.CertificateToolRunnerFactory = sp => { - var runner = new TestDotNetCliRunner(); - runner.CheckHttpCertificateMachineReadableAsyncCallback = (_, _) => + return new TestCertificateToolRunner { - return (0, new CertificateTrustResult + CheckHttpCertificateMachineReadableAsyncCallback = (_, _) => { - HasCertificates = true, - TrustLevel = DevCertTrustLevel.Full, - Certificates = [new DevCertInfo { Version = 5, TrustLevel = DevCertTrustLevel.Full, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] - }); + return (0, new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = DevCertTrustLevel.Full, + Certificates = [new DevCertInfo { Version = 5, TrustLevel = DevCertTrustLevel.Full, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] + }); + } }; - return runner; }; }); var sp = services.BuildServiceProvider(); var cs = sp.GetRequiredService(); - var runner = sp.GetRequiredService(); - var result = await cs.EnsureCertificatesTrustedAsync(runner, TestContext.Current.CancellationToken).DefaultTimeout(); + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); Assert.NotNull(result); Assert.Empty(result.EnvironmentVariables); @@ -53,44 +52,44 @@ public async Task EnsureCertificatesTrustedAsync_WithNotTrustedCert_RunsTrustOpe var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.DotNetCliRunnerFactory = sp => + options.CertificateToolRunnerFactory = sp => { - var runner = new TestDotNetCliRunner(); var callCount = 0; - runner.CheckHttpCertificateMachineReadableAsyncCallback = (_, _) => + return new TestCertificateToolRunner { - callCount++; - // First call returns not trusted, second call (after trust) returns fully trusted - if (callCount == 1) + CheckHttpCertificateMachineReadableAsyncCallback = (_, _) => { + callCount++; + // First call returns not trusted, second call (after trust) returns fully trusted + if (callCount == 1) + { + return (0, new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = DevCertTrustLevel.None, + Certificates = [new DevCertInfo { Version = 5, TrustLevel = DevCertTrustLevel.None, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] + }); + } return (0, new CertificateTrustResult { HasCertificates = true, - TrustLevel = DevCertTrustLevel.None, - Certificates = [new DevCertInfo { Version = 5, TrustLevel = DevCertTrustLevel.None, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] + TrustLevel = DevCertTrustLevel.Full, + Certificates = [new DevCertInfo { Version = 5, TrustLevel = DevCertTrustLevel.Full, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] }); - } - return (0, new CertificateTrustResult + }, + TrustHttpCertificateAsyncCallback = (_, _) => { - HasCertificates = true, - TrustLevel = DevCertTrustLevel.Full, - Certificates = [new DevCertInfo { Version = 5, TrustLevel = DevCertTrustLevel.Full, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] - }); - }; - runner.TrustHttpCertificateAsyncCallback = (_, _) => - { - trustCalled = true; - return 0; + trustCalled = true; + return 0; + } }; - return runner; }; }); var sp = services.BuildServiceProvider(); var cs = sp.GetRequiredService(); - var runner = sp.GetRequiredService(); - var result = await cs.EnsureCertificatesTrustedAsync(runner, TestContext.Current.CancellationToken).DefaultTimeout(); + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); Assert.True(trustCalled); Assert.NotNull(result); @@ -109,27 +108,27 @@ public async Task EnsureCertificatesTrustedAsync_WithPartiallyTrustedCert_SetsSs var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.DotNetCliRunnerFactory = sp => + options.CertificateToolRunnerFactory = sp => { - var runner = new TestDotNetCliRunner(); - runner.CheckHttpCertificateMachineReadableAsyncCallback = (_, _) => + return new TestCertificateToolRunner { - return (0, new CertificateTrustResult + CheckHttpCertificateMachineReadableAsyncCallback = (_, _) => { - HasCertificates = true, - TrustLevel = DevCertTrustLevel.Partial, - Certificates = [new DevCertInfo { Version = 5, TrustLevel = DevCertTrustLevel.Partial, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] - }); + return (0, new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = DevCertTrustLevel.Partial, + Certificates = [new DevCertInfo { Version = 5, TrustLevel = DevCertTrustLevel.Partial, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] + }); + } }; - return runner; }; }); var sp = services.BuildServiceProvider(); var cs = sp.GetRequiredService(); - var runner = sp.GetRequiredService(); - var result = await cs.EnsureCertificatesTrustedAsync(runner, TestContext.Current.CancellationToken).DefaultTimeout(); + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); Assert.NotNull(result); Assert.True(result.EnvironmentVariables.ContainsKey("SSL_CERT_DIR")); @@ -144,44 +143,44 @@ public async Task EnsureCertificatesTrustedAsync_WithNoCertificates_RunsTrustOpe var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.DotNetCliRunnerFactory = sp => + options.CertificateToolRunnerFactory = sp => { - var runner = new TestDotNetCliRunner(); var callCount = 0; - runner.CheckHttpCertificateMachineReadableAsyncCallback = (_, _) => + return new TestCertificateToolRunner { - callCount++; - // First call returns no certificates, second call (after trust) returns fully trusted - if (callCount == 1) + CheckHttpCertificateMachineReadableAsyncCallback = (_, _) => { + callCount++; + // First call returns no certificates, second call (after trust) returns fully trusted + if (callCount == 1) + { + return (0, new CertificateTrustResult + { + HasCertificates = false, + TrustLevel = null, + Certificates = [] + }); + } return (0, new CertificateTrustResult { - HasCertificates = false, - TrustLevel = null, - Certificates = [] + HasCertificates = true, + TrustLevel = DevCertTrustLevel.Full, + Certificates = [new DevCertInfo { Version = 5, TrustLevel = DevCertTrustLevel.Full, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] }); - } - return (0, new CertificateTrustResult + }, + TrustHttpCertificateAsyncCallback = (_, _) => { - HasCertificates = true, - TrustLevel = DevCertTrustLevel.Full, - Certificates = [new DevCertInfo { Version = 5, TrustLevel = DevCertTrustLevel.Full, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] - }); - }; - runner.TrustHttpCertificateAsyncCallback = (_, _) => - { - trustCalled = true; - return 0; + trustCalled = true; + return 0; + } }; - return runner; }; }); var sp = services.BuildServiceProvider(); var cs = sp.GetRequiredService(); - var runner = sp.GetRequiredService(); - var result = await cs.EnsureCertificatesTrustedAsync(runner, TestContext.Current.CancellationToken).DefaultTimeout(); + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); Assert.True(trustCalled); Assert.NotNull(result); @@ -193,34 +192,64 @@ public async Task EnsureCertificatesTrustedAsync_TrustOperationFails_DisplaysWar using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.DotNetCliRunnerFactory = sp => + options.CertificateToolRunnerFactory = sp => { - var runner = new TestDotNetCliRunner(); - runner.CheckHttpCertificateMachineReadableAsyncCallback = (_, _) => + return new TestCertificateToolRunner { - return (0, new CertificateTrustResult + CheckHttpCertificateMachineReadableAsyncCallback = (_, _) => { - HasCertificates = true, - TrustLevel = DevCertTrustLevel.None, - Certificates = [new DevCertInfo { Version = 5, TrustLevel = DevCertTrustLevel.None, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] - }); - }; - runner.TrustHttpCertificateAsyncCallback = (options, _) => - { - Assert.NotNull(options.StandardErrorCallback); - options.StandardErrorCallback!.Invoke("There was an error trusting the HTTPS developer certificate. It will be trusted by some clients but not by others."); - return 4; + return (0, new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = DevCertTrustLevel.None, + Certificates = [new DevCertInfo { Version = 5, TrustLevel = DevCertTrustLevel.None, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] + }); + }, + TrustHttpCertificateAsyncCallback = (options, _) => + { + Assert.NotNull(options.StandardErrorCallback); + options.StandardErrorCallback!.Invoke("There was an error trusting the HTTPS developer certificate. It will be trusted by some clients but not by others."); + return 4; + } }; - return runner; }; }); var sp = services.BuildServiceProvider(); var cs = sp.GetRequiredService(); - var runner = sp.GetRequiredService(); // If this does not throw then the code is behaving correctly. - var result = await cs.EnsureCertificatesTrustedAsync(runner, TestContext.Current.CancellationToken).DefaultTimeout(); + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); Assert.NotNull(result); } + + private sealed class TestCertificateToolRunner : ICertificateToolRunner + { + public Func? CheckHttpCertificateMachineReadableAsyncCallback { get; set; } + public Func? TrustHttpCertificateAsyncCallback { get; set; } + + public Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync(DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + { + if (CheckHttpCertificateMachineReadableAsyncCallback != null) + { + return Task.FromResult(CheckHttpCertificateMachineReadableAsyncCallback(options, cancellationToken)); + } + + // Default: Return a fully trusted certificate result + var result = new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = DevCertTrustLevel.Full, + Certificates = [] + }; + return Task.FromResult<(int, CertificateTrustResult?)>((0, result)); + } + + public Task TrustHttpCertificateAsync(DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + { + return TrustHttpCertificateAsyncCallback != null + ? Task.FromResult(TrustHttpCertificateAsyncCallback(options, cancellationToken)) + : Task.FromResult(0); + } + } } diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index cad6b4c7795..adb1530ad89 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -2,9 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Backchannel; +using Aspire.Cli.Utils; using Aspire.Cli.Certificates; using Aspire.Cli.Commands; -using Aspire.Cli.DotNet; using Aspire.Cli.Interaction; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; @@ -654,7 +654,7 @@ public async Task NewCommandWithExitCode73ShowsUserFriendlyError() private sealed class ThrowingCertificateService : ICertificateService { - public Task EnsureCertificatesTrustedAsync(IDotNetCliRunner runner, CancellationToken cancellationToken) + public Task EnsureCertificatesTrustedAsync(CancellationToken cancellationToken) { throw new CertificateServiceException("Failed to trust certificates"); } @@ -801,6 +801,53 @@ public async Task NewCommandEscapesMarkupInProjectNameAndOutputPath() var expectedPath = $"./[27;5;13~"; Assert.Equal(expectedPath, capturedOutputPathDefault); } + + [Fact] + public async Task NewCommandNonInteractiveDoesNotPrompt() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + // Configure non-interactive host environment + options.CliHostEnvironmentFactory = (sp) => + { + var configuration = sp.GetRequiredService(); + return new CliHostEnvironment(configuration, nonInteractive: true); + }; + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, prerelease, take, skip, nugetSource, useCache, options, cancellationToken) => + { + var package = new NuGetPackage() + { + Id = "Aspire.ProjectTemplates", + Source = "nuget", + Version = "9.2.0" + }; + + return ( + 0, // Exit code. + new NuGetPackage[] { package } // Single package. + ); + }; + + return runner; + }; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("new aspire-apphost-singlefile --name TestApp --output ."); + + // Before the fix, this would throw InvalidOperationException with + // "Interactive input is not supported in this environment" because + // GetTemplates() did not pass the nonInteractive flag, causing + // the template to try to prompt for options. + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, exitCode); + } } internal sealed class TestNewCommandPrompter(IInteractionService interactionService) : NewCommandPrompter(interactionService) diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 2be8fa710a4..a567ffc8b33 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -134,7 +134,7 @@ public async Task RunCommand_WhenCertificateServiceThrows_ReturnsNonZeroExitCode private sealed class ThrowingCertificateService : Aspire.Cli.Certificates.ICertificateService { - public Task EnsureCertificatesTrustedAsync(IDotNetCliRunner runner, CancellationToken cancellationToken) + public Task EnsureCertificatesTrustedAsync(CancellationToken cancellationToken) { throw new Aspire.Cli.Certificates.CertificateServiceException("Failed to trust certificates"); } diff --git a/tests/Aspire.Cli.Tests/Commands/SdkInstallerTests.cs b/tests/Aspire.Cli.Tests/Commands/SdkInstallerTests.cs index 8b765bff3b1..5c00a263b3c 100644 --- a/tests/Aspire.Cli.Tests/Commands/SdkInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/SdkInstallerTests.cs @@ -15,6 +15,19 @@ public class SdkInstallerTests(ITestOutputHelper outputHelper) public async Task RunCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() { using var workspace = TemporaryWorkspace.Create(outputHelper); + + // Create a minimal project file so project detection succeeds + var projectContent = """ + + + Exe + net10.0 + true + + + """; + await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"), projectContent); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.DotNetSdkInstallerFactory = _ => new TestDotNetSdkInstaller @@ -22,6 +35,9 @@ public async Task RunCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() CheckAsyncCallback = _ => (false, null, "9.0.302", false) // SDK not installed }; + // Use TestDotNetCliRunner to avoid real process execution + options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner(); + options.InteractionServiceFactory = _ => new TestConsoleInteractionService(); }); var provider = services.BuildServiceProvider(); @@ -84,6 +100,19 @@ public async Task NewCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() public async Task PublishCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() { using var workspace = TemporaryWorkspace.Create(outputHelper); + + // Create a minimal project file so project detection succeeds + var projectContent = """ + + + Exe + net10.0 + true + + + """; + await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"), projectContent); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.DotNetSdkInstallerFactory = _ => new TestDotNetSdkInstaller @@ -91,6 +120,9 @@ public async Task PublishCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() CheckAsyncCallback = _ => (false, null, "9.0.302", false) // SDK not installed }; + // Use TestDotNetCliRunner to avoid real process execution + options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner(); + options.InteractionServiceFactory = _ => new TestConsoleInteractionService(); }); var provider = services.BuildServiceProvider(); @@ -106,6 +138,19 @@ public async Task PublishCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() public async Task DeployCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() { using var workspace = TemporaryWorkspace.Create(outputHelper); + + // Create a minimal project file so project detection succeeds + var projectContent = """ + + + Exe + net10.0 + true + + + """; + await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"), projectContent); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.DotNetSdkInstallerFactory = _ => new TestDotNetSdkInstaller @@ -113,6 +158,9 @@ public async Task DeployCommand_WhenSdkNotInstalled_ReturnsCorrectExitCode() CheckAsyncCallback = _ => (false, null, "9.0.302", false) // SDK not installed }; + // Use TestDotNetCliRunner to avoid real process execution + options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner(); + options.InteractionServiceFactory = _ => new TestConsoleInteractionService(); }); var provider = services.BuildServiceProvider(); diff --git a/tests/Aspire.Cli.Tests/Packaging/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Packaging/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml index bdfb3c792dc..13162c35237 100644 --- a/tests/Aspire.Cli.Tests/Packaging/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Packaging/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml @@ -1,10 +1,10 @@ - + - + @@ -13,7 +13,7 @@ - + diff --git a/tests/Aspire.Cli.Tests/Packaging/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Packaging/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml index 9733e4dbd8a..d73c31e907d 100644 --- a/tests/Aspire.Cli.Tests/Packaging/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Packaging/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml @@ -1,14 +1,14 @@ - + - + - + diff --git a/tests/Aspire.Cli.Tests/Packaging/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Packaging/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml index fe6f671ff31..e978e326b2e 100644 --- a/tests/Aspire.Cli.Tests/Packaging/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Packaging/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml @@ -1,16 +1,16 @@ - + - + - + diff --git a/tests/Aspire.Cli.Tests/Packaging/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Packaging/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml index 80ec3d7d2e8..3695a23bf34 100644 --- a/tests/Aspire.Cli.Tests/Packaging/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Packaging/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml @@ -1,15 +1,15 @@ - + - + - + diff --git a/tests/Aspire.Cli.Tests/Packaging/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Packaging/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml index d9e45564f50..8afa43a77c9 100644 --- a/tests/Aspire.Cli.Tests/Packaging/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Packaging/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml @@ -1,10 +1,10 @@ - + - + - + diff --git a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs index aea7ee84d36..77fd8b738aa 100644 --- a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerSnapshotTests.cs @@ -90,7 +90,8 @@ await WriteConfigAsync(root, // Normalize machine-specific absolute hive paths in PR channel snapshots for stability if (channelName.StartsWith("pr-", StringComparison.OrdinalIgnoreCase)) { - var hivePath = Path.Combine(hivesDir.FullName, channelName); + // Use forward slashes to match the normalized paths in NuGet config + var hivePath = Path.Combine(hivesDir.FullName, channelName).Replace('\\', '/'); xmlString = xmlString.Replace(hivePath, "{PR_HIVE}"); } @@ -152,7 +153,8 @@ await WriteConfigAsync(root, // Normalize machine-specific absolute hive paths in PR channel snapshots for stability if (channelName.StartsWith("pr-", StringComparison.OrdinalIgnoreCase)) { - var hivePath = Path.Combine(hivesDir.FullName, channelName); + // Use forward slashes to match the normalized paths in NuGet config + var hivePath = Path.Combine(hivesDir.FullName, channelName).Replace('\\', '/'); xmlString = xmlString.Replace(hivePath, "{PR_HIVE}"); } @@ -213,7 +215,8 @@ await WriteConfigAsync(root, // Normalize machine-specific absolute hive paths in PR channel snapshots for stability if (channelName.StartsWith("pr-", StringComparison.OrdinalIgnoreCase)) { - var hivePath = Path.Combine(hivesDir.FullName, channelName); + // Use forward slashes to match the normalized paths in NuGet config + var hivePath = Path.Combine(hivesDir.FullName, channelName).Replace('\\', '/'); xmlString = xmlString.Replace(hivePath, "{PR_HIVE}"); } @@ -272,7 +275,8 @@ await WriteConfigAsync(root, // Normalize machine-specific absolute hive paths in PR channel snapshots for stability if (channelName.StartsWith("pr-", StringComparison.OrdinalIgnoreCase)) { - var hivePath = Path.Combine(hivesDir.FullName, channelName); + // Use forward slashes to match the normalized paths in NuGet config + var hivePath = Path.Combine(hivesDir.FullName, channelName).Replace('\\', '/'); xmlString = xmlString.Replace(hivePath, "{PR_HIVE}"); } @@ -336,7 +340,8 @@ await WriteConfigAsync(root, // Normalize machine-specific absolute hive paths in PR channel snapshots for stability if (channelName.StartsWith("pr-", StringComparison.OrdinalIgnoreCase)) { - var hivePath = Path.Combine(hivesDir.FullName, channelName); + // Use forward slashes to match the normalized paths in NuGet config + var hivePath = Path.Combine(hivesDir.FullName, channelName).Replace('\\', '/'); xmlString = xmlString.Replace(hivePath, "{PR_HIVE}"); } diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index 0b5675479d0..7ebdea5e7fd 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs @@ -25,49 +25,21 @@ public void Dispose() GC.SuppressFinalize(this); } - private AppHostServerProject CreateProject(string? appPath = null) + private DotNetBasedAppHostServerProject CreateProject(string? appPath = null) { appPath ??= _workspace.WorkspaceRoot.FullName; var runner = new TestDotNetCliRunner(); var packagingService = new MockPackagingService(); var configurationService = new TrackingConfigurationService(); - var logger = NullLogger.Instance; + var logger = NullLogger.Instance; - return new AppHostServerProject(appPath, runner, packagingService, configurationService, logger); - } - - /// - /// Normalizes a generated csproj for snapshot comparison by replacing dynamic values. - /// - private static string NormalizeCsprojForSnapshot(string csprojContent, AppHostServerProject project) - { - // Replace dynamic UserSecretsId with placeholder - return csprojContent.Replace(project.UserSecretsId, "{USER_SECRETS_ID}"); - } - - [Fact] - public async Task CreateProjectFiles_ProductionCsproj_MatchesSnapshot() - { - // Arrange - var project = CreateProject(); - var packages = new List<(string Name, string Version)> - { - ("Aspire.Hosting", "13.1.0"), - ("Aspire.Hosting.AppHost", "13.1.0"), - ("Aspire.Hosting.Redis", "13.1.0"), - ("Aspire.Hosting.PostgreSQL", "13.1.0"), - ("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0") - }; - - // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); + // Generate socket path same way as factory + var socketPath = "test.sock"; - // Assert - var csprojContent = await File.ReadAllTextAsync(projectPath); - var normalized = NormalizeCsprojForSnapshot(csprojContent, project); + // Use workspace root as repo root for testing + var repoRoot = _workspace.WorkspaceRoot.FullName; - await Verify(normalized, extension: "xml") - .UseFileName("AppHostServerProject_ProductionCsproj"); + return new DotNetBasedAppHostServerProject(appPath, socketPath, repoRoot, runner, packagingService, configurationService, logger); } [Fact] @@ -84,7 +56,7 @@ public async Task CreateProjectFiles_AppSettingsJson_MatchesSnapshot() }; // Act - await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); + await project.CreateProjectFilesAsync(packages).DefaultTimeout(); // Assert var appSettingsPath = Path.Combine(project.ProjectModelPath, "appsettings.json"); @@ -105,7 +77,7 @@ public async Task CreateProjectFiles_ProgramCs_MatchesSnapshot() }; // Act - await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); + await project.CreateProjectFilesAsync(packages).DefaultTimeout(); // Assert var programCsPath = Path.Combine(project.ProjectModelPath, "Program.cs"); @@ -116,132 +88,6 @@ await Verify(content, extension: "txt") .UseFileName("AppHostServerProject_ProgramCs"); } - [Fact] - public async Task CreateProjectFiles_GeneratesProductionCsproj_WithAspireSdk() - { - // Arrange - var project = CreateProject(); - var packages = new List<(string Name, string Version)> - { - ("Aspire.Hosting", "13.1.0"), - ("Aspire.Hosting.AppHost", "13.1.0"), - ("Aspire.Hosting.Redis", "13.1.0"), - ("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0") - }; - - // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); - - // Assert - Assert.True(File.Exists(projectPath)); - var doc = XDocument.Load(projectPath); - - // Verify SDK attribute - var sdkAttr = doc.Root?.Attribute("Sdk")?.Value; - Assert.Equal("Aspire.AppHost.Sdk/13.1.0", sdkAttr); - } - - [Fact] - public async Task CreateProjectFiles_ProductionMode_FiltersOutImplicitPackages() - { - // Arrange - var project = CreateProject(); - var packages = new List<(string Name, string Version)> - { - ("Aspire.Hosting", "13.1.0"), - ("Aspire.Hosting.AppHost", "13.1.0"), - ("Aspire.Hosting.Redis", "13.1.0"), - ("Aspire.Hosting.PostgreSQL", "13.1.0"), - ("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0") - }; - - // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); - - // Assert - var doc = XDocument.Load(projectPath); - var packageRefs = doc.Descendants("PackageReference") - .Select(e => e.Attribute("Include")?.Value) - .Where(v => v is not null) - .ToList(); - - // Aspire.Hosting and Aspire.Hosting.AppHost should NOT be in package references (SDK provides them) - Assert.DoesNotContain("Aspire.Hosting", packageRefs); - Assert.DoesNotContain("Aspire.Hosting.AppHost", packageRefs); - - // Integration packages and code gen should be present - Assert.Contains("Aspire.Hosting.Redis", packageRefs); - Assert.Contains("Aspire.Hosting.PostgreSQL", packageRefs); - Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript", packageRefs); - - // RemoteHost should always be added - Assert.Contains("Aspire.Hosting.RemoteHost", packageRefs); - } - - [Theory] - [InlineData("Aspire.Hosting", false)] - [InlineData("Aspire.Hosting.AppHost", false)] - [InlineData("Aspire.Hosting.Redis", true)] - [InlineData("Aspire.Hosting.PostgreSQL", true)] - [InlineData("Aspire.Hosting.RemoteHost", true)] - [InlineData("Aspire.Hosting.CodeGeneration.TypeScript", true)] - [InlineData("Aspire.Hosting.CodeGeneration.Python", true)] - public async Task CreateProjectFiles_ProductionMode_CorrectlyFiltersPackages(string packageName, bool shouldBeIncluded) - { - // Arrange - var project = CreateProject(); - var packages = new List<(string Name, string Version)> - { - ("Aspire.Hosting", "13.1.0"), - ("Aspire.Hosting.AppHost", "13.1.0"), - (packageName, "13.1.0") - }; - - // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); - - // Assert - var doc = XDocument.Load(projectPath); - var packageRefs = doc.Descendants("PackageReference") - .Select(e => e.Attribute("Include")?.Value) - .Where(v => v is not null) - .ToList(); - - if (shouldBeIncluded) - { - Assert.Contains(packageName, packageRefs); - } - else - { - Assert.DoesNotContain(packageName, packageRefs); - } - } - - [Fact] - public async Task CreateProjectFiles_ProductionMode_AlwaysAddsRemoteHost() - { - // Arrange - var project = CreateProject(); - var packages = new List<(string Name, string Version)> - { - ("Aspire.Hosting", "13.1.0"), - ("Aspire.Hosting.AppHost", "13.1.0") - }; - - // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); - - // Assert - var doc = XDocument.Load(projectPath); - var packageRefs = doc.Descendants("PackageReference") - .Select(e => e.Attribute("Include")?.Value) - .Where(v => v is not null) - .ToList(); - - // RemoteHost should always be present even if not in input packages - Assert.Contains("Aspire.Hosting.RemoteHost", packageRefs); - } - [Fact] public async Task CreateProjectFiles_GeneratesProgramCs() { @@ -253,7 +99,7 @@ public async Task CreateProjectFiles_GeneratesProgramCs() }; // Act - await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); + await project.CreateProjectFilesAsync(packages).DefaultTimeout(); // Assert var programCs = Path.Combine(project.ProjectModelPath, "Program.cs"); @@ -276,7 +122,7 @@ public async Task CreateProjectFiles_GeneratesAppSettingsJson_WithAtsAssemblies( }; // Act - await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); + await project.CreateProjectFilesAsync(packages).DefaultTimeout(); // Assert var appSettingsPath = Path.Combine(project.ProjectModelPath, "appsettings.json"); @@ -289,61 +135,6 @@ public async Task CreateProjectFiles_GeneratesAppSettingsJson_WithAtsAssemblies( Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript", content); } - [Fact] - public async Task CreateProjectFiles_ProductionMode_HasMinimalProperties() - { - // Arrange - var project = CreateProject(); - var packages = new List<(string Name, string Version)> - { - ("Aspire.Hosting", "13.1.0") - }; - - // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); - - // Assert - var doc = XDocument.Load(projectPath); - - // Should have minimal property group - var propertyGroup = doc.Descendants("PropertyGroup").First(); - - Assert.NotNull(propertyGroup.Element("OutputType")); - Assert.NotNull(propertyGroup.Element("TargetFramework")); - Assert.NotNull(propertyGroup.Element("AssemblyName")); - Assert.NotNull(propertyGroup.Element("OutDir")); - Assert.NotNull(propertyGroup.Element("UserSecretsId")); - Assert.NotNull(propertyGroup.Element("IsAspireHost")); - - // Should NOT have dev-mode only properties - Assert.Null(propertyGroup.Element("IsPublishable")); - Assert.Null(propertyGroup.Element("SelfContained")); - Assert.Null(propertyGroup.Element("NoWarn")); - Assert.Null(propertyGroup.Element("RepoRoot")); - } - - [Fact] - public async Task CreateProjectFiles_ProductionMode_DisablesCodeGeneration() - { - // Arrange - var project = CreateProject(); - var packages = new List<(string Name, string Version)> - { - ("Aspire.Hosting", "13.1.0") - }; - - // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); - - // Assert - var doc = XDocument.Load(projectPath); - - // Should have empty targets to disable code generation - var targets = doc.Descendants("Target").ToList(); - Assert.Contains(targets, t => t.Attribute("Name")?.Value == "_CSharpWriteHostProjectMetadataSources"); - Assert.Contains(targets, t => t.Attribute("Name")?.Value == "_CSharpWriteProjectMetadataSources"); - } - [Fact] public async Task CreateProjectFiles_CopiesAppSettingsToOutput() { @@ -355,7 +146,7 @@ public async Task CreateProjectFiles_CopiesAppSettingsToOutput() }; // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); + var (projectPath, _) = await project.CreateProjectFilesAsync(packages).DefaultTimeout(); // Assert var doc = XDocument.Load(projectPath); @@ -371,7 +162,7 @@ public async Task CreateProjectFiles_CopiesAppSettingsToOutput() public void DefaultSdkVersion_ReturnsValidVersion() { // Act - var version = AppHostServerProject.DefaultSdkVersion; + var version = DotNetBasedAppHostServerProject.DefaultSdkVersion; // Assert Assert.NotNull(version); @@ -408,51 +199,6 @@ public void UserSecretsId_IsStableForSameAppPath() Assert.Equal(project1.UserSecretsId, project2.UserSecretsId); } - [Fact] - public async Task CreateProjectFiles_UsesSdkVersionInPackageAttribute() - { - // Arrange - var project = CreateProject(); - var packages = new List<(string Name, string Version)> - { - ("Aspire.Hosting", "13.2.0") - }; - - // Act - var (projectPath, _) = await project.CreateProjectFilesAsync("13.2.0", packages).DefaultTimeout(); - - // Assert - var doc = XDocument.Load(projectPath); - var sdkAttr = doc.Root?.Attribute("Sdk")?.Value; - Assert.Equal("Aspire.AppHost.Sdk/13.2.0", sdkAttr); - } - - [Fact] - public async Task CreateProjectFiles_PackageVersionsMatchSdkVersion() - { - // Arrange - var project = CreateProject(); - var sdkVersion = "13.3.0"; - var packages = new List<(string Name, string Version)> - { - ("Aspire.Hosting", sdkVersion), - ("Aspire.Hosting.Redis", sdkVersion) - }; - - // Act - var (projectPath, _) = await project.CreateProjectFilesAsync(sdkVersion, packages).DefaultTimeout(); - - // Assert - var doc = XDocument.Load(projectPath); - - // RemoteHost should use SDK version - var remoteHostRef = doc.Descendants("PackageReference") - .FirstOrDefault(e => e.Attribute("Include")?.Value == "Aspire.Hosting.RemoteHost"); - - Assert.NotNull(remoteHostRef); - Assert.Equal(sdkVersion, remoteHostRef.Attribute("Version")?.Value); - } - /// /// Regression test for channel switching bug. /// When a project has a channel configured in .aspire/settings.json (project-local), @@ -508,11 +254,11 @@ await File.WriteAllTextAsync(settingsJson, """ builder.SetMinimumLevel(LogLevel.Debug); builder.AddXunit(outputHelper); }); - var logger = loggerFactory.CreateLogger(); + var logger = loggerFactory.CreateLogger(); // Use a workspace-local ProjectModelPath for test isolation var projectModelPath = Path.Combine(appPath, ".aspire_server"); - var project = new AppHostServerProject(appPath, runner, packagingService, configurationService, logger, projectModelPath); + var project = new DotNetBasedAppHostServerProject(appPath, "test.sock", appPath, runner, packagingService, configurationService, logger, projectModelPath); var packages = new List<(string Name, string Version)> { @@ -522,7 +268,7 @@ await File.WriteAllTextAsync(settingsJson, """ }; // Act - await project.CreateProjectFilesAsync("13.1.0", packages).DefaultTimeout(); + await project.CreateProjectFilesAsync(packages).DefaultTimeout(); // Dump workspace directory tree for debugging outputHelper.WriteLine("=== Workspace Directory Tree ==="); diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 433c9a70611..36db392a5ba 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -139,10 +139,11 @@ public void AspireJsonConfiguration_GetAllPackages_IncludesBasePackages() // Act var packages = config.GetAllPackages().ToList(); - // Assert - should include base packages plus explicit packages + // Assert - should include base package (Aspire.Hosting) plus explicit packages + // Note: Aspire.Hosting.AppHost is an SDK-only package and is excluded Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); - Assert.Contains(packages, p => p.Name == "Aspire.Hosting.AppHost" && p.Version == "13.1.0"); Assert.Contains(packages, p => p.Name == "Aspire.Hosting.Redis" && p.Version == "13.1.0"); + Assert.Equal(2, packages.Count); } [Fact] @@ -158,10 +159,10 @@ public void AspireJsonConfiguration_GetAllPackages_WithNoExplicitPackages_Return // Act var packages = config.GetAllPackages().ToList(); - // Assert - should include base packages only - Assert.Equal(2, packages.Count); + // Assert - should include base package only (Aspire.Hosting) + // Note: Aspire.Hosting.AppHost is an SDK-only package and is excluded + Assert.Single(packages); Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); - Assert.Contains(packages, p => p.Name == "Aspire.Hosting.AppHost" && p.Version == "13.1.0"); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Snapshots/AppHostServerProject_ProductionCsproj.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/AppHostServerProject_ProductionCsproj.verified.xml deleted file mode 100644 index 149faf81e21..00000000000 --- a/tests/Aspire.Cli.Tests/Snapshots/AppHostServerProject_ProductionCsproj.verified.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - exe - net10.0 - AppHostServer - build - {USER_SECRETS_ID} - true - - - - - - - - - - - - - - \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml index bdfb3c792dc..13162c35237 100644 --- a/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_ExtraPatternOnDailyFeedWhenOnPrFeedGetsConsolidatedWithOtherPatterns_ProducesExpectedXml.pr-1234.verified.xml @@ -1,10 +1,10 @@ - + - + @@ -13,7 +13,7 @@ - + diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml index 9733e4dbd8a..d73c31e907d 100644 --- a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithBrokenSdkState_ProducesExpectedXml.pr-1234.verified.xml @@ -1,14 +1,14 @@ - + - + - + diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml index fe6f671ff31..e978e326b2e 100644 --- a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithDailyFeedWithExtraMappingsIsPreserved_ProducesExpectedXml.pr-1234.verified.xml @@ -1,16 +1,16 @@ - + - + - + diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml index 80ec3d7d2e8..3695a23bf34 100644 --- a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithExtraInternalFeedIncorrectlyMapped_ProducesExpectedXml.pr-1234.verified.xml @@ -1,15 +1,15 @@ - + - + - + diff --git a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml index d9e45564f50..8afa43a77c9 100644 --- a/tests/Aspire.Cli.Tests/Snapshots/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml +++ b/tests/Aspire.Cli.Tests/Snapshots/Merge_WithSimpleNuGetConfig_ProducesExpectedXml.pr-1234.verified.xml @@ -1,10 +1,10 @@ - + - + - + diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index cb47f0170c3..383cf49560c 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -13,6 +13,7 @@ using Aspire.Cli.Packaging; using Aspire.Cli.Templating; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Utils; using Aspire.Shared; using Spectre.Console; @@ -323,7 +324,7 @@ public void GetTemplates_SingleFileAppHostIsAlwaysVisible() Assert.Contains("aspire-py-starter", templateNames); } - private static DotNetTemplateFactory CreateTemplateFactory(TestFeatures features) + private static DotNetTemplateFactory CreateTemplateFactory(TestFeatures features, bool nonInteractive = false) { var interactionService = new TestInteractionService(); var runner = new TestDotNetCliRunner(); @@ -335,6 +336,7 @@ private static DotNetTemplateFactory CreateTemplateFactory(TestFeatures features var cacheDirectory = new DirectoryInfo("/tmp/cache"); var executionContext = new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); var configurationService = new FakeConfigurationService(); + var hostEnvironment = new FakeCliHostEnvironment(nonInteractive); return new DotNetTemplateFactory( interactionService, @@ -344,7 +346,8 @@ private static DotNetTemplateFactory CreateTemplateFactory(TestFeatures features prompter, executionContext, features, - configurationService); + configurationService, + hostEnvironment); } private sealed class FakeConfigurationService : IConfigurationService @@ -478,19 +481,13 @@ public Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referen public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task TrustHttpCertificateAsync(DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) - => throw new NotImplementedException(); - public Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - - public Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync(DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) - => Task.FromResult<(int, CertificateTrustResult?)>((0, new CertificateTrustResult { HasCertificates = true, TrustLevel = DevCertTrustLevel.Full, Certificates = [] })); } private sealed class TestCertificateService : ICertificateService { - public Task EnsureCertificatesTrustedAsync(IDotNetCliRunner runner, CancellationToken cancellationToken) + public Task EnsureCertificatesTrustedAsync(CancellationToken cancellationToken) => Task.FromResult(new EnsureCertificatesTrustedResult { EnvironmentVariables = new Dictionary() }); } @@ -514,4 +511,11 @@ public Task PromptForOutputPath(string defaultPath, CancellationToken ca public Task PromptForTemplateAsync(ITemplate[] templates, CancellationToken cancellationToken) => throw new NotImplementedException(); } + + private sealed class FakeCliHostEnvironment(bool nonInteractive) : ICliHostEnvironment + { + public bool SupportsInteractiveInput => !nonInteractive; + public bool SupportsInteractiveOutput => !nonInteractive; + public bool SupportsAnsi => false; + } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs new file mode 100644 index 00000000000..549c0f8c246 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs @@ -0,0 +1,41 @@ +// 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.Certificates; +using Aspire.Cli.DotNet; + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// Test implementation of ICertificateToolRunner that returns fully trusted certs by default. +/// Used to avoid real certificate operations in tests. +/// +internal sealed class TestCertificateToolRunner : ICertificateToolRunner +{ + public Func? CheckHttpCertificateMachineReadableAsyncCallback { get; set; } + public Func? TrustHttpCertificateAsyncCallback { get; set; } + + public Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync(DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + { + if (CheckHttpCertificateMachineReadableAsyncCallback != null) + { + return Task.FromResult(CheckHttpCertificateMachineReadableAsyncCallback(options, cancellationToken)); + } + + // Default: Return a fully trusted certificate result + var result = new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = DevCertTrustLevel.Full, + Certificates = [] + }; + return Task.FromResult<(int, CertificateTrustResult?)>((0, result)); + } + + public Task TrustHttpCertificateAsync(DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + { + return TrustHttpCertificateAsyncCallback != null + ? Task.FromResult(TrustHttpCertificateAsyncCallback(options, cancellationToken)) + : Task.FromResult(0); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs index 4d078c3aafc..30b87c6ee52 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -3,7 +3,6 @@ using System.Text.Json; using Aspire.Cli.Backchannel; -using Aspire.Cli.Certificates; using Aspire.Cli.DotNet; using Aspire.Cli.Utils; using NuGetPackage = Aspire.Shared.NuGetPackageCli; @@ -15,7 +14,6 @@ internal sealed class TestDotNetCliRunner : IDotNetCliRunner public Func? AddPackageAsyncCallback { get; set; } public Func? AddProjectToSolutionAsyncCallback { get; set; } public Func? BuildAsyncCallback { get; set; } - public Func? CheckHttpCertificateMachineReadableAsyncCallback { get; set; } public Func? GetAppHostInformationAsyncCallback { get; set; } public Func? GetNuGetConfigPathsAsyncCallback { get; set; } public Func? GetProjectItemsAndPropertiesAsyncCallback { get; set; } @@ -23,7 +21,6 @@ internal sealed class TestDotNetCliRunner : IDotNetCliRunner public Func? NewProjectAsyncCallback { get; set; } public Func?, TaskCompletionSource?, DotNetCliRunnerInvocationOptions, CancellationToken, Task>? RunAsyncCallback { get; set; } public Func? SearchPackagesAsyncCallback { get; set; } - public Func? TrustHttpCertificateAsyncCallback { get; set; } public Func Projects)>? GetSolutionProjectsAsyncCallback { get; set; } public Func? AddProjectReferenceAsyncCallback { get; set; } @@ -48,23 +45,6 @@ public Task BuildAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationO : throw new NotImplementedException(); } - public Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync(DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) - { - if (CheckHttpCertificateMachineReadableAsyncCallback != null) - { - return Task.FromResult(CheckHttpCertificateMachineReadableAsyncCallback(options, cancellationToken)); - } - - // Default: Return a fully trusted certificate result - var result = new CertificateTrustResult - { - HasCertificates = true, - TrustLevel = DevCertTrustLevel.Full, - Certificates = [] - }; - return Task.FromResult<(int, CertificateTrustResult?)>((0, result)); - } - public Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { var informationalVersion = VersionHelper.GetDefaultTemplateVersion(); @@ -125,13 +105,6 @@ public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, string : throw new NotImplementedException(); } - public Task TrustHttpCertificateAsync(DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) - { - return TrustHttpCertificateAsyncCallback != null - ? Task.FromResult(TrustHttpCertificateAsyncCallback(options, cancellationToken)) - : throw new NotImplementedException(); - } - public Task<(int ExitCode, IReadOnlyList Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { return GetSolutionProjectsAsyncCallback != null diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 16e703f807c..1289030cc5c 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -10,6 +10,7 @@ using Aspire.Cli.DotNet; using Aspire.Cli.Git; using Aspire.Cli.Interaction; +using Aspire.Cli.Layout; using Aspire.Cli.Mcp; using Aspire.Cli.Mcp.Docs; using Aspire.Cli.NuGet; @@ -91,6 +92,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(options.ExtensionRpcTargetFactory); services.AddTransient(options.ExtensionBackchannelFactory); services.AddSingleton(options.InteractionServiceFactory); + services.AddSingleton(options.CertificateToolRunnerFactory); services.AddSingleton(options.CertificateServiceFactory); services.AddSingleton(options.NewCommandPrompterFactory); services.AddSingleton(options.AddCommandPrompterFactory); @@ -103,7 +105,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(options.ConfigurationServiceFactory); services.AddSingleton(options.FeatureFlagsFactory); services.AddSingleton(options.CliUpdateNotifierFactory); - services.AddSingleton(options.DotNetSdkInstallerFactory); + services.AddSingleton(options.DotNetSdkInstallerFactory); services.AddSingleton(options.PackagingServiceFactory); services.AddSingleton(options.CliExecutionContextFactory); services.AddSingleton(options.DiskCacheFactory); @@ -124,6 +126,12 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(); services.AddSingleton(options.LanguageServiceFactory); + // Bundle layout services - return null/no-op implementations to trigger SDK mode fallback + // This ensures backward compatibility: no layout found = use legacy SDK mode + services.AddSingleton(options.LayoutDiscoveryFactory); + services.AddSingleton(options.BundleDownloaderFactory); + services.AddSingleton(); + // AppHost project handlers - must match Program.cs registration pattern services.AddSingleton(); services.AddSingleton>(sp => @@ -349,11 +357,19 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment); }; + public Func CertificateToolRunnerFactory { get; set; } = (IServiceProvider _) => + { + // Use TestCertificateToolRunner by default to avoid calling real dotnet dev-certs + // which can be slow or block on macOS (keychain access prompts) + return new TestCertificateToolRunner(); + }; + public Func CertificateServiceFactory { get; set; } = (IServiceProvider serviceProvider) => { + var certificateToolRunner = serviceProvider.GetRequiredService(); var interactiveService = serviceProvider.GetRequiredService(); var telemetry = serviceProvider.GetRequiredService(); - return new CertificateService(interactiveService, telemetry); + return new CertificateService(certificateToolRunner, interactiveService, telemetry); }; public Func DotNetCliExecutionFactoryFactory { get; set; } = (IServiceProvider serviceProvider) => @@ -413,7 +429,8 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser public Func FeatureFlagsFactory { get; set; } = (IServiceProvider serviceProvider) => { var configuration = serviceProvider.GetRequiredService(); - return new Features(configuration); + var logger = serviceProvider.GetRequiredService>(); + return new Features(configuration, logger); }; public Func TemplateProviderFactory { get; set; } = (IServiceProvider serviceProvider) => @@ -426,7 +443,8 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var executionContext = serviceProvider.GetRequiredService(); var features = serviceProvider.GetRequiredService(); var configurationService = serviceProvider.GetRequiredService(); - var factory = new DotNetTemplateFactory(interactionService, runner, certificateService, packagingService, prompter, executionContext, features, configurationService); + var hostEnvironment = serviceProvider.GetRequiredService(); + var factory = new DotNetTemplateFactory(interactionService, runner, certificateService, packagingService, prompter, executionContext, features, configurationService, hostEnvironment); return new TemplateProvider([factory]); }; @@ -477,6 +495,16 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser return new TestAppHostServerSessionFactory(); }; + // Layout discovery - returns null by default (no bundle layout), causing SDK mode fallback + public Func LayoutDiscoveryFactory { get; set; } = _ => new NullLayoutDiscovery(); + + // Bundle downloader - returns a no-op implementation that indicates no bundle mode + // This causes UpdateCommand to fall back to CLI-only update or show dotnet tool instructions + public Func BundleDownloaderFactory { get; set; } = (IServiceProvider serviceProvider) => + { + return new NullBundleDownloader(); + }; + public Func McpServerTransportFactory { get; set; } = (IServiceProvider serviceProvider) => { var loggerFactory = serviceProvider.GetService(); @@ -499,6 +527,38 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser }; } +/// +/// A layout discovery that always returns null (no bundle layout). +/// Used in tests to ensure SDK mode is used. +/// +internal sealed class NullLayoutDiscovery : ILayoutDiscovery +{ + public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null) => null; + + public string? GetComponentPath(LayoutComponent component, string? projectDirectory = null) => null; + + public bool IsBundleModeAvailable(string? projectDirectory = null) => false; +} + +/// +/// A no-op bundle downloader that always returns "no updates available". +/// Used in tests to ensure backward compatibility - no layout = SDK mode. +/// +internal sealed class NullBundleDownloader : IBundleDownloader +{ + public Task DownloadLatestBundleAsync(CancellationToken cancellationToken) + => throw new NotSupportedException("Bundle downloads not available in test environment"); + + public Task GetLatestVersionAsync(CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task IsUpdateAvailableAsync(string currentVersion, CancellationToken cancellationToken) + => Task.FromResult(false); + + public Task ApplyUpdateAsync(string archivePath, string installPath, CancellationToken cancellationToken) + => Task.FromResult(BundleUpdateResult.Failed("Bundle updates not available in test environment")); +} + internal sealed class TestOutputTextWriter : TextWriter { private readonly ITestOutputHelper _outputHelper; diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.csproj b/tests/Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.csproj index 575762dcbad..913432d4d46 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.csproj +++ b/tests/Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.csproj @@ -1,7 +1,8 @@ - $(DefaultTargetFramework) + + net10.0 diff --git a/tools/CreateLayout/CreateLayout.csproj b/tools/CreateLayout/CreateLayout.csproj new file mode 100644 index 00000000000..42bb99f8e20 --- /dev/null +++ b/tools/CreateLayout/CreateLayout.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + Exe + enable + enable + false + false + false + false + CreateLayout + Aspire.Tools.CreateLayout + + + + + + + + diff --git a/tools/CreateLayout/Program.cs b/tools/CreateLayout/Program.cs new file mode 100644 index 00000000000..7a898abfd12 --- /dev/null +++ b/tools/CreateLayout/Program.cs @@ -0,0 +1,870 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Diagnostics; +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Aspire.Tools.CreateLayout; + +/// +/// Creates the Aspire bundle layout for distribution. +/// This tool assembles all components into a self-contained package. +/// +/// +/// See docs/specs/bundle.md for the complete bundle specification and layout structure. +/// +public static class Program +{ + public static async Task Main(string[] args) + { + var outputOption = new Option("--output", "-o") + { + Description = "Output directory for the layout", + Required = true + }; + + var artifactsOption = new Option("--artifacts", "-a") + { + Description = "Path to build artifacts directory", + Required = true + }; + + var ridOption = new Option("--rid") + { + Description = "Runtime identifier", + Required = true + }; + + var runtimeOption = new Option("--runtime", "-r") + { + Description = "Path to .NET runtime to include (alternative to --download-runtime)" + }; + + var bundleVersionOption = new Option("--bundle-version") + { + Description = "Version string for the layout", + DefaultValueFactory = _ => "0.0.0-dev" + }; + + var runtimeVersionOption = new Option("--runtime-version") + { + Description = ".NET SDK version to download", + Required = true + }; + + var downloadRuntimeOption = new Option("--download-runtime") + { + Description = "Download .NET and ASP.NET runtimes from Microsoft" + }; + + var archiveOption = new Option("--archive") + { + Description = "Create archive (zip/tar.gz) after building" + }; + + var verboseOption = new Option("--verbose") + { + Description = "Enable verbose output" + }; + + var rootCommand = new RootCommand("CreateLayout - Build Aspire bundle layout for distribution"); + rootCommand.Options.Add(outputOption); + rootCommand.Options.Add(artifactsOption); + rootCommand.Options.Add(ridOption); + rootCommand.Options.Add(runtimeOption); + rootCommand.Options.Add(bundleVersionOption); + rootCommand.Options.Add(runtimeVersionOption); + rootCommand.Options.Add(downloadRuntimeOption); + rootCommand.Options.Add(archiveOption); + rootCommand.Options.Add(verboseOption); + + rootCommand.SetAction(async (parseResult, cancellationToken) => + { + var outputPath = parseResult.GetValue(outputOption)!; + var artifactsPath = parseResult.GetValue(artifactsOption)!; + var rid = parseResult.GetValue(ridOption)!; + var runtimePath = parseResult.GetValue(runtimeOption); + var version = parseResult.GetValue(bundleVersionOption)!; + var runtimeVersion = parseResult.GetValue(runtimeVersionOption)!; + var downloadRuntime = parseResult.GetValue(downloadRuntimeOption); + var createArchive = parseResult.GetValue(archiveOption); + var verbose = parseResult.GetValue(verboseOption); + + try + { + using var builder = new LayoutBuilder(outputPath, artifactsPath, runtimePath, rid, version, runtimeVersion, downloadRuntime, verbose); + await builder.BuildAsync().ConfigureAwait(false); + + if (createArchive) + { + await builder.CreateArchiveAsync().ConfigureAwait(false); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + if (verbose) + { + Console.Error.WriteLine(ex.StackTrace); + } + return 1; + } + }); + + return await rootCommand.Parse(args).InvokeAsync().ConfigureAwait(false); + } +} + +/// +/// Builds the layout directory structure. +/// +internal sealed class LayoutBuilder : IDisposable +{ + private readonly string _outputPath; + private readonly string _artifactsPath; + private readonly string? _runtimePath; + private readonly string _rid; + private readonly string _version; + private readonly string _runtimeVersion; + private readonly bool _downloadRuntime; + private readonly bool _verbose; + private readonly HttpClient _httpClient = new(); + + public LayoutBuilder(string outputPath, string artifactsPath, string? runtimePath, string rid, string version, string runtimeVersion, bool downloadRuntime, bool verbose) + { + _outputPath = Path.GetFullPath(outputPath); + _artifactsPath = Path.GetFullPath(artifactsPath); + _runtimePath = runtimePath is not null ? Path.GetFullPath(runtimePath) : null; + _rid = rid; + _version = version; + _runtimeVersion = runtimeVersion; + _downloadRuntime = downloadRuntime; + _verbose = verbose; + } + + public void Dispose() + { + _httpClient.Dispose(); + } + + public async Task BuildAsync() + { + Log($"Building layout for {_rid} version {_version}"); + Log($"Output: {_outputPath}"); + Log($"Artifacts: {_artifactsPath}"); + + // Clean and create output directory + if (Directory.Exists(_outputPath)) + { + Directory.Delete(_outputPath, recursive: true); + } + Directory.CreateDirectory(_outputPath); + + // Copy components + await CopyCliAsync().ConfigureAwait(false); + await CopyRuntimeAsync().ConfigureAwait(false); + await CopyNuGetHelperAsync().ConfigureAwait(false); + await CopyAppHostServerAsync().ConfigureAwait(false); + await CopyDashboardAsync().ConfigureAwait(false); + await CopyDcpAsync().ConfigureAwait(false); + + // Enable rollforward for all managed tools + EnableRollForwardForAllTools(); + + Log("Layout build complete!"); + } + + private async Task CopyCliAsync() + { + Log("Copying CLI..."); + + var cliPublishPath = FindPublishPath("Aspire.Cli"); + if (cliPublishPath is null) + { + throw new InvalidOperationException("CLI publish output not found. Run 'dotnet publish' on Aspire.Cli first."); + } + + var cliExe = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase) ? "aspire.exe" : "aspire"; + var sourceExe = Path.Combine(cliPublishPath, cliExe); + + if (!File.Exists(sourceExe)) + { + throw new InvalidOperationException($"CLI executable not found at {sourceExe}"); + } + + var destExe = Path.Combine(_outputPath, cliExe); + File.Copy(sourceExe, destExe, overwrite: true); + + // Make executable on Unix + if (!_rid.StartsWith("win", StringComparison.OrdinalIgnoreCase)) + { + await SetExecutableAsync(destExe).ConfigureAwait(false); + } + + Log($" Copied {cliExe}"); + } + + private async Task CopyRuntimeAsync() + { + Log("Copying .NET runtime..."); + + var runtimeDir = Path.Combine(_outputPath, "runtime"); + Directory.CreateDirectory(runtimeDir); + + if (_runtimePath is not null && Directory.Exists(_runtimePath)) + { + CopyRuntimeFromPath(_runtimePath, runtimeDir); + Log($" Copied runtime from {_runtimePath}"); + } + else if (_downloadRuntime) + { + // Download runtime from Microsoft + await DownloadRuntimeAsync(runtimeDir).ConfigureAwait(false); + } + else + { + // Try to find runtime in artifacts or use shared runtime + var sharedRuntime = FindSharedRuntime(); + if (sharedRuntime is not null) + { + CopyRuntimeFromPath(sharedRuntime, runtimeDir); + Log($" Copied shared runtime from {sharedRuntime}"); + } + else + { + Log(" WARNING: No runtime found. Layout will require runtime to be downloaded separately."); + Log(" Use --download-runtime to download the runtime from Microsoft."); + await File.WriteAllTextAsync( + Path.Combine(runtimeDir, "README.txt"), + "Place .NET runtime files here.\n").ConfigureAwait(false); + } + } + } + + /// + /// Copy runtime from a source path, excluding unnecessary frameworks like WindowsDesktop.App. + /// + private void CopyRuntimeFromPath(string sourcePath, string destPath) + { + // Copy everything except the shared/Microsoft.WindowsDesktop.App directory + var sharedDir = Path.Combine(sourcePath, "shared"); + if (Directory.Exists(sharedDir)) + { + var destSharedDir = Path.Combine(destPath, "shared"); + Directory.CreateDirectory(destSharedDir); + + // Only copy NETCore.App and AspNetCore.App - skip WindowsDesktop.App to save space + var frameworksToCopy = new[] { "Microsoft.NETCore.App", "Microsoft.AspNetCore.App" }; + foreach (var framework in frameworksToCopy) + { + var srcFrameworkDir = Path.Combine(sharedDir, framework); + if (Directory.Exists(srcFrameworkDir)) + { + CopyDirectory(srcFrameworkDir, Path.Combine(destSharedDir, framework)); + } + } + } + + // Copy host directory + var hostDir = Path.Combine(sourcePath, "host"); + if (Directory.Exists(hostDir)) + { + CopyDirectory(hostDir, Path.Combine(destPath, "host")); + } + + // Copy dotnet executable and related files + var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase); + var dotnetExe = isWindows ? "dotnet.exe" : "dotnet"; + var dotnetPath = Path.Combine(sourcePath, dotnetExe); + if (File.Exists(dotnetPath)) + { + File.Copy(dotnetPath, Path.Combine(destPath, dotnetExe), overwrite: true); + } + + // Copy LICENSE and ThirdPartyNotices if present + foreach (var file in new[] { "LICENSE.txt", "ThirdPartyNotices.txt" }) + { + var srcFile = Path.Combine(sourcePath, file); + if (File.Exists(srcFile)) + { + File.Copy(srcFile, Path.Combine(destPath, file), overwrite: true); + } + } + } + + private async Task DownloadRuntimeAsync(string runtimeDir) + { + Log($" Downloading .NET SDK {_runtimeVersion} for {_rid}..."); + + var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase); + var archiveExt = isWindows ? "zip" : "tar.gz"; + + // Download the full SDK - it contains runtime, aspnetcore, and dev-certs tool + var sdkUrl = $"https://builds.dotnet.microsoft.com/dotnet/Sdk/{_runtimeVersion}/dotnet-sdk-{_runtimeVersion}-{_rid}.{archiveExt}"; + await DownloadAndExtractSdkAsync(sdkUrl, runtimeDir).ConfigureAwait(false); + + Log($" SDK components extracted successfully"); + } + + private async Task DownloadAndExtractSdkAsync(string url, string runtimeDir) + { + Log($" Downloading SDK from {url}..."); + + var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-sdk-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase); + var archiveExt = isWindows ? "zip" : "tar.gz"; + var archivePath = Path.Combine(tempDir, $"sdk.{archiveExt}"); + + // Download the archive + using (var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false)) + { + response.EnsureSuccessStatusCode(); + using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + using var fileStream = File.Create(archivePath); + await stream.CopyToAsync(fileStream).ConfigureAwait(false); + } + + Log($" Extracting SDK..."); + + // Extract the archive + var extractDir = Path.Combine(tempDir, "extracted"); + Directory.CreateDirectory(extractDir); + + if (isWindows) + { + ZipFile.ExtractToDirectory(archivePath, extractDir); + } + else + { + // Use tar to extract on Unix + var psi = new ProcessStartInfo + { + FileName = "tar", + Arguments = $"-xzf \"{archivePath}\" -C \"{extractDir}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + using var process = Process.Start(psi); + await process!.WaitForExitAsync().ConfigureAwait(false); + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"Failed to extract SDK: tar exited with code {process.ExitCode}"); + } + } + + // Extract runtime components: shared/, host/, dotnet executable + Log($" Extracting runtime components..."); + + // Copy only the shared frameworks we need (exclude WindowsDesktop.App to save space) + var sharedDir = Path.Combine(extractDir, "shared"); + if (Directory.Exists(sharedDir)) + { + var destSharedDir = Path.Combine(runtimeDir, "shared"); + Directory.CreateDirectory(destSharedDir); + + // Only copy NETCore.App and AspNetCore.App - skip WindowsDesktop.App + var frameworksToCopy = new[] { "Microsoft.NETCore.App", "Microsoft.AspNetCore.App" }; + foreach (var framework in frameworksToCopy) + { + var srcFrameworkDir = Path.Combine(sharedDir, framework); + if (Directory.Exists(srcFrameworkDir)) + { + CopyDirectory(srcFrameworkDir, Path.Combine(destSharedDir, framework)); + Log($" Copied {framework}"); + } + } + } + + // Copy host directory + var hostDir = Path.Combine(extractDir, "host"); + if (Directory.Exists(hostDir)) + { + CopyDirectory(hostDir, Path.Combine(runtimeDir, "host")); + } + + // Copy dotnet executable + var dotnetExe = isWindows ? "dotnet.exe" : "dotnet"; + var dotnetPath = Path.Combine(extractDir, dotnetExe); + if (File.Exists(dotnetPath)) + { + var destDotnet = Path.Combine(runtimeDir, dotnetExe); + File.Copy(dotnetPath, destDotnet, overwrite: true); + if (!isWindows) + { + await SetExecutableAsync(destDotnet).ConfigureAwait(false); + } + } + + // Copy LICENSE and ThirdPartyNotices + foreach (var file in new[] { "LICENSE.txt", "ThirdPartyNotices.txt" }) + { + var srcFile = Path.Combine(extractDir, file); + if (File.Exists(srcFile)) + { + File.Copy(srcFile, Path.Combine(runtimeDir, file), overwrite: true); + } + } + + // Extract dev-certs tool from SDK + Log($" Extracting dev-certs tool..."); + await ExtractDevCertsToolAsync(extractDir).ConfigureAwait(false); + + Log($" SDK extraction complete"); + } + finally + { + // Cleanup temp directory + try + { + Directory.Delete(tempDir, recursive: true); + } + catch + { + // Ignore cleanup errors + } + } + } + + private Task ExtractDevCertsToolAsync(string sdkExtractDir) + { + // Find the dev-certs tool in sdk//DotnetTools/dotnet-dev-certs/ + var sdkDir = Path.Combine(sdkExtractDir, "sdk"); + if (!Directory.Exists(sdkDir)) + { + Log($" WARNING: SDK directory not found, skipping dev-certs extraction"); + return Task.CompletedTask; + } + + // Find the SDK version directory (e.g., "10.0.102") + var sdkVersionDirs = Directory.GetDirectories(sdkDir); + if (sdkVersionDirs.Length == 0) + { + Log($" WARNING: No SDK version directory found, skipping dev-certs extraction"); + return Task.CompletedTask; + } + + // Use the first (should be only) SDK version directory + var sdkVersionDir = sdkVersionDirs[0]; + var dotnetToolsDir = Path.Combine(sdkVersionDir, "DotnetTools", "dotnet-dev-certs"); + + if (!Directory.Exists(dotnetToolsDir)) + { + Log($" WARNING: dotnet-dev-certs not found at {dotnetToolsDir}, skipping"); + return Task.CompletedTask; + } + + // Find the tool version directory (e.g., "10.0.2-servicing.25612.105") + var toolVersionDirs = Directory.GetDirectories(dotnetToolsDir); + if (toolVersionDirs.Length == 0) + { + Log($" WARNING: No dev-certs version directory found, skipping"); + return Task.CompletedTask; + } + + // Find the tools/net10.0/any directory containing the actual DLLs + var toolVersionDir = toolVersionDirs[0]; + var toolsDir = Path.Combine(toolVersionDir, "tools"); + + // Look for net10.0/any or similar pattern + string? devCertsSourceDir = null; + if (Directory.Exists(toolsDir)) + { + foreach (var tfmDir in Directory.GetDirectories(toolsDir)) + { + var anyDir = Path.Combine(tfmDir, "any"); + if (Directory.Exists(anyDir) && File.Exists(Path.Combine(anyDir, "dotnet-dev-certs.dll"))) + { + devCertsSourceDir = anyDir; + break; + } + } + } + + if (devCertsSourceDir is null) + { + Log($" WARNING: dev-certs DLLs not found, skipping"); + return Task.CompletedTask; + } + + // Copy to tools/dev-certs/ in the layout + var devCertsDestDir = Path.Combine(_outputPath, "tools", "dev-certs"); + Directory.CreateDirectory(devCertsDestDir); + + // Copy the essential files + foreach (var file in new[] { "dotnet-dev-certs.dll", "dotnet-dev-certs.deps.json", "dotnet-dev-certs.runtimeconfig.json" }) + { + var srcFile = Path.Combine(devCertsSourceDir, file); + if (File.Exists(srcFile)) + { + File.Copy(srcFile, Path.Combine(devCertsDestDir, file), overwrite: true); + } + } + + Log($" dev-certs tool extracted to tools/dev-certs/"); + return Task.CompletedTask; + } + + private Task CopyNuGetHelperAsync() + { + Log("Copying NuGet Helper..."); + + var helperPublishPath = FindPublishPath("Aspire.Cli.NuGetHelper"); + if (helperPublishPath is null) + { + throw new InvalidOperationException("NuGet Helper publish output not found."); + } + + var helperDir = Path.Combine(_outputPath, "tools", "aspire-nuget"); + Directory.CreateDirectory(helperDir); + + CopyDirectory(helperPublishPath, helperDir); + Log($" Copied NuGet Helper to tools/aspire-nuget"); + + return Task.CompletedTask; + } + + private Task CopyAppHostServerAsync() + { + Log("Copying AppHost Server..."); + + var serverPublishPath = FindPublishPath("Aspire.Hosting.RemoteHost"); + if (serverPublishPath is null) + { + throw new InvalidOperationException("AppHost Server (Aspire.Hosting.RemoteHost) publish output not found."); + } + + var serverDir = Path.Combine(_outputPath, "aspire-server"); + Directory.CreateDirectory(serverDir); + + CopyDirectory(serverPublishPath, serverDir); + Log($" Copied AppHost Server to aspire-server"); + + return Task.CompletedTask; + } + + private Task CopyDashboardAsync() + { + Log("Copying Dashboard..."); + + var dashboardPublishPath = FindPublishPath("Aspire.Dashboard"); + if (dashboardPublishPath is null) + { + Log(" WARNING: Dashboard publish output not found. Skipping."); + return Task.CompletedTask; + } + + var dashboardDir = Path.Combine(_outputPath, "dashboard"); + Directory.CreateDirectory(dashboardDir); + + CopyDirectory(dashboardPublishPath, dashboardDir); + Log($" Copied Dashboard to dashboard"); + + return Task.CompletedTask; + } + + private Task CopyDcpAsync() + { + Log("Copying DCP..."); + + // DCP comes from NuGet packages, look for it in the artifacts + var dcpPath = FindDcpPath(); + if (dcpPath is null) + { + Log(" WARNING: DCP not found. Skipping."); + return Task.CompletedTask; + } + + var dcpDir = Path.Combine(_outputPath, "dcp"); + Directory.CreateDirectory(dcpDir); + + CopyDirectory(dcpPath, dcpDir); + Log($" Copied DCP to dcp"); + + return Task.CompletedTask; + } + + public async Task CreateArchiveAsync() + { + var archiveName = $"aspire-{_version}-{_rid}"; + var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase); + var archiveExt = isWindows ? ".zip" : ".tar.gz"; + var archivePath = Path.Combine(Path.GetDirectoryName(_outputPath)!, archiveName + archiveExt); + + Log($"Creating archive: {archivePath}"); + + if (isWindows) + { + // Use PowerShell for zip + var psi = new ProcessStartInfo + { + FileName = "powershell", + Arguments = $"-NoProfile -Command \"Compress-Archive -Path '{_outputPath}\\*' -DestinationPath '{archivePath}' -Force\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using var process = Process.Start(psi); + if (process is not null) + { + await process.WaitForExitAsync().ConfigureAwait(false); + if (process.ExitCode != 0) + { + var stderr = await process.StandardError.ReadToEndAsync().ConfigureAwait(false); + throw new InvalidOperationException($"Failed to create archive (exit code {process.ExitCode}): {stderr}"); + } + } + } + else + { + // Use tar for tar.gz + var psi = new ProcessStartInfo + { + FileName = "tar", + Arguments = $"-czf \"{archivePath}\" -C \"{Path.GetDirectoryName(_outputPath)}\" \"{Path.GetFileName(_outputPath)}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using var process = Process.Start(psi); + if (process is not null) + { + await process.WaitForExitAsync().ConfigureAwait(false); + if (process.ExitCode != 0) + { + var stderr = await process.StandardError.ReadToEndAsync().ConfigureAwait(false); + throw new InvalidOperationException($"Failed to create archive (exit code {process.ExitCode}): {stderr}"); + } + } + } + + Log($"Archive created: {archivePath}"); + } + + private string? FindPublishPath(string projectName) + { + // Look for publish output in standard locations + // Order matters - RID-specific single-file publish paths should come first + var searchPaths = new[] + { + // Native AOT output (aspire CLI uses this) + Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", _rid, "native"), + // RID-specific single-file publish output (preferred) + Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", _rid, "publish"), + Path.Combine(_artifactsPath, "bin", projectName, "Release", "net8.0", _rid, "publish"), + // Standard publish output + Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", "publish"), + // Arcade SDK output + Path.Combine(_artifactsPath, "bin", projectName, "Release", _rid), + Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0"), + // net8.0 for Dashboard (it targets net8.0) + Path.Combine(_artifactsPath, "bin", projectName, "Release", "net8.0", "publish"), + Path.Combine(_artifactsPath, "bin", projectName, "Release", "net8.0"), + // Debug fallback + Path.Combine(_artifactsPath, "bin", projectName, "Debug", "net10.0", _rid, "native"), + Path.Combine(_artifactsPath, "bin", projectName, "Debug", "net10.0", _rid, "publish"), + Path.Combine(_artifactsPath, "bin", projectName, "Debug", "net10.0", "publish"), + }; + + foreach (var path in searchPaths) + { + if (Directory.Exists(path)) + { + return path; + } + } + + return null; + } + + private static string? FindSharedRuntime() + { + // Look for .NET runtime in common locations + var dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT"); + if (!string.IsNullOrEmpty(dotnetRoot)) + { + var sharedPath = Path.Combine(dotnetRoot, "shared", "Microsoft.NETCore.App"); + if (Directory.Exists(sharedPath)) + { + // Find the latest version + var versions = Directory.GetDirectories(sharedPath) + .OrderByDescending(d => d) + .FirstOrDefault(); + if (versions is not null) + { + return versions; + } + } + } + + return null; + } + + private string? FindDcpPath() + { + // DCP is in NuGet packages as Microsoft.DeveloperControlPlane.{os}-{arch} + var nugetPackages = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".nuget", "packages"); + + // Map RID to DCP package name format + // win-x64 -> windows-amd64, linux-x64 -> linux-amd64, osx-arm64 -> darwin-arm64 + var dcpRid = _rid.ToLowerInvariant() switch + { + "win-x64" => "windows-amd64", + "win-arm64" => "windows-arm64", + "linux-x64" => "linux-amd64", + "linux-arm64" => "linux-arm64", + "linux-musl-x64" => "linux-musl-amd64", + "osx-x64" => "darwin-amd64", + "osx-arm64" => "darwin-arm64", + _ => _rid.ToLowerInvariant() + }; + + var dcpPackageName = $"microsoft.developercontrolplane.{dcpRid}"; + var dcpPackagePath = Path.Combine(nugetPackages, dcpPackageName); + + if (Directory.Exists(dcpPackagePath)) + { + // Find latest version (semantic versioning aware) + var latestVersion = Directory.GetDirectories(dcpPackagePath) + .Select(d => (Path: d, Version: Version.TryParse(Path.GetFileName(d), out var v) ? v : null)) + .Where(x => x.Version is not null) + .OrderByDescending(x => x.Version) + .FirstOrDefault(); + + if (latestVersion.Path is not null) + { + var toolsPath = Path.Combine(latestVersion.Path, "tools"); + if (Directory.Exists(toolsPath)) + { + Log($" Found DCP at {toolsPath}"); + return toolsPath; + } + } + } + + // Fallback: try the old Aspire.Hosting.Orchestration.{rid} package name + var oldPackageName = $"aspire.hosting.orchestration.{_rid.ToLowerInvariant()}"; + var oldPackagePath = Path.Combine(nugetPackages, oldPackageName); + + if (Directory.Exists(oldPackagePath)) + { + var latestVersion = Directory.GetDirectories(oldPackagePath) + .OrderByDescending(d => d) + .FirstOrDefault(); + + if (latestVersion is not null) + { + var toolsPath = Path.Combine(latestVersion, "tools"); + if (Directory.Exists(toolsPath)) + { + Log($" Found DCP at {toolsPath} (legacy package)"); + return toolsPath; + } + } + } + + return null; + } + + private static void CopyDirectory(string source, string destination) + { + Directory.CreateDirectory(destination); + + foreach (var file in Directory.GetFiles(source)) + { + var destFile = Path.Combine(destination, Path.GetFileName(file)); + File.Copy(file, destFile, overwrite: true); + } + + foreach (var dir in Directory.GetDirectories(source)) + { + var destDir = Path.Combine(destination, Path.GetFileName(dir)); + CopyDirectory(dir, destDir); + } + } + + private static async Task SetExecutableAsync(string path) + { + var psi = new ProcessStartInfo + { + FileName = "chmod", + Arguments = $"+x \"{path}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + using var process = Process.Start(psi); + if (process is not null) + { + await process.WaitForExitAsync().ConfigureAwait(false); + } + } + + private void EnableRollForwardForAllTools() + { + Log("Enabling RollForward=Major for all tools..."); + + // Find all runtimeconfig.json files in the bundle + var runtimeConfigFiles = Directory.GetFiles(_outputPath, "*.runtimeconfig.json", SearchOption.AllDirectories); + + foreach (var configFile in runtimeConfigFiles) + { + try + { + var json = File.ReadAllText(configFile); + using var doc = JsonDocument.Parse(json); + + // Check if rollForward is already set + if (doc.RootElement.TryGetProperty("runtimeOptions", out var runtimeOptions) && + !runtimeOptions.TryGetProperty("rollForward", out _)) + { + // Add rollForward: Major to the runtimeOptions + var updatedJson = json.Replace( + "\"runtimeOptions\": {", + "\"runtimeOptions\": {\n \"rollForward\": \"Major\","); + File.WriteAllText(configFile, updatedJson); + Log($" Updated: {Path.GetRelativePath(_outputPath, configFile)}"); + } + } + catch (Exception ex) + { + Log($" WARNING: Failed to update {configFile}: {ex.Message}"); + } + } + } + + private void Log(string message) + { + if (_verbose || !message.StartsWith(" ")) + { + Console.WriteLine(message); + } + } +} + +#region JSON Models + +[JsonSerializable(typeof(JsonElement))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true)] +internal sealed partial class LayoutJsonContext : JsonSerializerContext +{ +} + +#endregion diff --git a/tools/CreateLayout/README.md b/tools/CreateLayout/README.md new file mode 100644 index 00000000000..2178e18df09 --- /dev/null +++ b/tools/CreateLayout/README.md @@ -0,0 +1,150 @@ +# CreateLayout Tool + +This tool creates the Aspire bundle layout for distribution. It assembles all components (CLI, Dashboard, DCP, runtime, and tools) into a self-contained package that can run without requiring a globally-installed .NET SDK. + +## Purpose + +The bundle layout enables polyglot app hosts (TypeScript, Python, Go, etc.) to use Aspire without needing a .NET SDK installed. The bundle includes: + +- **Aspire CLI** - Native AOT compiled command-line interface +- **.NET Runtime** - Shared runtime for managed components +- **Dashboard** - Blazor-based monitoring UI +- **DCP** - Developer Control Plane (orchestrator) +- **AppHost Server** - Pre-built server for running app models +- **NuGet Helper** - Package search and restore operations +- **Dev-certs** - HTTPS certificate management + +## Prerequisites + +Before running CreateLayout, you must: + +1. Build the Aspire solution with the required components published +2. Have the following publish outputs available in the artifacts directory: + - `Aspire.Cli.NuGetHelper` → `artifacts/bin/Aspire.Cli.NuGetHelper/{config}/{tfm}/publish/` + - `Aspire.Hosting.RemoteHost` → `artifacts/bin/Aspire.Hosting.RemoteHost/{config}/{tfm}/publish/` + - `Aspire.Dashboard` → `artifacts/bin/Aspire.Dashboard/{config}/{tfm}/publish/` + +The build scripts (`./build.sh -bundle` / `./build.cmd -bundle`) handle this automatically. + +## Usage + +```bash +dotnet run --project tools/CreateLayout/CreateLayout.csproj -- [options] +``` + +### Required Options + +| Option | Description | +|--------|-------------| +| `-o, --output ` | Output directory for the layout | +| `-a, --artifacts ` | Path to build artifacts directory | + +### Optional Options + +| Option | Description | +|--------|-------------| +| `-r, --runtime ` | Path to existing .NET runtime to include | +| `--rid ` | Runtime identifier (default: current platform) | +| `--bundle-version ` | Version string for the layout | +| `--download-runtime` | Download .NET and ASP.NET runtimes from Microsoft | +| `--runtime-version ` | Specific .NET SDK version to download | +| `--archive` | Create archive (zip/tar.gz) after building | +| `--verbose` | Enable verbose output | + +### Examples + +**Build layout with runtime download:** +```bash +dotnet run --project tools/CreateLayout/CreateLayout.csproj -- \ + --output ./artifacts/bundle/linux-x64 \ + --artifacts ./artifacts \ + --rid linux-x64 \ + --bundle-version 13.2.0 \ + --download-runtime \ + --archive \ + --verbose +``` + +**Build layout with existing runtime:** +```bash +dotnet run --project tools/CreateLayout/CreateLayout.csproj -- \ + --output ./artifacts/bundle/win-x64 \ + --artifacts ./artifacts \ + --runtime /path/to/dotnet \ + --rid win-x64 +``` + +## Output Structure + +The tool creates the following layout: + +``` +{output}/ +├── aspire[.exe] # Native AOT CLI executable +├── runtime/ # .NET shared runtime +│ ├── dotnet[.exe] +│ └── shared/ +│ ├── Microsoft.NETCore.App/{version}/ +│ └── Microsoft.AspNetCore.App/{version}/ +├── dashboard/ # Aspire Dashboard (framework-dependent) +├── dcp/ # DCP binaries +├── aspire-server/ # Pre-built AppHost server (framework-dependent) +└── tools/ + ├── aspire-nuget/ # NuGet helper tool + └── dev-certs/ # Certificate management +``` + +## How It Works + +1. **Copies CLI** - Finds the native AOT compiled CLI from artifacts and copies to root +2. **Downloads/Copies Runtime** - Either downloads from Microsoft or copies from specified path +3. **Copies Dashboard** - Copies the published Dashboard output +4. **Copies DCP** - Finds DCP binaries from NuGet package restore output +5. **Copies AppHost Server** - Copies the published RemoteHost (server) output +6. **Copies NuGet Helper** - Copies the published NuGet helper tool +7. **Copies Dev-certs** - Copies the dev-certs tool from SDK +8. **Creates Archive** - Optionally creates .zip (Windows) or .tar.gz (Linux/macOS) + +## Runtime Download + +When `--download-runtime` is specified, the tool: + +1. Downloads the .NET SDK from `builds.dotnet.microsoft.com` (using `--runtime-version` for the SDK version) +2. Extracts the .NET runtime and ASP.NET Core runtime from the SDK to the `runtime/` directory +3. Extracts the `dotnet-dev-certs` tool from the SDK to `tools/dev-certs/` + +## Integration with Build Scripts + +The recommended way to build the bundle is through the main build scripts: + +**Linux/macOS:** +```bash +./build.sh -bundle +``` + +**Windows:** +```powershell +.\build.cmd -bundle +``` + +These scripts handle: +- Building the solution +- Publishing bundle components +- Running CreateLayout with appropriate arguments + +## Troubleshooting + +### "AppHost Server publish output not found" +Run `dotnet publish` on `Aspire.Hosting.RemoteHost` first: +```bash +dotnet publish src/Aspire.Hosting.RemoteHost/Aspire.Hosting.RemoteHost.csproj -c Release +``` + +### "Dashboard publish output not found" +Run `dotnet publish` on `Aspire.Dashboard` first: +```bash +dotnet publish src/Aspire.Dashboard/Aspire.Dashboard.csproj -c Release +``` + +### "DCP not found" +DCP binaries come from the NuGet package. Ensure the solution has been restored and built. diff --git a/tools/scripts/.gitignore b/tools/scripts/.gitignore new file mode 100644 index 00000000000..e9da6cb9a69 --- /dev/null +++ b/tools/scripts/.gitignore @@ -0,0 +1,6 @@ +.output/ + +# Downloaded CI failure logs and artifacts +failed_job_*.log +artifact_*.zip +artifact_*/ From ab4cebc6c77a90769d0f30c33b5855a0f9c9fe6a Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 12:30:12 +1100 Subject: [PATCH 064/256] AKS E2E tests: Redis variant, port fix, and reliability improvements (#14371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add AKS starter deployment E2E test (Phase 1) This adds a new end-to-end deployment test that validates Azure Kubernetes Service (AKS) infrastructure creation: - Creates resource group, ACR, and AKS cluster - Configures kubectl credentials - Verifies cluster connectivity - Cleans up resources after test Phase 1 focuses on infrastructure only - Aspire deployment will be added in subsequent phases. * Fix AKS test: register required resource providers Add step to register Microsoft.ContainerService and Microsoft.ContainerRegistry resource providers before attempting to create AKS resources. This fixes the MissingSubscriptionRegistration error when the subscription hasn't been configured for AKS usage. * Fix AKS test: use Standard_B2s_v2 VM size The subscription in westus3 doesn't have access to Standard_B2s, only the v2 series VMs. Changed to Standard_B2s_v2 which is available. * Fix AKS test: use Standard_D2s_v3 VM size The subscription has zero quota for B-series VMs in westus3. Changed to Standard_D2s_v3 which is a widely-available D-series VM with typical quota. * Add Phase 2 & 3: Aspire project creation, Helm chart generation, and AKS deployment Phase 2 additions: - Create Aspire starter project using 'aspire new' - Add Aspire.Hosting.Kubernetes package via 'aspire add' - Modify AppHost.cs to call AddKubernetesEnvironment() with ACR config - Login to ACR for Docker image push - Run 'aspire publish' to generate Helm charts and push images Phase 3 additions: - Deploy Helm chart to AKS using 'helm install' - Verify pods are running with kubectl - Verify deployments are healthy This completes the full end-to-end flow: AKS cluster creation -> Aspire project creation -> Helm chart generation -> Deployment to Kubernetes * Fix Kubernetes deployment: Add container build/push step Changes: - Remove invalid ContainerRegistry property from AddKubernetesEnvironment - Add pragma warning disable for experimental ASPIREPIPELINES001 - Add container build step using dotnet publish /t:PublishContainer - Push container images to ACR before Helm deployment - Override Helm image values with ACR image references The Kubernetes publisher generates Helm charts but doesn't build containers. We need to build and push containers separately using dotnet publish. * Fix duplicate Service ports in Kubernetes publisher When multiple endpoints resolve to the same port number, the Service manifest generator was creating duplicate port entries, which Kubernetes rejects as invalid. This fix deduplicates ports by (port, protocol) before adding them to the Service spec. Fixes the error: Service 'xxx-service' is invalid: spec.ports[1]: Duplicate value * Add explicit AKS-ACR attachment verification step Added Step 6 to explicitly run 'az aks update --attach-acr' after AKS cluster creation to ensure the AcrPull role assignment has properly propagated. This addresses potential image pull permission issues where AKS cannot pull images from the attached ACR. Also renumbered all subsequent steps to maintain proper ordering. * Fix AKS image pull: correct Helm value paths and add ACR check * Fix duplicate Service/container ports: compare underlying values not Helm expressions * Re-enable AppService deployment tests * Add endpoint verification via kubectl port-forward to AKS test * Wait for pods to be ready before port-forward verification * Use retry loop for health endpoint verification and log HTTP status codes * Use real app endpoints: /weatherforecast and / instead of /health * Improve comments explaining duplicate port dedup rationale * Refactor cleanup to async pattern matching other deployment tests * Fix duplicate K8s ports: skip DefaultHttpsEndpoint in ProcessEndpoints The Kubernetes publisher was generating duplicate Service/container ports (both 8080/TCP) for ProjectResources with default http+https endpoints. The root cause is that GenerateDefaultProjectEndpointMapping assigns the same default port 8080 to every endpoint with None target port. The proper fix mirrors the core framework's SetBothPortsEnvVariables() behavior: skip the DefaultHttpsEndpoint (which the container won't listen on — TLS termination happens at ingress/service mesh). The https endpoint still gets an EndpointMapping (for service discovery) but reuses the http endpoint's HelmValue, so no duplicate K8s port is generated. Added Aspire.Hosting.Kubernetes to InternalsVisibleTo to access ProjectResource.DefaultHttpsEndpoint. The downstream dedup in ToService() and WithContainerPorts() remains as defense-in-depth. Fixes https://github.com/dotnet/aspire/issues/14029 * Add AKS + Redis E2E deployment test Validates the Aspire starter template with Redis cache enabled deploys correctly to AKS. Exercises the full pipeline: webfrontend → apiservice → Redis by hitting the /weather page (SSR, uses Redis output caching). Key differences from the base AKS test: - Selects 'Yes' for Redis Cache in aspire new prompts - Redis uses public container image (no ACR push needed) - Verifies /weather page content (confirms Redis integration works) * Fix ACR name collision between parallel AKS tests Both AKS tests generated the same ACR name from RunId+RunAttempt. Use different prefixes (acrs/acrr) to ensure uniqueness. * Fix Redis Helm deployment: provide missing cross-resource secret value Work around K8s publisher bug where cross-resource secret references create Helm value paths under the consuming resource instead of referencing the owning resource's secret. The webfrontend template expects secrets.webfrontend.cache_password but values.yaml only has secrets.cache.REDIS_PASSWORD. Provide the missing value via --set. * Move ACR login before AKS creation to avoid OIDC token expiration The OIDC federated token expires after ~5 minutes, but AKS cluster creation takes 10-15 minutes. By the time the test reaches az acr login, the assertion is stale. Moving ACR auth to right after ACR creation ensures the OIDC token is still fresh, and Docker credentials persist in ~/.docker/config.json for later use. * Add pod diagnostics for Redis test: accept kubectl wait failure gracefully The kubectl wait step was blocking the test when Redis pods failed to start. Now accepts either OK or ERR exit, captures pod logs for diagnostics, and continues to verify what we can. * Wait only for project resource pods, skip Redis (K8s publisher bug #14370) Redis container crashes with 'cannot open redis-server: No such file' due to incorrect container command generated by the K8s publisher. The webfrontend handles Redis being unavailable gracefully. Wait only for apiservice and webfrontend pods using label selectors, and capture Redis pod logs for diagnostics. * Remove redundant weather page grep check (Blazor SSR streaming issue) The curl -sf /weather | grep 'Weather' step fails because Blazor SSR streaming rendering returns incomplete initial HTML via curl. The /weather endpoint already returns 200 (verified in previous step), which is sufficient to confirm the full pipeline works. * Add --max-time 10 to /weather curl (Blazor SSR streaming keeps connection open) Blazor SSR streaming rendering keeps the HTTP connection open to stream updates to the browser. curl waits indefinitely for the response to complete, causing the WaitForSuccessPrompt to time out. Adding --max-time ensures curl returns after receiving the initial 200 status code. * Fix /weather curl: capture status code in variable to handle SSR streaming curl --max-time exits with code 28 (timeout) even when HTTP 200 was received, because Blazor SSR streaming keeps the connection open. This causes the && chain to fail, so echo/break never execute. Fix by using semicolons and capturing the status code in a variable, then checking it explicitly with [ "$S" = "200" ]. * Fix K8s publisher: set ExecutionContext on CommandLineArgsCallbackContext The K8s publisher was not setting ExecutionContext when creating the CommandLineArgsCallbackContext in ProcessArgumentsAsync, causing it to default to Run mode. This made Redis's WithArgs callback produce individual args instead of a single -c shell command string, resulting in '/bin/sh redis-server' (open as script) instead of '/bin/sh -c "redis-server ..."' (execute as command). Matches the Docker Compose publisher which correctly sets ExecutionContext = executionContext. Also updates the Redis E2E test to wait for all pods (including cache) and verify Redis responds to PING. * Avoid leaking Redis password in test logs Expand $REDIS_PASSWORD inside the container shell instead of extracting it from the K8s secret on the host. Also use --no-auth-warning to suppress redis-cli's password-on-command-line warning. * Replace kubectl exec redis-cli with pod status check to avoid container name issue * Set REDIS_PASSWORD in helm install to prevent redis-server --requirepass with empty arg * Fix Helm secret path: use parameter name (cache_password) not env var key (REDIS_PASSWORD) The K8s publisher AllocateParameter creates Helm expressions using the parameter name (cache-password -> cache_password), but AddValuesToHelmSectionAsync writes values using the env var key (REDIS_PASSWORD). The template references .Values.secrets.cache.cache_password but values.yaml has REDIS_PASSWORD, so the password is always empty and Redis crashes with 'requirepass' having no argument. * Use dynamically generated GUID for Redis password instead of hardcoded value * Pass same Redis password to webfrontend so it can authenticate to Redis cache --------- Co-authored-by: Mitch Denny --- .../KubernetesResource.cs | 5 +- .../AksStarterDeploymentTests.cs | 17 +++-- .../AksStarterWithRedisDeploymentTests.cs | 67 +++++++++++-------- 3 files changed, 55 insertions(+), 34 deletions(-) diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs index e0f959a150e..c7615693728 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesResource.cs @@ -287,7 +287,10 @@ private async Task ProcessArgumentsAsync(KubernetesEnvironmentContext environmen { if (resource.TryGetAnnotationsOfType(out var commandLineArgsCallbackAnnotations)) { - var context = new CommandLineArgsCallbackContext([], resource, cancellationToken: cancellationToken); + var context = new CommandLineArgsCallbackContext([], resource, cancellationToken: cancellationToken) + { + ExecutionContext = executionContext + }; foreach (var c in commandLineArgsCallbackAnnotations) { diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index 3b8b1a58bed..fc21e5ccfc0 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -139,6 +139,15 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + // Step 4b: Login to ACR immediately (before AKS creation which takes 10-15 min). + // The OIDC federated token expires after ~5 minutes, so we must authenticate with + // ACR while it's still fresh. Docker credentials persist in ~/.docker/config.json. + output.WriteLine("Step 4b: Logging into Azure Container Registry (early, before token expires)..."); + sequenceBuilder + .Type($"az acr login --name {acrName}") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + // Step 5: Create AKS cluster with ACR attached // Using minimal configuration: 1 node, Standard_D2s_v3 (widely available with quota) output.WriteLine("Step 5: Creating AKS cluster (this may take 10-15 minutes)..."); @@ -274,12 +283,8 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Enter() .WaitForSuccessPrompt(counter); - // Step 16: Login to ACR for Docker push - output.WriteLine("Step 16: Logging into Azure Container Registry..."); - sequenceBuilder - .Type($"az acr login --name {acrName}") - .Enter() - .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + // Step 16: ACR login was already done in Step 4b (before AKS creation). + // Docker credentials persist in ~/.docker/config.json. // Step 17: Build and push container images to ACR // The starter template creates webfrontend and apiservice projects diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs index 2da92fa996a..f597e557da7 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs @@ -57,6 +57,7 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can // Generate unique names for Azure resources var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("aksredis"); var clusterName = $"aks-{DeploymentE2ETestHelpers.GetRunId()}-{DeploymentE2ETestHelpers.GetRunAttempt()}"; + var redisPassword = Guid.NewGuid().ToString("N"); // ACR names must be alphanumeric only, 5-50 chars, globally unique var acrName = $"acrr{DeploymentE2ETestHelpers.GetRunId()}{DeploymentE2ETestHelpers.GetRunAttempt()}".ToLowerInvariant(); acrName = new string(acrName.Where(char.IsLetterOrDigit).Take(50).ToArray()); @@ -139,6 +140,15 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + // Step 4b: Login to ACR immediately (before AKS creation which takes 10-15 min). + // The OIDC federated token expires after ~5 minutes, so we must authenticate with + // ACR while it's still fresh. Docker credentials persist in ~/.docker/config.json. + output.WriteLine("Step 4b: Logging into Azure Container Registry (early, before token expires)..."); + sequenceBuilder + .Type($"az acr login --name {acrName}") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + // Step 5: Create AKS cluster with ACR attached output.WriteLine("Step 5: Creating AKS cluster (this may take 10-15 minutes)..."); sequenceBuilder @@ -271,12 +281,8 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can .Enter() .WaitForSuccessPrompt(counter); - // Step 16: Login to ACR for Docker push - output.WriteLine("Step 16: Logging into Azure Container Registry..."); - sequenceBuilder - .Type($"az acr login --name {acrName}") - .Enter() - .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + // Step 16: ACR login was already done in Step 4b (before AKS creation). + // Docker credentials persist in ~/.docker/config.json. // Step 17: Build and push container images to ACR // Only project resources need to be built — Redis uses a public container image @@ -331,25 +337,38 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can // Step 21: Deploy Helm chart to AKS with ACR image overrides // Only project resources need image overrides — Redis uses the public image from the chart - // Note: secrets.webfrontend.cache_password is a workaround for a K8s publisher bug where - // cross-resource secret references create Helm value paths under the consuming resource - // instead of referencing the owning resource's secret path (secrets.cache.REDIS_PASSWORD). + // Note: Two K8s publisher Helm value bugs require workarounds: + // 1. secrets.cache.cache_password: The Helm template expression uses the parameter name + // (cache_password from "cache-password") but values.yaml uses the env var key (REDIS_PASSWORD). + // We must set the parameter name path for the password to reach the K8s Secret. + // 2. secrets.webfrontend.cache_password: Cross-resource secret references create Helm value + // paths under the consuming resource instead of the owning resource (issue #14370). output.WriteLine("Step 21: Deploying Helm chart to AKS..."); sequenceBuilder .Type($"helm install aksredis ../charts --namespace default --wait --timeout 10m " + $"--set parameters.webfrontend.webfrontend_image={acrName}.azurecr.io/webfrontend:latest " + $"--set parameters.apiservice.apiservice_image={acrName}.azurecr.io/apiservice:latest " + - $"--set secrets.webfrontend.cache_password=\"\"") + $"--set secrets.cache.cache_password={redisPassword} " + + $"--set secrets.webfrontend.cache_password={redisPassword}") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(12)); - // Step 22: Wait for all pods to be ready (including Redis) - output.WriteLine("Step 22: Waiting for pods to be ready..."); + // Step 22: Wait for all pods to be ready (including Redis cache) + output.WriteLine("Step 22: Waiting for all pods to be ready..."); sequenceBuilder - .Type("kubectl wait --for=condition=ready pod --all -n default --timeout=120s") + .Type("kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=apiservice --timeout=120s -n default && " + + "kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=webfrontend --timeout=120s -n default && " + + "kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=cache --timeout=120s -n default") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + // Step 22b: Verify Redis container is running and stable (no restarts) + output.WriteLine("Step 22b: Verifying Redis container is stable..."); + sequenceBuilder + .Type("kubectl get pod cache-statefulset-0 -o jsonpath='{.status.containerStatuses[0].ready} restarts:{.status.containerStatuses[0].restartCount}'") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + // Step 23: Verify all pods are running output.WriteLine("Step 23: Verifying pods are running..."); sequenceBuilder @@ -392,29 +411,23 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); // Step 28: Verify webfrontend /weather page (exercises webfrontend → apiservice → Redis pipeline) - // The /weather page is server-side rendered and fetches data from the apiservice. - // Redis output caching is used, so this validates the full Redis integration. + // The /weather page uses Blazor SSR streaming rendering which keeps the HTTP connection open. + // We use -m 5 (max-time) to avoid curl hanging, and capture the status code in a variable + // because --max-time causes curl to exit non-zero (code 28) even on HTTP 200. output.WriteLine("Step 28: Verifying webfrontend /weather page (exercises Redis cache)..."); sequenceBuilder - .Type("for i in $(seq 1 10); do sleep 3 && curl -sf http://localhost:18081/weather -o /dev/null -w '%{http_code}' && echo ' OK' && break; done") + .Type("for i in $(seq 1 10); do sleep 3; S=$(curl -so /dev/null -w '%{http_code}' -m 5 http://localhost:18081/weather); [ \"$S\" = \"200\" ] && echo \"$S OK\" && break; done") .Enter() - .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); - - // Step 29: Verify /weather page actually returns weather data - output.WriteLine("Step 29: Verifying weather page content..."); - sequenceBuilder - .Type("curl -sf http://localhost:18081/weather | grep -q 'Weather' && echo 'Weather page content verified'") - .Enter() - .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(120)); - // Step 30: Clean up port-forwards - output.WriteLine("Step 30: Cleaning up port-forwards..."); + // Step 29: Clean up port-forwards + output.WriteLine("Step 29: Cleaning up port-forwards..."); sequenceBuilder .Type("kill %1 %2 2>/dev/null; true") .Enter() .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(10)); - // Step 31: Exit terminal + // Step 30: Exit terminal sequenceBuilder .Type("exit") .Enter(); From 6e0fa878c605034669c864ff56c9d084af4a560f Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 9 Feb 2026 19:37:31 +1100 Subject: [PATCH 065/256] Update Hex1b packages to 0.78.0 and add dependency-update skill (#14400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add dependency-update skill for external NuGet package updates - SKILL.md: Agent instructions for version lookup, changelog review, user confirmation, and pipeline orchestration - migrate-package.sh: Companion script to trigger and monitor the dotnet-migrate-package Azure DevOps pipeline (def 931) - AGENTS.md: Register new skill in Available Skills section * Rewrite migrate-package script as single-file C# app Replace migrate-package.sh with MigratePackage.cs using: - Azure.Identity (AzureCliCredential) for authentication - Azure DevOps .NET SDK (PipelinesHttpClient) for pipeline triggering via RunPipelineAsync and polling via GetRunAsync - Auto-detection of Microsoft corp tenant for token acquisition Add .editorconfig and Directory.Packages.props overrides to allow standalone #:package directives within the repo's CPM. * Update Hex1b packages to 0.75.0 and add Hex1b.Tool - Hex1b: 0.48.0 → 0.75.0 - Hex1b.McpServer: 0.48.0 → 0.75.0 - Hex1b.Tool: added at 0.75.0 (new dotnet tool) All three packages imported via dotnet-migrate-package pipeline: - Hex1b: Run 2898644 (succeeded) - Hex1b.McpServer: Run 2898645 (succeeded) - Hex1b.Tool: Run 2898650 (succeeded) * Update .github/skills/dependency-update/MigratePackage.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Hex1b.McpServer and Hex1b.Tool to 0.77.0 * Update Hex1b packages to 0.78.0 --------- Co-authored-by: Mitch Denny Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .config/dotnet-tools.json | 6 + .../skills/dependency-update/.editorconfig | 12 + .../Directory.Packages.props | 8 + .../dependency-update/MigratePackage.cs | 423 ++++++++++++++++++ .github/skills/dependency-update/SKILL.md | 254 +++++++++++ AGENTS.md | 1 + Directory.Packages.props | 5 +- 7 files changed, 707 insertions(+), 2 deletions(-) create mode 100644 .github/skills/dependency-update/.editorconfig create mode 100644 .github/skills/dependency-update/Directory.Packages.props create mode 100644 .github/skills/dependency-update/MigratePackage.cs create mode 100644 .github/skills/dependency-update/SKILL.md diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 451b12b8800..5724a00943b 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -13,6 +13,12 @@ "commands": [ "pwsh" ] + }, + "hex1b.tool": { + "version": "0.78.0", + "commands": [ + "hex1b" + ] } } } diff --git a/.github/skills/dependency-update/.editorconfig b/.github/skills/dependency-update/.editorconfig new file mode 100644 index 00000000000..54135adebee --- /dev/null +++ b/.github/skills/dependency-update/.editorconfig @@ -0,0 +1,12 @@ +# Override repo-level analyzers for standalone tool scripts +root = true + +[*.cs] +# Disable file header requirement +dotnet_diagnostic.IDE0073.severity = none +# Disable unused using warning (script may need conditional usings) +dotnet_diagnostic.IDE0005.severity = suggestion +# Disable ConfigureAwait requirement (not needed in console apps) +dotnet_diagnostic.CA2007.severity = none +# Disable locale-sensitive parsing warning +dotnet_diagnostic.CA1305.severity = none diff --git a/.github/skills/dependency-update/Directory.Packages.props b/.github/skills/dependency-update/Directory.Packages.props new file mode 100644 index 00000000000..886fb9b1236 --- /dev/null +++ b/.github/skills/dependency-update/Directory.Packages.props @@ -0,0 +1,8 @@ + + + + false + + diff --git a/.github/skills/dependency-update/MigratePackage.cs b/.github/skills/dependency-update/MigratePackage.cs new file mode 100644 index 00000000000..ff50ebaaf68 --- /dev/null +++ b/.github/skills/dependency-update/MigratePackage.cs @@ -0,0 +1,423 @@ +// Triggers and monitors the dotnet-migrate-package Azure DevOps pipeline. +// Usage: dotnet MigratePackage.cs [options] +// +// Options: +// --poll-interval Polling interval (default: 30) +// --timeout Max wait time (default: 900) +// --migration-type Pipeline migration type (default: "New or non-Microsoft") +// --no-wait Trigger only, don't wait for completion +// --check-prereqs Check prerequisites and exit +// +// Requires: Azure CLI logged in (`az login`) to a tenant with access to the dnceng Azure DevOps org. + +#:package Microsoft.TeamFoundationServer.Client@19.* +#:package Microsoft.VisualStudio.Services.Client@19.* +#:package Azure.Identity@1.* + +using System.Diagnostics; +using System.Text.Json; +using Azure.Core; +using Azure.Identity; +using Microsoft.Azure.Pipelines.WebApi; +using Microsoft.VisualStudio.Services.OAuth; +using Microsoft.VisualStudio.Services.WebApi; + +const string AzDoOrg = "https://dev.azure.com/dnceng"; +const string AzDoProject = "internal"; +const int PipelineId = 931; +const string AzDoScope = "499b84ac-1321-427f-aa17-267ca6975798/.default"; + +// Parse arguments +string? packageName = null; +string? packageVersion = null; +string migrationType = "New or non-Microsoft"; +int pollInterval = 30; +int timeout = 900; +bool noWait = false; +bool checkPrereqs = false; + +for (int i = 0; i < args.Length; i++) +{ + switch (args[i]) + { + case "--help" or "-h": + PrintUsage(); + return; + case "--check-prereqs": + checkPrereqs = true; + break; + case "--poll-interval": + pollInterval = int.Parse(args[++i]); + break; + case "--timeout": + timeout = int.Parse(args[++i]); + break; + case "--migration-type": + migrationType = args[++i]; + break; + case "--no-wait": + noWait = true; + break; + default: + if (args[i].StartsWith('-')) + { + LogError($"Unknown option: {args[i]}"); + PrintUsage(); + return; + } + if (packageName is null) + { + packageName = args[i]; + } + else if (packageVersion is null) + { + packageVersion = args[i]; + } + else + { + LogError($"Unexpected argument: {args[i]}"); + PrintUsage(); + return; + } + break; + } +} + +if (checkPrereqs) +{ + await CheckPrerequisitesAsync(verbose: true); + return; +} + +if (packageName is null || packageVersion is null) +{ + LogError("PackageName and PackageVersion are required."); + Console.WriteLine(); + PrintUsage(); + return; +} + +// Check prerequisites +if (!await CheckPrerequisitesAsync(verbose: true)) +{ + return; +} + +Console.WriteLine(); + +// Connect and trigger +var client = await ConnectAsync(); +if (client is null) +{ + return; +} + +var run = await TriggerPipelineAsync(client, packageName, packageVersion, migrationType); +if (run is null) +{ + return; +} + +if (noWait) +{ + LogInfo($"Skipping wait (--no-wait). Monitor at:"); + LogInfo($" {AzDoOrg}/{AzDoProject}/_build/results?buildId={run.Id}"); + return; +} + +Console.WriteLine(); + +// Poll until completion +await PollPipelineAsync(client, run.Id, pollInterval, timeout); + +// --- Functions --- + +async Task ConnectAsync() +{ + try + { + var tenantId = await GetAzCliTenantIdAsync(); + var credential = tenantId is not null + ? new AzureCliCredential(new AzureCliCredentialOptions { TenantId = tenantId }) + : new AzureCliCredential(); + + var token = await credential.GetTokenAsync(new TokenRequestContext([AzDoScope])); + var vssCred = new VssOAuthAccessTokenCredential(token.Token); + var connection = new VssConnection(new Uri(AzDoOrg), vssCred); + return connection.GetClient(); + } + catch (Exception ex) + { + LogError($"Failed to connect to Azure DevOps: {ex.Message}"); + LogError("Ensure you are logged in with `az login` to a tenant that has access to the dnceng org."); + return null; + } +} + +async Task TriggerPipelineAsync(PipelinesHttpClient client, string name, string version, string type) +{ + LogInfo("Triggering dotnet-migrate-package pipeline..."); + LogInfo($" Package: {name}"); + LogInfo($" Version: {version}"); + LogInfo($" MigrationType: {type}"); + + try + { + var parameters = new RunPipelineParameters + { + TemplateParameters = new Dictionary + { + ["PackageNames"] = name, + ["PackageVersion"] = version, + ["MigrationType"] = type + } + }; + + var run = await client.RunPipelineAsync(parameters, AzDoProject, PipelineId); + + LogSuccess("Pipeline triggered successfully"); + LogInfo($" Run ID: {run.Id}"); + LogInfo($" URL: {AzDoOrg}/{AzDoProject}/_build/results?buildId={run.Id}"); + + return run; + } + catch (Exception ex) + { + LogError($"Failed to trigger pipeline: {ex.Message}"); + return null; + } +} + +async Task PollPipelineAsync(PipelinesHttpClient client, int runId, int interval, int maxWait) +{ + LogInfo($"Polling pipeline run {runId} (interval: {interval}s, timeout: {maxWait}s)..."); + + var sw = Stopwatch.StartNew(); + + while (true) + { + if (sw.Elapsed.TotalSeconds >= maxWait) + { + LogError($"Timeout after {maxWait}s waiting for pipeline run {runId}"); + return; + } + + try + { + var run = await client.GetRunAsync(AzDoProject, PipelineId, runId); + var elapsed = sw.Elapsed; + var elapsedStr = $"{(int)elapsed.TotalMinutes}m{elapsed.Seconds}s"; + + if (run.State == RunState.Completed) + { + if (run.Result == RunResult.Succeeded) + { + LogSuccess($"Pipeline completed successfully ({elapsedStr})"); + } + else + { + LogError($"Pipeline completed with result: {run.Result} ({elapsedStr})"); + LogError($"See: {AzDoOrg}/{AzDoProject}/_build/results?buildId={runId}"); + } + return; + } + + LogInfo($" Status: {run.State} (elapsed: {elapsedStr})..."); + } + catch (Exception ex) + { + LogWarn($" Poll error (will retry): {ex.Message}"); + } + + await Task.Delay(TimeSpan.FromSeconds(interval)); + } +} + +async Task CheckPrerequisitesAsync(bool verbose) +{ + var ok = true; + + // Check az CLI + if (!await IsCommandAvailableAsync("az")) + { + if (verbose) + { + LogError("Azure CLI (az) is not installed."); + Console.WriteLine(" Install: https://learn.microsoft.com/cli/azure/install-azure-cli"); + } + ok = false; + } + else if (verbose) + { + var (_, version) = await RunProcessAsync("az", "version --query \"azure-cli\" -o tsv"); + LogSuccess($"Azure CLI found: {version.Trim()}"); + } + + // Check az login status + var (loginSuccess, loginOutput) = await RunProcessAsync("az", "account show --query user.name -o tsv"); + if (!loginSuccess) + { + if (verbose) + { + LogError("Not logged in to Azure CLI."); + Console.WriteLine(" Login: az login"); + } + ok = false; + } + else if (verbose) + { + LogSuccess($"Logged in as: {loginOutput.Trim()}"); + } + + // Check tenant + if (loginSuccess) + { + var tenantId = await GetAzCliTenantIdAsync(); + if (verbose && tenantId is not null) + { + LogSuccess($"Tenant: {tenantId}"); + } + } + + // Try to get a token for Azure DevOps + if (loginSuccess) + { + try + { + var tenantId = await GetAzCliTenantIdAsync(); + var credential = tenantId is not null + ? new AzureCliCredential(new AzureCliCredentialOptions { TenantId = tenantId }) + : new AzureCliCredential(); + await credential.GetTokenAsync(new TokenRequestContext([AzDoScope])); + if (verbose) + { + LogSuccess("Azure DevOps token acquired successfully"); + } + } + catch (Exception ex) + { + if (verbose) + { + LogError($"Failed to acquire Azure DevOps token: {ex.Message}"); + Console.WriteLine(" You may need to log in to the correct tenant: az login --tenant "); + } + ok = false; + } + } + + if (verbose) + { + if (ok) + { + LogSuccess("All prerequisites met"); + } + else + { + LogError("Some prerequisites are missing. See above for details."); + } + } + + return ok; +} + +async Task GetAzCliTenantIdAsync() +{ + // Discover the tenant from the current az CLI session. + // The dnceng org is in the Microsoft tenant; if the user is logged into + // a different tenant we attempt to find the Microsoft corp one. + var (success, output) = await RunProcessAsync("az", "account show --query tenantId -o tsv"); + if (!success) + { + return null; + } + + var currentTenant = output.Trim(); + + // If already on the Microsoft corp tenant, use it directly + if (string.Equals(currentTenant, "72f988bf-86f1-41af-91ab-2d7cd011db47", global::System.StringComparison.OrdinalIgnoreCase)) + { + return currentTenant; + } + + // Check if the Microsoft corp tenant is available in the account list + var (listOk, listOutput) = await RunProcessAsync("az", "account list --query \"[?tenantId=='72f988bf-86f1-41af-91ab-2d7cd011db47'].tenantId | [0]\" -o tsv"); + if (listOk && !string.IsNullOrWhiteSpace(listOutput)) + { + return listOutput.Trim(); + } + + return currentTenant; +} + +async Task IsCommandAvailableAsync(string command) +{ + try + { + var (success, _) = await RunProcessAsync(command, "--version"); + return success; + } + catch + { + return false; + } +} + +async Task<(bool Success, string Output)> RunProcessAsync(string fileName, string arguments) +{ + using var process = Process.Start(new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }); + + if (process is null) + { + return (false, string.Empty); + } + + var output = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + return (process.ExitCode == 0, output); +} + +void PrintUsage() +{ + Console.WriteLine(""" + MigratePackage.cs — Trigger and monitor the dotnet-migrate-package Azure DevOps pipeline + + USAGE: + dotnet MigratePackage.cs [OPTIONS] + dotnet MigratePackage.cs --check-prereqs + dotnet MigratePackage.cs --help + + ARGUMENTS: + PackageName NuGet package ID (e.g., Hex1b) + PackageVersion Version to import (e.g., 0.49.0) or "latest" + + OPTIONS: + --poll-interval Polling interval (default: 30) + --timeout Max wait time (default: 900) + --migration-type Pipeline migration type (default: "New or non-Microsoft") + --no-wait Trigger only, don't wait for completion + --check-prereqs Check prerequisites and exit + --help Show this help + + AUTHENTICATION: + Uses Azure.Identity (AzureCliCredential) to acquire a token for Azure DevOps. + Ensure you are logged in with: az login + The script will automatically select the Microsoft corp tenant if available. + + EXAMPLES: + dotnet MigratePackage.cs Hex1b 0.49.0 + dotnet MigratePackage.cs StackExchange.Redis 2.9.33 --no-wait + dotnet MigratePackage.cs --check-prereqs + """); +} + +void LogInfo(string message) => Console.WriteLine($"\u001b[36m[INFO]\u001b[0m {message}"); +void LogSuccess(string message) => Console.WriteLine($"\u001b[32m[OK]\u001b[0m {message}"); +void LogWarn(string message) => Console.WriteLine($"\u001b[33m[WARN]\u001b[0m {message}"); +void LogError(string message) => Console.Error.WriteLine($"\u001b[31m[ERROR]\u001b[0m {message}"); diff --git a/.github/skills/dependency-update/SKILL.md b/.github/skills/dependency-update/SKILL.md new file mode 100644 index 00000000000..0f99b55f5a0 --- /dev/null +++ b/.github/skills/dependency-update/SKILL.md @@ -0,0 +1,254 @@ +--- +name: dependency-update +description: Guides dependency version updates by checking nuget.org for latest versions, triggering the dotnet-migrate-package Azure DevOps pipeline, and monitoring runs. Use this when asked to update external NuGet dependencies. +--- + +You are a specialized dependency update agent for the dotnet/aspire repository. Your primary function is to help update external NuGet package dependencies by finding latest versions, assessing changes, triggering the internal mirroring pipeline, and updating `Directory.Packages.props`. + +## Background + +External NuGet dependencies (e.g., Hex1b, StackExchange.Redis, Confluent.Kafka) cannot be directly consumed from nuget.org in the internal build. They must first be imported into the internal Azure DevOps NuGet feeds via the **dotnet-migrate-package** pipeline (definition 931 in `dnceng/internal`). This skill automates that workflow. + +### Pipeline Details + +- **Organization**: `https://dev.azure.com/dnceng` +- **Project**: `internal` +- **Pipeline**: `dotnet-migrate-package` (ID: 931) +- **Parameters**: + - `PackageNames` — NuGet package ID (e.g., `Hex1b`) + - `PackageVersion` — Version to import (e.g., `0.49.0`) or `latest` + - `MigrationType` — Use `New or non-Microsoft` for external dependencies + +### Companion Script + +A single-file C# app is bundled alongside this skill at `.github/skills/dependency-update/MigratePackage.cs`. It uses the Azure DevOps .NET SDK (`PipelinesHttpClient`) with `Azure.Identity` for authentication. Use it to trigger and monitor pipeline runs — it handles prerequisite checks, pipeline triggering, and polling. + +## Understanding User Requests + +Parse user requests to identify: + +1. **Package name(s)** — Specific packages (e.g., "update Hex1b") or categories (e.g., "update all HealthChecks packages") +2. **Target version** — Specific version or "latest" (default behavior) +3. **Scope** — Single package, a family of packages, or all external dependencies + +### Example Requests + +**Single package:** +> Update Hex1b to the latest version + +**Package family:** +> Update the Azure.Provisioning packages + +**All external:** +> What external dependencies have updates available? + +**Specific version:** +> Update StackExchange.Redis to 2.10.0 + +## Task Execution Steps + +### 1. Identify Packages to Update + +Locate the target packages in `Directory.Packages.props` at the repository root. This file uses Central Package Management with `` elements. + +```bash +# Find all versions of a specific package +grep -i "PackageVersion.*Include=\"Hex1b" Directory.Packages.props + +# Find all external dependencies (the "external dependencies" section) +sed -n '//,//p' Directory.Packages.props +``` + +For each package, extract: +- Package ID (the `Include` attribute) +- Current version (the `Version` attribute) + +### 2. Look Up Latest Versions on nuget.org + +For each package, query the nuget.org API to find available versions: + +```bash +# Get all versions for a package +curl -s "https://api.nuget.org/v3-flatcontainer/{package-id-lowercase}/index.json" | python3 -c " +import json, sys +data = json.load(sys.stdin) +versions = data['versions'] + +# Separate stable and pre-release +stable = [v for v in versions if '-' not in v] +prerelease = [v for v in versions if '-' in v] + +print('Latest stable:', stable[-1] if stable else 'none') +print('Latest pre-release:', prerelease[-1] if prerelease else 'none') +" +``` + +**Version selection guidance:** + +- **Default to latest stable** for packages currently on stable versions +- **Note pre-release versions** if they exist and are newer than the latest stable +- **For packages already on pre-release** (e.g., `Spectre.Console 0.52.1-preview.0.5`), show both the latest pre-release and the latest stable +- **Always show the current version** for comparison + +### 3. Present Version Summary + +Present a clear table to the user with the `ask_user` tool: + +```markdown +## Dependency Update Summary + +| Package | Current | Latest Stable | Latest Pre-release | Recommendation | +|---------|---------|---------------|-------------------|----------------| +| Hex1b | 0.48.0 | 0.49.0 | 0.50.0-beta.1 | ⬆️ 0.49.0 (stable) | +| Hex1b.McpServer | 0.48.0 | 0.49.0 | 0.50.0-beta.1 | ⬆️ 0.49.0 (stable) | +| StackExchange.Redis | 2.9.32 | 2.9.33 | — | ⬆️ 2.9.33 | + +Packages already at latest: Confluent.Kafka (2.12.0) ✅ +``` + +**Recommendation logic:** +- If currently on stable → recommend latest stable +- If currently on pre-release → recommend latest pre-release (note stable alternative) +- If current == latest → mark as up-to-date +- If a major version bump → flag for careful review + +### 4. Review Changes (For Major/Minor Bumps) + +For packages with version changes beyond patch-level, help the user assess risk: + +1. **Find the project/release page** — Search for the package's GitHub repository or changelog +2. **Summarize notable changes** — Breaking changes, new features, deprecations +3. **Check for known issues** — Look for open issues related to the new version +4. **Assess impact** — Which Aspire projects reference this package? + +```bash +# Find which projects reference a package +grep -r "PackageReference.*Include=\"Hex1b\"" src/ tests/ --include="*.csproj" -l +``` + +Use the `ask_user` tool to confirm which packages and versions to proceed with before triggering any pipelines. + +### 5. Check Prerequisites + +Before triggering pipelines, verify the Azure DevOps tooling is ready: + +```bash +dotnet .github/skills/dependency-update/MigratePackage.cs -- --check-prereqs +``` + +If prerequisites fail, guide the user through setup: + +**Azure CLI not installed:** +> Install from: https://learn.microsoft.com/cli/azure/install-azure-cli + +**Not logged in:** +```bash +az login +``` + +**Wrong tenant (the script auto-detects the Microsoft corp tenant, but if that fails):** +```bash +az login --tenant 72f988bf-86f1-41af-91ab-2d7cd011db47 +``` + +### 6. Trigger Pipeline for Each Package + +Run the companion script for each confirmed package. Process **one package at a time**: + +```bash +dotnet .github/skills/dependency-update/MigratePackage.cs -- "" "" +``` + +The script will: +1. Authenticate via Azure.Identity (AzureCliCredential) +2. Trigger the `dotnet-migrate-package` pipeline using `PipelinesHttpClient.RunPipelineAsync` +3. Poll every 30 seconds via `PipelinesHttpClient.GetRunAsync` until completion (default 15-minute timeout) +4. Report success or failure + +**Example:** +```bash +dotnet .github/skills/dependency-update/MigratePackage.cs -- "Hex1b" "0.49.0" +dotnet .github/skills/dependency-update/MigratePackage.cs -- "Hex1b.McpServer" "0.49.0" +``` + +**If a pipeline run fails**, stop and report the failure to the user before proceeding with additional packages. Include the Azure DevOps run URL for investigation. + +### 7. Update Directory.Packages.props + +After each pipeline run succeeds, update the version in `Directory.Packages.props`: + +```xml + + + + + +``` + +**Important considerations:** +- Some packages share versions (e.g., `Hex1b` and `Hex1b.McpServer`). Update all related packages together. +- Some packages have version properties defined in `eng/Versions.props` instead of inline. Check both files. +- Don't modify packages in the `` section of `eng/Versions.props` — those are managed by Dependency Flow automation. + +### 8. Verify the Build + +After updating versions, verify the project still builds: + +```bash +# Quick build check (skip native AOT to save time) +./build.sh --build /p:SkipNativeBuild=true +``` + +If the build fails due to API changes in the updated package, report the errors and help the user fix them. + +### 9. Summarize Results + +Provide a final summary: + +```markdown +## Dependency Update Complete + +### ✅ Successfully Updated +| Package | Previous | New | Pipeline Run | +|---------|----------|-----|-------------| +| Hex1b | 0.48.0 | 0.49.0 | [Run 12345](https://dev.azure.com/...) | +| Hex1b.McpServer | 0.48.0 | 0.49.0 | [Run 12346](https://dev.azure.com/...) | + +### ❌ Failed +| Package | Version | Reason | +|---------|---------|--------| +| (none) | | | + +### 📋 Files Modified +- `Directory.Packages.props` — Updated 2 package versions + +### Next Steps +- Review the changes with `git diff` +- Run targeted tests for affected projects +- Create a PR with the updates +``` + +## Handling Related Package Families + +Some packages should be updated together. Common families in this repo: + +- **Hex1b**: `Hex1b`, `Hex1b.McpServer` +- **Azure.Provisioning**: `Azure.Provisioning`, `Azure.Provisioning.*` +- **OpenTelemetry**: `OpenTelemetry.*` (versions often defined as MSBuild properties in `eng/Versions.props`) +- **AspNetCore.HealthChecks**: `AspNetCore.HealthChecks.*` +- **Grpc**: `Grpc.AspNetCore`, `Grpc.Net.ClientFactory`, `Grpc.Tools` +- **Polly**: `Polly.Core`, `Polly.Extensions` +- **Azure SDK**: `Azure.Messaging.*`, `Azure.Storage.*`, `Azure.Security.*` +- **ModelContextProtocol**: `ModelContextProtocol`, `ModelContextProtocol.AspNetCore` + +When updating one member of a family, check if other members also have updates available and suggest updating them together. + +## Important Constraints + +- **One package per pipeline run** — The script processes one dependency at a time +- **Wait for completion** — Don't start the next pipeline run until the current one finishes (the pipeline queue is aggressive) +- **Always check nuget.org** — The mirroring pipeline pulls from nuget.org +- **Verify versions exist** — Before triggering the pipeline, confirm the version exists on nuget.org +- **Don't modify NuGet.config** — Package sources are managed separately; this skill only handles version updates +- **Don't modify eng/Version.Details.xml** — That file is managed by Dependency Flow automation (Maestro/Darc) +- **Ask before proceeding** — Always present the version summary and get user confirmation before triggering pipelines diff --git a/AGENTS.md b/AGENTS.md index 156c8f83df6..cb4d5711eb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -354,6 +354,7 @@ The following specialized skills are available in `.github/skills/`: - **cli-e2e-testing**: Guide for writing Aspire CLI end-to-end tests using Hex1b terminal automation - **test-management**: Quarantines or disables flaky/problematic tests using the QuarantineTools utility - **connection-properties**: Expert for creating and improving Connection Properties in Aspire resources +- **dependency-update**: Guides dependency version updates by checking nuget.org, triggering the dotnet-migrate-package Azure DevOps pipeline, and monitoring runs ## Pattern-Based Instructions diff --git a/Directory.Packages.props b/Directory.Packages.props index 77397aa81f6..8e4674e5743 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -98,8 +98,9 @@ - - + + + From 6e4f1429799a06a58bbf42e9b31b251e7d3aaedd Mon Sep 17 00:00:00 2001 From: Karol Zadora-Przylecki Date: Mon, 9 Feb 2026 10:53:09 -0800 Subject: [PATCH 066/256] Re-enable container tunnel tests (#14412) Latest DCP insertion should have fixed this. Fixes https://github.com/dotnet/aspire/issues/14325 --- tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs b/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs index 2e8de82ccf0..b35b2459fb0 100644 --- a/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs +++ b/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs @@ -13,7 +13,6 @@ public class ContainerTunnelTests(ITestOutputHelper testOutputHelper) { [Fact] [RequiresFeature(TestFeature.Docker)] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/14325")] public async Task ContainerTunnelWorksWithYarp() { const string testName = "container-tunnel-works-with-yarp"; From c900804cf27eaac8216e471802fd443e15d9e5dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Mon, 9 Feb 2026 15:04:14 -0800 Subject: [PATCH 067/256] Ensure DCP path is valid (#14415) --- Directory.Build.props | 2 +- src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 9c3eaf36972..e6d941e1378 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -51,7 +51,7 @@ 386 arm64 amd64 - $(NuGetPackageRoot)microsoft.developercontrolplane.$(BuildOs)-$(BuildArch)/$(MicrosoftDeveloperControlPlanedarwinamd64Version)/tools/ + $([MSBuild]::EnsureTrailingSlash('$(NuGetPackageRoot)'))microsoft.developercontrolplane.$(BuildOs)-$(BuildArch)/$(MicrosoftDeveloperControlPlanedarwinamd64Version)/tools/ diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 5ac830a3d53..3d32b0d4e95 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -166,7 +166,7 @@ private XDocument CreateProjectFile(IEnumerable<(string Name, string Version)> p true 42.42.42 - $(NuGetPackageRoot){dcpPackageName}/{dcpVersion}/tools/ + $([MSBuild]::EnsureTrailingSlash('$(NuGetPackageRoot)')){dcpPackageName}/{dcpVersion}/tools/ {_repoRoot}artifacts/bin/Aspire.Dashboard/Debug/net8.0/ From 32d6b11c3116fa628ec6408e88b28959f4df518b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Mon, 9 Feb 2026 19:09:22 -0800 Subject: [PATCH 068/256] Add ATS API surface tracking workflow (#14418) * Add ATS API surface tracking workflow with SqlServer example Add generate-ats-diffs.yml workflow that: - Runs aspire sdk dump --ci for each Aspire.Hosting.* project with [AspireExport] attributes - Saves output as .ats.txt files in each project's api/ directory - Dynamically discovers integration projects (excludes Analyzers, CodeGeneration, RemoteHost) - Auto-creates PR with NO-MERGE label (matching generate-api-diffs.yml pattern) Include SqlServer .ats.txt as an example of the generated output. Add workflow to CI exclusion list. * Fix review issues in generate-ats-diffs workflow - Fix subdirectory discovery: walk up from matched file to find nearest .csproj instead of assuming flat project layout - Fix silent failures: remove set +e and continue-on-error, track failures per project, exit non-zero if any fail, exit immediately if core dump fails - Fix inefficiency: build CLI once in a separate step, use --no-build in the loop --- .github/workflows/ci.yml | 1 + .github/workflows/generate-ats-diffs.yml | 86 +++++++ .../api/Aspire.Hosting.SqlServer.ats.txt | 211 ++++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 .github/workflows/generate-ats-diffs.yml create mode 100644 src/Aspire.Hosting.SqlServer/api/Aspire.Hosting.SqlServer.ats.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b06f967a6e..a0007e8030f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,7 @@ jobs: \.github/workflows/backport.yml \.github/workflows/dogfood-comment.yml \.github/workflows/generate-api-diffs.yml + \.github/workflows/generate-ats-diffs.yml \.github/workflows/labeler-*.yml \.github/workflows/markdownlint*.yml \.github/workflows/refresh-manifests.yml diff --git a/.github/workflows/generate-ats-diffs.yml b/.github/workflows/generate-ats-diffs.yml new file mode 100644 index 00000000000..dc36eb09ee3 --- /dev/null +++ b/.github/workflows/generate-ats-diffs.yml @@ -0,0 +1,86 @@ +name: Generate ATS Diffs + +on: + workflow_dispatch: + schedule: + - cron: '0 16 * * *' # 8am PST (16:00 UTC) + +permissions: + contents: write + pull-requests: write + +jobs: + generate-and-pr: + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'dotnet' }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Restore + run: ./restore.sh + + - name: Build CLI + run: ./dotnet.sh build src/Aspire.Cli/Aspire.Cli.csproj --configuration Release + + - name: Discover and dump ATS capabilities + run: | + ASPIRE_CLI="./dotnet.sh run --no-build --project src/Aspire.Cli/Aspire.Cli.csproj --configuration Release --" + FAILURES=0 + + # Dump core Aspire.Hosting capabilities (no integration argument) + echo "::group::Aspire.Hosting (core)" + if ! $ASPIRE_CLI sdk dump --ci -o src/Aspire.Hosting/api/Aspire.Hosting.ats.txt; then + echo "::error::Failed to dump core Aspire.Hosting capabilities" + exit 1 + fi + echo "::endgroup::" + + # Discover Aspire.Hosting.* integration projects with [AspireExport] attributes + # Find project directories by resolving matched files to their nearest .csproj + # Exclude infrastructure projects that define/analyze the attribute rather than use it + PROJECTS=$(grep -rl '\[AspireExport(' src/Aspire.Hosting.*/ --include='*.cs' \ + | grep -v '/obj/' | grep -v '/bin/' \ + | grep -v 'Aspire.Hosting.Analyzers' \ + | grep -v 'Aspire.Hosting.CodeGeneration' \ + | grep -v 'Aspire.Hosting.RemoteHost' \ + | while read -r file; do + # Walk up from the file to find the directory containing a .csproj + dir=$(dirname "$file") + while [ "$dir" != "." ] && [ "$dir" != "/" ]; do + if ls "$dir"/*.csproj 1>/dev/null 2>&1; then + echo "$dir" + break + fi + dir=$(dirname "$dir") + done + done | sort -u) + + for proj in $PROJECTS; do + proj_name=$(basename "$proj") + csproj="$proj/$proj_name.csproj" + if [ -f "$csproj" ]; then + echo "::group::$proj_name" + mkdir -p "$proj/api" + if ! $ASPIRE_CLI sdk dump --ci "$csproj" -o "$proj/api/$proj_name.ats.txt"; then + echo "::error::Failed to dump ATS capabilities for $proj_name" + FAILURES=$((FAILURES + 1)) + fi + echo "::endgroup::" + fi + done + + if [ "$FAILURES" -gt 0 ]; then + echo "::error::$FAILURES project(s) failed ATS capability dump" + exit 1 + fi + + - name: Create or update pull request + uses: dotnet/actions-create-pull-request@e8d799aa1f8b17f324f9513832811b0a62f1e0b1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: update-ats-diffs + base: main + labels: | + NO-MERGE + title: "[Automated] Update ATS API Surface Area" + body: "Auto-generated update to the ATS (Aspire Type System) capability surface to compare current surface vs latest release. This should only be merged once this surface area ships in a new release." diff --git a/src/Aspire.Hosting.SqlServer/api/Aspire.Hosting.SqlServer.ats.txt b/src/Aspire.Hosting.SqlServer/api/Aspire.Hosting.SqlServer.ats.txt new file mode 100644 index 00000000000..16751bee1bc --- /dev/null +++ b/src/Aspire.Hosting.SqlServer/api/Aspire.Hosting.SqlServer.ats.txt @@ -0,0 +1,211 @@ +# Aspire Type System Capabilities +# Generated by: aspire sdk dump --ci + +# Handle Types +Aspire.Hosting.SqlServer/Aspire.Hosting.ApplicationModel.SqlServerDatabaseResource +Aspire.Hosting.SqlServer/Aspire.Hosting.ApplicationModel.SqlServerServerResource +Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext [ExposeProperties] +Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource +Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference [ExposeProperties] +Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReferenceExpression [ExposeProperties] +Aspire.Hosting/Aspire.Hosting.ApplicationModel.EnvironmentCallbackContext [ExposeProperties] +Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecutableResource +Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecuteCommandContext [ExposeProperties] +Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource [interface] +Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithConnectionString [interface] +Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource +Aspire.Hosting/Aspire.Hosting.ApplicationModel.ProjectResource +Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression +Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceUrlsCallbackContext [ExposeProperties] +Aspire.Hosting/Aspire.Hosting.ApplicationModel.UpdateCommandStateContext [ExposeProperties] +Aspire.Hosting/Aspire.Hosting.DistributedApplication +Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext [ExposeProperties] +Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContextOptions +Aspire.Hosting/Aspire.Hosting.Eventing.DistributedApplicationEventSubscription +Aspire.Hosting/Aspire.Hosting.Eventing.DistributedApplicationResourceEventSubscription +Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationEvent [interface] +Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationEventing [interface, ExposeMethods] +Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationResourceEvent [interface] +Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder [interface, ExposeProperties] +Aspire.Hosting/Aspire.Hosting.IResourceWithServiceDiscovery [interface] + +# DTO Types +Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandOptions + ConfirmationMessage: string + Description: string + IconName: string + IconVariant: enum:Aspire.Hosting.ApplicationModel.IconVariant + IsHighlighted: boolean + Parameter: any + UpdateState: System.Private.CoreLib/System.Func`2[[Aspire.Hosting.ApplicationModel.UpdateCommandStateContext, Aspire.Hosting, Version=42.42.42.42, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51],[Aspire.Hosting.ApplicationModel.ResourceCommandState, Aspire.Hosting, Version=42.42.42.42, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51]] +Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecuteCommandResult + Canceled: boolean + ErrorMessage: string + Success: boolean +Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceUrlAnnotation + DisplayLocation: enum:Aspire.Hosting.ApplicationModel.UrlDisplayLocation + DisplayText: string + Endpoint: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference + Url: string +Aspire.Hosting/Aspire.Hosting.Ats.CreateBuilderOptions + AllowUnsecuredTransport: boolean + AppHostFilePath: string + Args: string[] + ContainerRegistryOverride: string + DashboardApplicationName: string + DisableDashboard: boolean + EnableResourceLogging: boolean + ProjectDirectory: string +Aspire.Hosting/Aspire.Hosting.Ats.ResourceEventDto + ExitCode: number + HealthStatus: string + ResourceId: string + ResourceName: string + State: string + StateStyle: string + +# Enum Types +enum:Aspire.Hosting.ApplicationModel.ContainerLifetime = Session | Persistent +enum:Aspire.Hosting.ApplicationModel.EndpointProperty = Url | Host | IPV4Host | Port | Scheme | TargetPort | HostAndPort +enum:Aspire.Hosting.ApplicationModel.IconVariant = Regular | Filled +enum:Aspire.Hosting.ApplicationModel.ImagePullPolicy = Default | Always | Missing | Never +enum:Aspire.Hosting.ApplicationModel.UrlDisplayLocation = SummaryAndDetails | DetailsOnly +enum:Aspire.Hosting.DistributedApplicationOperation = Run | Publish +enum:System.Net.Sockets.ProtocolType = IP | IPv6HopByHopOptions | Unspecified | Icmp | Igmp | Ggp | IPv4 | Tcp | Pup | Udp | Idp | IPv6 | IPv6RoutingHeader | IPv6FragmentHeader | IPSecEncapsulatingSecurityPayload | IPSecAuthenticationHeader | IcmpV6 | IPv6NoNextHeader | IPv6DestinationOptions | ND | Raw | Ipx | Spx | SpxII | Unknown + +# Capabilities +Aspire.Hosting.ApplicationModel/CommandLineArgsCallbackContext.args(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext) -> Aspire.Hosting/List +Aspire.Hosting.ApplicationModel/CommandLineArgsCallbackContext.cancellationToken(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext) -> cancellationToken +Aspire.Hosting.ApplicationModel/CommandLineArgsCallbackContext.executionContext(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext) -> Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext +Aspire.Hosting.ApplicationModel/CommandLineArgsCallbackContext.setExecutionContext(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext, value: Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandLineArgsCallbackContext +Aspire.Hosting.ApplicationModel/EndpointReference.endpointName(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference) -> string +Aspire.Hosting.ApplicationModel/EndpointReference.errorMessage(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference) -> string +Aspire.Hosting.ApplicationModel/EndpointReference.exists(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference) -> boolean +Aspire.Hosting.ApplicationModel/EndpointReference.host(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference) -> string +Aspire.Hosting.ApplicationModel/EndpointReference.isAllocated(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference) -> boolean +Aspire.Hosting.ApplicationModel/EndpointReference.isHttp(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference) -> boolean +Aspire.Hosting.ApplicationModel/EndpointReference.isHttps(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference) -> boolean +Aspire.Hosting.ApplicationModel/EndpointReference.port(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference) -> number +Aspire.Hosting.ApplicationModel/EndpointReference.scheme(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference) -> string +Aspire.Hosting.ApplicationModel/EndpointReference.setErrorMessage(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference, value: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference +Aspire.Hosting.ApplicationModel/EndpointReference.targetPort(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference) -> number +Aspire.Hosting.ApplicationModel/EndpointReference.url(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference) -> string +Aspire.Hosting.ApplicationModel/EndpointReferenceExpression.endpoint(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReferenceExpression) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference +Aspire.Hosting.ApplicationModel/EndpointReferenceExpression.property(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReferenceExpression) -> enum:Aspire.Hosting.ApplicationModel.EndpointProperty +Aspire.Hosting.ApplicationModel/EndpointReferenceExpression.valueExpression(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReferenceExpression) -> string +Aspire.Hosting.ApplicationModel/EnvironmentCallbackContext.cancellationToken(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EnvironmentCallbackContext) -> cancellationToken +Aspire.Hosting.ApplicationModel/EnvironmentCallbackContext.environmentVariables(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EnvironmentCallbackContext) -> Aspire.Hosting/Dict +Aspire.Hosting.ApplicationModel/EnvironmentCallbackContext.executionContext(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EnvironmentCallbackContext) -> Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext +Aspire.Hosting.ApplicationModel/ExecuteCommandContext.cancellationToken(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecuteCommandContext) -> cancellationToken +Aspire.Hosting.ApplicationModel/ExecuteCommandContext.resourceName(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecuteCommandContext) -> string +Aspire.Hosting.ApplicationModel/ExecuteCommandContext.setCancellationToken(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecuteCommandContext, value: cancellationToken) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecuteCommandContext +Aspire.Hosting.ApplicationModel/ExecuteCommandContext.setResourceName(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecuteCommandContext, value: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecuteCommandContext +Aspire.Hosting.ApplicationModel/getValueAsync(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference, cancellationToken?: cancellationToken) -> string +Aspire.Hosting.ApplicationModel/ResourceUrlsCallbackContext.cancellationToken(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceUrlsCallbackContext) -> cancellationToken +Aspire.Hosting.ApplicationModel/ResourceUrlsCallbackContext.executionContext(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceUrlsCallbackContext) -> Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext +Aspire.Hosting.ApplicationModel/ResourceUrlsCallbackContext.urls(context: Aspire.Hosting/Aspire.Hosting.ApplicationModel.ResourceUrlsCallbackContext) -> Aspire.Hosting/List +Aspire.Hosting.Eventing/IDistributedApplicationEventing.unsubscribe(context: Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationEventing, subscription: Aspire.Hosting/Aspire.Hosting.Eventing.DistributedApplicationEventSubscription) -> void +Aspire.Hosting.SqlServer/addDatabase(name: string, databaseName?: string) -> Aspire.Hosting.SqlServer/Aspire.Hosting.ApplicationModel.SqlServerDatabaseResource +Aspire.Hosting.SqlServer/addSqlServer(name: string, password?: Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource, port?: number) -> Aspire.Hosting.SqlServer/Aspire.Hosting.ApplicationModel.SqlServerServerResource +Aspire.Hosting.SqlServer/withCreationScript(script: string) -> Aspire.Hosting.SqlServer/Aspire.Hosting.ApplicationModel.SqlServerDatabaseResource +Aspire.Hosting.SqlServer/withDataBindMount(source: string, isReadOnly?: boolean) -> Aspire.Hosting.SqlServer/Aspire.Hosting.ApplicationModel.SqlServerServerResource +Aspire.Hosting.SqlServer/withDataVolume(name?: string, isReadOnly?: boolean) -> Aspire.Hosting.SqlServer/Aspire.Hosting.ApplicationModel.SqlServerServerResource +Aspire.Hosting.SqlServer/withHostPort(port: number) -> Aspire.Hosting.SqlServer/Aspire.Hosting.ApplicationModel.SqlServerServerResource +Aspire.Hosting.SqlServer/withPassword(password: Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource) -> Aspire.Hosting.SqlServer/Aspire.Hosting.ApplicationModel.SqlServerServerResource +Aspire.Hosting/addConnectionString(name: string, environmentVariableName?: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithConnectionString +Aspire.Hosting/addContainer(name: string, image: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource +Aspire.Hosting/addExecutable(name: string, command: string, workingDirectory: string, args: string[]) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecutableResource +Aspire.Hosting/addParameter(name: string, secret?: boolean) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource +Aspire.Hosting/addProject(name: string, projectPath: string, launchProfileName: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ProjectResource +Aspire.Hosting/asHttp2Service() -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints +Aspire.Hosting/build(context: Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder) -> Aspire.Hosting/Aspire.Hosting.DistributedApplication +Aspire.Hosting/completeLog(resource: Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource) -> void +Aspire.Hosting/completeLogByName(resourceName: string) -> void +Aspire.Hosting/createBuilder() -> Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder +Aspire.Hosting/createBuilderWithOptions() -> Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder +Aspire.Hosting/Dict.clear() -> void +Aspire.Hosting/Dict.count() -> number +Aspire.Hosting/Dict.get(key: string) -> any +Aspire.Hosting/Dict.has(key: string) -> boolean +Aspire.Hosting/Dict.keys() -> string[] +Aspire.Hosting/Dict.remove(key: string) -> boolean +Aspire.Hosting/Dict.set(key: string, value: any) -> void +Aspire.Hosting/Dict.toObject() -> Aspire.Hosting/Dict +Aspire.Hosting/Dict.values() -> any[] +Aspire.Hosting/DistributedApplicationExecutionContext.isPublishMode(context: Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext) -> boolean +Aspire.Hosting/DistributedApplicationExecutionContext.isRunMode(context: Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext) -> boolean +Aspire.Hosting/DistributedApplicationExecutionContext.operation(context: Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext) -> enum:Aspire.Hosting.DistributedApplicationOperation +Aspire.Hosting/DistributedApplicationExecutionContext.publisherName(context: Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext) -> string +Aspire.Hosting/DistributedApplicationExecutionContext.setPublisherName(context: Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext, value: string) -> Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext +Aspire.Hosting/getConfigValue(key: string) -> string +Aspire.Hosting/getConnectionString(name: string) -> string +Aspire.Hosting/getEndpoint(name: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.EndpointReference +Aspire.Hosting/getEnvironmentName() -> string +Aspire.Hosting/getRequiredService(typeId: string) -> any +Aspire.Hosting/getResourceName() -> string +Aspire.Hosting/getService(typeId: string) -> any +Aspire.Hosting/IDistributedApplicationBuilder.appHostDirectory(context: Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder) -> string +Aspire.Hosting/IDistributedApplicationBuilder.eventing(context: Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder) -> Aspire.Hosting/Aspire.Hosting.Eventing.IDistributedApplicationEventing +Aspire.Hosting/IDistributedApplicationBuilder.executionContext(context: Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder) -> Aspire.Hosting/Aspire.Hosting.DistributedApplicationExecutionContext +Aspire.Hosting/isDevelopment() -> boolean +Aspire.Hosting/List.add(item: any) -> void +Aspire.Hosting/List.clear() -> void +Aspire.Hosting/List.get(index: number) -> any +Aspire.Hosting/List.indexOf(item: any) -> number +Aspire.Hosting/List.insert(index: number, item: any) -> void +Aspire.Hosting/List.length() -> number +Aspire.Hosting/List.removeAt(index: number) -> boolean +Aspire.Hosting/List.set(index: number, value: any) -> void +Aspire.Hosting/List.toArray() -> any[] +Aspire.Hosting/log(level: string, message: string) -> void +Aspire.Hosting/logDebug(message: string) -> void +Aspire.Hosting/logError(message: string) -> void +Aspire.Hosting/logInformation(message: string) -> void +Aspire.Hosting/logWarning(message: string) -> void +Aspire.Hosting/publishResourceUpdate(resource: Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource, state?: string, stateStyle?: string) -> void +Aspire.Hosting/run(context: Aspire.Hosting/Aspire.Hosting.DistributedApplication, cancellationToken?: cancellationToken) -> void +Aspire.Hosting/tryGetResourceState(resourceName: string) -> Aspire.Hosting/Aspire.Hosting.Ats.ResourceEventDto +Aspire.Hosting/waitFor(dependency: Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithWaitSupport +Aspire.Hosting/waitForCompletion(dependency: Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource, exitCode?: number) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithWaitSupport +Aspire.Hosting/waitForDependencies(resource: Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource) -> void +Aspire.Hosting/waitForResourceHealthy(resourceName: string) -> Aspire.Hosting/Aspire.Hosting.Ats.ResourceEventDto +Aspire.Hosting/waitForResourceState(resourceName: string, targetState?: string) -> void +Aspire.Hosting/waitForResourceStates(resourceName: string, targetStates: string[]) -> string +Aspire.Hosting/withArgs(args: string[]) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs +Aspire.Hosting/withArgsCallback(callback: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs +Aspire.Hosting/withArgsCallbackAsync(callback: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs +Aspire.Hosting/withBindMount(source: string, target: string, isReadOnly?: boolean) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource +Aspire.Hosting/withCommand(name: string, displayName: string, executeCommand: callback, commandOptions?: Aspire.Hosting/Aspire.Hosting.ApplicationModel.CommandOptions) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource +Aspire.Hosting/withContainerName(name: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource +Aspire.Hosting/withContainerRuntimeArgs(args: string[]) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource +Aspire.Hosting/withDescription(description: string, enableMarkdown?: boolean) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource +Aspire.Hosting/withEndpoint(port?: number, targetPort?: number, scheme?: string, name?: string, env?: string, isProxied?: boolean, isExternal?: boolean, protocol?: enum:System.Net.Sockets.ProtocolType) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints +Aspire.Hosting/withEntrypoint(entrypoint: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource +Aspire.Hosting/withEnvironment(name: string, value: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment +Aspire.Hosting/withEnvironmentCallback(callback: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment +Aspire.Hosting/withEnvironmentCallbackAsync(callback: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment +Aspire.Hosting/withEnvironmentExpression(name: string, value: Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment +Aspire.Hosting/withExecutableCommand(command: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecutableResource +Aspire.Hosting/withExplicitStart() -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource +Aspire.Hosting/withExternalHttpEndpoints() -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints +Aspire.Hosting/withHealthCheck(key: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource +Aspire.Hosting/withHttpEndpoint(port?: number, targetPort?: number, name?: string, env?: string, isProxied?: boolean) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints +Aspire.Hosting/withHttpHealthCheck(path?: string, statusCode?: number, endpointName?: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints +Aspire.Hosting/withHttpsEndpoint(port?: number, targetPort?: number, name?: string, env?: string, isProxied?: boolean) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints +Aspire.Hosting/withImage(image: string, tag?: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource +Aspire.Hosting/withImagePullPolicy(pullPolicy: enum:Aspire.Hosting.ApplicationModel.ImagePullPolicy) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource +Aspire.Hosting/withImageRegistry(registry: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource +Aspire.Hosting/withImageTag(tag: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource +Aspire.Hosting/withLifetime(lifetime: enum:Aspire.Hosting.ApplicationModel.ContainerLifetime) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource +Aspire.Hosting/withParentRelationship(parent: Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource +Aspire.Hosting/withReference(source: Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithConnectionString, connectionName?: string, optional?: boolean) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment +Aspire.Hosting/withReplicas(replicas: number) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ProjectResource +Aspire.Hosting/withServiceReference(source: Aspire.Hosting/Aspire.Hosting.IResourceWithServiceDiscovery) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment +Aspire.Hosting/withUrl(url: string, displayText?: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource +Aspire.Hosting/withUrlExpression(url: Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression, displayText?: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource +Aspire.Hosting/withUrlForEndpoint(endpointName: string, callback: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource +Aspire.Hosting/withUrlForEndpointFactory(endpointName: string, callback: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints +Aspire.Hosting/withUrlsCallback(callback: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource +Aspire.Hosting/withUrlsCallbackAsync(callback: callback) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource +Aspire.Hosting/withVolume(target: string, name?: string, isReadOnly?: boolean) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerResource +Aspire.Hosting/withWorkingDirectory(workingDirectory: string) -> Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecutableResource From ea1aa0deafd179422875d93c8b46d009ac48991a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Mon, 9 Feb 2026 19:55:36 -0800 Subject: [PATCH 069/256] Add AspireExport coverage for Aspire.Hosting.Azure.Storage (#14420) * Add AspireExport coverage for Aspire.Hosting.Azure.Storage Add [AspireExport] attributes to 15 public extension methods and [AspireExportIgnore] to 2 incompatible methods in AzureStorageExtensions.cs, enabling polyglot (TypeScript) app host support for Azure Storage. Exported methods: - addAzureStorage, runAsEmulator - withDataBindMount, withDataVolume - withBlobPort, withQueuePort, withTablePort, withApiVersionCheck - addBlobs, addDataLake, addTables, addQueues - addBlobContainer, addDataLakeFileSystem, addQueue Ignored methods: - AddBlobContainer(AzureBlobStorageResource) - obsolete - WithRoleAssignments - params StorageBuiltInRole[] not ATS-compatible Includes TypeScript validation app host in playground/polyglot. * Remove .aspire/ folders from playground/polyglot tracking Remove the gitignore un-ignore rule for playground/polyglot/**/.aspire/ so these machine-specific settings (channels, SDK versions, local paths) are not tracked. The global .aspire/ ignore rule now applies uniformly. * Fix review --- .../ValidationAppHost/.aspire/settings.json | 9 + .../ValidationAppHost/apphost.run.json | 13 + .../ValidationAppHost/apphost.ts | 25 + .../ValidationAppHost/package-lock.json | 961 ++++++++++++++++++ .../ValidationAppHost/package.json | 19 + .../ValidationAppHost/tsconfig.json | 15 + .../AzureStorageExtensions.cs | 15 + 7 files changed, 1057 insertions(+) create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/apphost.run.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/apphost.ts create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/package-lock.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/package.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/tsconfig.json diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json new file mode 100644 index 00000000000..6f25239fe75 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json @@ -0,0 +1,9 @@ +{ + "appHostPath": "../apphost.ts", + "language": "typescript/nodejs", + "channel": "pr-13970", + "sdkVersion": "13.2.0-pr.13970.g9fb24263", + "packages": { + "Aspire.Hosting.Azure.Storage": "13.2.0-pr.13970.g9fb24263" + } +} \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/apphost.run.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/apphost.run.json new file mode 100644 index 00000000000..41b1ca76f11 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/apphost.run.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "https": { + "applicationUrl": "https://localhost:52066;http://localhost:47700", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:62976", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:34207" + } + } + } +} \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/apphost.ts b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/apphost.ts new file mode 100644 index 00000000000..9f438b1a7e7 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/apphost.ts @@ -0,0 +1,25 @@ +import { createBuilder } from './.modules/aspire.js'; + +const builder = await createBuilder(); + +const storage = await builder.addAzureStorage("storage"); +await storage.runAsEmulator(); + +// Callbacks are currently not working +// await storage.runAsEmulator({ +// configureContainer: async (emulator) => { +// await emulator.withBlobPort(10000); +// await emulator.withQueuePort(10001); +// await emulator.withTablePort(10002); +// await emulator.withDataVolume(); +// await emulator.withApiVersionCheck({ enable: false }); +// } +// }); + +await storage.addBlobs("blobs"); +await storage.addTables("tables"); +await storage.addQueues("queues"); +await storage.addQueue("orders"); +await storage.addBlobContainer("images"); + +await builder.build().run(); \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/package-lock.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/package-lock.json new file mode 100644 index 00000000000..f1821e859ca --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/package-lock.json @@ -0,0 +1,961 @@ +{ + "name": "validationapphost", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "validationapphost", + "version": "1.0.0", + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "nodemon": "^3.1.11", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", + "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + } + } +} diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/package.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/package.json new file mode 100644 index 00000000000..be16934198a --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/package.json @@ -0,0 +1,19 @@ +{ + "name": "validationapphost", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "aspire run", + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "nodemon": "^3.1.11", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } +} \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/tsconfig.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/tsconfig.json new file mode 100644 index 00000000000..edf7302cc25 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": ["apphost.ts", ".modules/**/*.ts"], + "exclude": ["node_modules"] +} \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs index f2513ccd5af..d8d58e45996 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs @@ -37,6 +37,7 @@ public static class AzureStorageExtensions /// /// These can be replaced by calling . /// + [AspireExport("addAzureStorage", Description = "Adds an Azure Storage resource")] public static IResourceBuilder AddAzureStorage(this IDistributedApplicationBuilder builder, [ResourceName] string name) { ArgumentNullException.ThrowIfNull(builder); @@ -174,6 +175,7 @@ public static IResourceBuilder AddAzureStorage(this IDistr /// The Azure storage resource builder. /// Callback that exposes underlying container used for emulation to allow for customization. /// A reference to the . + [AspireExport("runAsEmulator", Description = "Configures the Azure Storage resource to be emulated using Azurite")] public static IResourceBuilder RunAsEmulator(this IResourceBuilder builder, Action>? configureContainer = null) { ArgumentNullException.ThrowIfNull(builder); @@ -267,6 +269,7 @@ public static IResourceBuilder RunAsEmulator(this IResourc /// Relative path to the AppHost where emulator storage is persisted between runs. Defaults to the path '.azurite/{builder.Resource.Name}' /// A flag that indicates if this is a read-only mount. /// A builder for the . + [AspireExport("withDataBindMount", Description = "Adds a bind mount for the data folder to an Azure Storage emulator resource")] public static IResourceBuilder WithDataBindMount(this IResourceBuilder builder, string? path = null, bool isReadOnly = false) { ArgumentNullException.ThrowIfNull(builder); @@ -281,6 +284,7 @@ public static IResourceBuilder WithDataBindMount(t /// The name of the volume. Defaults to an auto-generated name based on the application and resource names. /// A flag that indicates if this is a read-only volume. /// A builder for the . + [AspireExport("withDataVolume", Description = "Adds a named volume for the data folder to an Azure Storage emulator resource")] public static IResourceBuilder WithDataVolume(this IResourceBuilder builder, string? name = null, bool isReadOnly = false) { ArgumentNullException.ThrowIfNull(builder); @@ -294,6 +298,7 @@ public static IResourceBuilder WithDataVolume(this /// Storage emulator resource builder. /// Host port to use. /// + [AspireExport("withBlobPort", Description = "Sets the host port for blob requests on the storage emulator")] public static IResourceBuilder WithBlobPort(this IResourceBuilder builder, int port) { ArgumentNullException.ThrowIfNull(builder); @@ -310,6 +315,7 @@ public static IResourceBuilder WithBlobPort(this I /// Storage emulator resource builder. /// Host port to use. /// + [AspireExport("withQueuePort", Description = "Sets the host port for queue requests on the storage emulator")] public static IResourceBuilder WithQueuePort(this IResourceBuilder builder, int port) { ArgumentNullException.ThrowIfNull(builder); @@ -326,6 +332,7 @@ public static IResourceBuilder WithQueuePort(this /// Storage emulator resource builder. /// Host port to use. /// An for the . + [AspireExport("withTablePort", Description = "Sets the host port for table requests on the storage emulator")] public static IResourceBuilder WithTablePort(this IResourceBuilder builder, int port) { ArgumentNullException.ThrowIfNull(builder); @@ -342,6 +349,7 @@ public static IResourceBuilder WithTablePort(this /// Storage emulator resource builder. /// Whether to enable API version check or not. Default is true. /// An for the . + [AspireExport("withApiVersionCheck", Description = "Configures whether the emulator checks API version validity")] public static IResourceBuilder WithApiVersionCheck(this IResourceBuilder builder, bool enable = true) { ArgumentNullException.ThrowIfNull(builder); @@ -366,6 +374,7 @@ public static IResourceBuilder WithApiVersionCheck /// The for . /// The name of the resource. /// An for the . + [AspireExport("addBlobs", Description = "Adds an Azure Blob Storage resource")] public static IResourceBuilder AddBlobs(this IResourceBuilder builder, [ResourceName] string name) { ArgumentNullException.ThrowIfNull(builder); @@ -387,6 +396,7 @@ public static IResourceBuilder AddBlobs(this IResource /// The for . /// The name of the resource. /// An for the . + [AspireExport("addDataLake", Description = "Adds an Azure Data Lake Storage resource")] public static IResourceBuilder AddDataLake(this IResourceBuilder builder, [ResourceName] string name) { ArgumentNullException.ThrowIfNull(builder); @@ -451,6 +461,7 @@ private static IResourceBuilder GetDataLakeService /// The name of the resource. /// The name of the blob container. /// An for the . + [AspireExport("addBlobContainer", Description = "Adds an Azure Blob Storage container resource")] public static IResourceBuilder AddBlobContainer(this IResourceBuilder builder, [ResourceName] string name, string? blobContainerName = null) { ArgumentNullException.ThrowIfNull(builder); @@ -487,6 +498,7 @@ public static IResourceBuilder AddBlobContain /// The name of the resource. /// The name of the data lake file system. /// An for the . + [AspireExport("addDataLakeFileSystem", Description = "Adds an Azure Data Lake Storage file system resource")] public static IResourceBuilder AddDataLakeFileSystem(this IResourceBuilder builder, [ResourceName] string name, string? dataLakeFileSystemName = null) { ArgumentNullException.ThrowIfNull(builder); @@ -545,6 +557,7 @@ public static IResourceBuilder AddBlobContain /// The for . /// The name of the resource. /// An for the . + [AspireExport("addTables", Description = "Adds an Azure Table Storage resource")] public static IResourceBuilder AddTables(this IResourceBuilder builder, [ResourceName] string name) { ArgumentNullException.ThrowIfNull(builder); @@ -559,6 +572,7 @@ public static IResourceBuilder AddTables(this IResour /// The for . /// The name of the resource. /// An for the . + [AspireExport("addQueues", Description = "Adds an Azure Queue Storage resource")] public static IResourceBuilder AddQueues(this IResourceBuilder builder, [ResourceName] string name) { ArgumentNullException.ThrowIfNull(builder); @@ -595,6 +609,7 @@ private static IResourceBuilder GetQueueService(this /// The name of the resource. /// The name of the queue. /// An for the . + [AspireExport("addQueue", Description = "Adds an Azure Storage queue resource")] public static IResourceBuilder AddQueue(this IResourceBuilder builder, [ResourceName] string name, string? queueName = null) { ArgumentNullException.ThrowIfNull(builder); From 0e2fce74ea48137130d0f8ecd6c4a58e8f72c492 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 10 Feb 2026 00:19:45 -0800 Subject: [PATCH 070/256] Fix Spectre markup escaping bugs across CLI commands (#14422) * Add failing tests for unescaped Spectre markup in CLI output * Fix Spectre markup escaping bugs across CLI commands - ConsoleInteractionService: Escape appHostHostingVersion, RequiredCapability in DisplayIncompatibleVersionError, and newerVersion/updateCommand in DisplayVersionUpdateNotification - RunCommand: Remove double-escaping of ex.Message before DisplayError (lines 355,362,371), escape resource/endpoint names in Markup (line 313), escape log file paths in DisplayMessage (lines 366,375,850) - LogsCommand: Escape logLine.ResourceName in MarkupLine (line 337) - TelemetryLogsCommand/TelemetrySpansCommand: Escape resourceName in MarkupLine (lines 261,267) - Add tests: DisplayError double-escape verification, DisplayMessage with escaped/unescaped paths, DisplaySubtleMessage default escaping, choice prompt with bracket-containing Azure subscription names * Fix Spectre markup escaping: escape link targets and CLI version string - RunCommand: escape endpoint URL in link= attribute (IPv6 [::1] URLs crash Spectre) - ConsoleInteractionService: escape cliInformationalVersion (brackets in build metadata crash Spectre) - Verified: LogsCommand double-escaping is NOT a bug (both approaches produce identical markup) * Remove tests that don't exercise actual Spectre escaping * Fix DisplayIncompatibleVersionError showing capability name instead of hosting version --- .../AppHostIncompatibleException.cs | 3 +- src/Aspire.Cli/Commands/LogsCommand.cs | 2 +- src/Aspire.Cli/Commands/RunCommand.cs | 16 +- .../Commands/TelemetryLogsCommand.cs | 2 +- .../Commands/TelemetrySpansCommand.cs | 2 +- .../Interaction/ConsoleInteractionService.cs | 14 +- .../Projects/DotNetAppHostProject.cs | 3 +- ...PublishCommandPromptingIntegrationTests.cs | 42 ---- .../ConsoleInteractionServiceTests.cs | 193 ++++++++++++++++++ 9 files changed, 215 insertions(+), 62 deletions(-) diff --git a/src/Aspire.Cli/Backchannel/AppHostIncompatibleException.cs b/src/Aspire.Cli/Backchannel/AppHostIncompatibleException.cs index 1487a81efd9..74afd98b29f 100644 --- a/src/Aspire.Cli/Backchannel/AppHostIncompatibleException.cs +++ b/src/Aspire.Cli/Backchannel/AppHostIncompatibleException.cs @@ -3,7 +3,8 @@ namespace Aspire.Cli.Backchannel; -internal class AppHostIncompatibleException(string message, string requiredCapability) : Exception(message) +internal class AppHostIncompatibleException(string message, string requiredCapability, string? aspireHostingVersion = null) : Exception(message) { public string RequiredCapability { get; } = requiredCapability; + public string? AspireHostingVersion { get; } = aspireHostingVersion; } diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs index c06426e13f9..e4dd45dca33 100644 --- a/src/Aspire.Cli/Commands/LogsCommand.cs +++ b/src/Aspire.Cli/Commands/LogsCommand.cs @@ -334,7 +334,7 @@ private void OutputLogLine(ResourceLogLine logLine, OutputFormat format) // Colorized output: assign a consistent color to each resource var color = GetResourceColor(logLine.ResourceName); var escapedContent = logLine.Content.EscapeMarkup(); - AnsiConsole.MarkupLine($"[{color}][[{logLine.ResourceName}]][/] {escapedContent}"); + AnsiConsole.MarkupLine($"[{color}][[{logLine.ResourceName.EscapeMarkup()}]][/] {escapedContent}"); } else { diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index b5170cc3b60..e09d37da8f9 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -310,7 +310,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell endpointsGrid.AddRow( firstEndpoint ? new Align(new Markup($"[bold green]{endpointsLocalizedString}[/]:"), HorizontalAlignment.Right) : Text.Empty, - new Markup($"[bold]{resource}[/] [grey]has endpoint[/] [link={endpoint}]{endpoint}[/]") + new Markup($"[bold]{resource.EscapeMarkup()}[/] [grey]has endpoint[/] [link={endpoint.EscapeMarkup()}]{endpoint.EscapeMarkup()}[/]") ); var endpointsPadder = new Padder(endpointsGrid, new Padding(3, 0)); @@ -348,31 +348,31 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell catch (AppHostIncompatibleException ex) { Telemetry.RecordError(ex.Message, ex); - return InteractionService.DisplayIncompatibleVersionError(ex, ex.RequiredCapability); + return InteractionService.DisplayIncompatibleVersionError(ex, ex.AspireHostingVersion ?? ex.RequiredCapability); } catch (CertificateServiceException ex) { - var errorMessage = string.Format(CultureInfo.CurrentCulture, TemplatingStrings.CertificateTrustError, ex.Message.EscapeMarkup()); + var errorMessage = string.Format(CultureInfo.CurrentCulture, TemplatingStrings.CertificateTrustError, ex.Message); Telemetry.RecordError(errorMessage, ex); InteractionService.DisplayError(errorMessage); return ExitCodeConstants.FailedToTrustCertificates; } catch (FailedToConnectBackchannelConnection ex) { - var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ErrorConnectingToAppHost, ex.Message.EscapeMarkup()); + var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ErrorConnectingToAppHost, ex.Message); Telemetry.RecordError(errorMessage, ex); InteractionService.DisplayError(errorMessage); // Don't display raw output - it's already in the log file - InteractionService.DisplayMessage("page_facing_up", string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath)); + InteractionService.DisplayMessage("page_facing_up", string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath.EscapeMarkup())); return ExitCodeConstants.FailedToDotnetRunAppHost; } catch (Exception ex) { - var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message.EscapeMarkup()); + var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message); Telemetry.RecordError(errorMessage, ex); InteractionService.DisplayError(errorMessage); // Don't display raw output - it's already in the log file - InteractionService.DisplayMessage("page_facing_up", string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath)); + InteractionService.DisplayMessage("page_facing_up", string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.SeeLogsAt, ExecutionContext.LogFilePath.EscapeMarkup())); return ExitCodeConstants.FailedToDotnetRunAppHost; } } @@ -850,7 +850,7 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? _interactionService.DisplayMessage("magnifying_glass_tilted_right", string.Format( CultureInfo.CurrentCulture, RunCommandStrings.CheckLogsForDetails, - _fileLoggerProvider.LogFilePath)); + _fileLoggerProvider.LogFilePath.EscapeMarkup())); return ExitCodeConstants.FailedToDotnetRunAppHost; } diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs index c363581dbb7..5312b6be831 100644 --- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs @@ -258,6 +258,6 @@ private static void DisplayLogEntry(string resourceName, OtlpLogRecordJson log) var severityColor = TelemetryCommandHelpers.GetSeverityColor(log.SeverityNumber); var escapedBody = body.EscapeMarkup(); - AnsiConsole.MarkupLine($"[grey]{timestamp}[/] [{severityColor}]{severity,-5}[/] [cyan]{resourceName}[/] {escapedBody}"); + AnsiConsole.MarkupLine($"[grey]{timestamp}[/] [{severityColor}]{severity,-5}[/] [cyan]{resourceName.EscapeMarkup()}[/] {escapedBody}"); } } diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs index a82e6163976..ba29aa1f7fd 100644 --- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs @@ -264,6 +264,6 @@ private static void DisplaySpanEntry(string resourceName, OtlpSpanJson span) var durationStr = TelemetryCommandHelpers.FormatDuration(duration); var escapedName = name.EscapeMarkup(); - AnsiConsole.MarkupLine($"[grey]{shortSpanId}[/] [cyan]{resourceName,-15}[/] [{statusColor}]{statusText}[/] [white]{durationStr,8}[/] {escapedName}"); + AnsiConsole.MarkupLine($"[grey]{shortSpanId}[/] [cyan]{resourceName.EscapeMarkup(),-15}[/] [{statusColor}]{statusText}[/] [white]{durationStr,8}[/] {escapedName}"); } } diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index 0e1bc787647..4c7d8487ebd 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -145,7 +145,7 @@ public async Task PromptForSelectionAsync(string promptText, IEnumerable() .Title(promptText) - .UseConverter(choiceFormatter) + .UseConverter(item => choiceFormatter(item).EscapeMarkup()) .AddChoices(choices) .PageSize(10) .EnableSearch(); @@ -174,7 +174,7 @@ public async Task> PromptForSelectionsAsync(string promptTex var prompt = new MultiSelectionPrompt() .Title(promptText) - .UseConverter(choiceFormatter) + .UseConverter(item => choiceFormatter(item).EscapeMarkup()) .AddChoices(choices) .PageSize(10); @@ -189,9 +189,9 @@ public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, stri DisplayError(InteractionServiceStrings.AppHostNotCompatibleConsiderUpgrading); Console.WriteLine(); _outConsole.MarkupLine( - $"\t[bold]{InteractionServiceStrings.AspireHostingSDKVersion}[/]: {appHostHostingVersion}"); - _outConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.AspireCLIVersion}[/]: {cliInformationalVersion}"); - _outConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.RequiredCapability}[/]: {ex.RequiredCapability}"); + $"\t[bold]{InteractionServiceStrings.AspireHostingSDKVersion}[/]: {appHostHostingVersion.EscapeMarkup()}"); + _outConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.AspireCLIVersion}[/]: {cliInformationalVersion.EscapeMarkup()}"); + _outConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.RequiredCapability}[/]: {ex.RequiredCapability.EscapeMarkup()}"); Console.WriteLine(); return ExitCodeConstants.AppHostIncompatible; } @@ -303,11 +303,11 @@ public void DisplayVersionUpdateNotification(string newerVersion, string? update { // Write to stderr to avoid corrupting stdout when JSON output is used _errorConsole.WriteLine(); - _errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NewCliVersionAvailable, newerVersion)); + _errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NewCliVersionAvailable, newerVersion.EscapeMarkup())); if (!string.IsNullOrEmpty(updateCommand)) { - _errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ToUpdateRunCommand, updateCommand)); + _errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ToUpdateRunCommand, updateCommand.EscapeMarkup())); } _errorConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.MoreInfoNewCliVersion, UpdateUrl)); diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index c2f96d8a5cd..f47686fa1a3 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -379,7 +379,8 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca { var exception = new AppHostIncompatibleException( $"The app host is not compatible. Aspire.Hosting version: {compatibilityCheck.AspireHostingVersion}", - "Aspire.Hosting"); + "Aspire.Hosting", + compatibilityCheck.AspireHostingVersion); // Signal the backchannel completion source so the caller doesn't wait forever context.BackchannelCompletionSource?.TrySetException(exception); throw exception; diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 02646fd80c4..0d07a8c2aa7 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -720,48 +720,6 @@ public async Task PublishCommand_SingleInputPrompt_WhenStatusTextEqualsLabel_Sho // Should show: [bold]Environment Name[/] Assert.Equal("[bold]Environment Name[/]", promptCall.PromptText); } - - [Fact] - public async Task PublishCommand_SingleInputPrompt_EscapesSpectreMarkupInLabels() - { - // Arrange - using var workspace = TemporaryWorkspace.Create(outputHelper); - var promptBackchannel = new TestPromptBackchannel(); - var consoleService = new TestConsoleInteractionServiceWithPromptTracking(); - - // Set up a single-input prompt with Spectre markup characters in both StatusText and Label - promptBackchannel.AddPrompt("markup-prompt", "Value [required]", InputTypes.Text, "Enter value [1-10]", isRequired: true); - - // Set up the expected user response - consoleService.SetupStringPromptResponse("5"); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.ProjectLocatorFactory = (sp) => new TestProjectLocator(); - options.DotNetCliRunnerFactory = (sp) => CreateTestRunnerWithPromptBackchannel(promptBackchannel); - }); - - services.AddSingleton(consoleService); - - var serviceProvider = services.BuildServiceProvider(); - var command = serviceProvider.GetRequiredService(); - - // Act - var result = command.Parse("publish"); - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - // Assert - Assert.Equal(0, exitCode); - - // Verify that square brackets are properly escaped - var promptCalls = consoleService.StringPromptCalls; - Assert.Single(promptCalls); - var promptCall = promptCalls[0]; - - // Square brackets should be escaped to [[bracket]] - Assert.Contains("[[1-10]]", promptCall.PromptText); - Assert.Contains("[[required]]", promptCall.PromptText); - } } // Test implementation of IAppHostCliBackchannel that simulates prompt interactions diff --git a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs index 3ee92c32353..090fe5f1f35 100644 --- a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.InternalTesting; +using Aspire.Cli.Backchannel; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Utils; @@ -354,4 +355,196 @@ public void ShowStatus_NestedCall_DoesNotThrowException() Assert.Contains(outerStatusText, outputString); Assert.Contains(innerStatusText, outputString); } + + [Fact] + public void DisplayIncompatibleVersionError_WithMarkupCharactersInVersion_DoesNotThrow() + { + // Arrange + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var interactionService = CreateInteractionService(console, executionContext); + + var ex = new AppHostIncompatibleException("Incompatible [version]", "capability [Prod]"); + + // Act - should not throw due to unescaped markup characters + var exception = Record.Exception(() => interactionService.DisplayIncompatibleVersionError(ex, "9.0.0-preview.1 [rc]")); + + // Assert + Assert.Null(exception); + var outputString = output.ToString(); + Assert.Contains("capability [Prod]", outputString); + Assert.Contains("9.0.0-preview.1 [rc]", outputString); + } + + [Fact] + public void DisplayMessage_WithMarkupCharactersInMessage_DoesNotThrow() + { + // Arrange + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var interactionService = CreateInteractionService(console, executionContext); + + // DisplayMessage passes its message directly to MarkupLine. + // Callers that embed external data must escape it first. + var message = "See logs at C:\\Users\\test [Dev]\\logs\\aspire.log"; + + // Act - should not throw due to unescaped markup characters + var exception = Record.Exception(() => interactionService.DisplayMessage("page_facing_up", message.EscapeMarkup())); + + // Assert + Assert.Null(exception); + var outputString = output.ToString(); + Assert.Contains("C:\\Users\\test [Dev]\\logs\\aspire.log", outputString); + } + + [Fact] + public void DisplayVersionUpdateNotification_WithMarkupCharactersInVersion_DoesNotThrow() + { + // Arrange + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var interactionService = CreateInteractionService(console, executionContext); + + // Version strings are unlikely to have brackets, but the method should handle it + var version = "13.2.0-preview [beta]"; + var updateCommand = "aspire update --channel [stable]"; + + // Act - should not throw due to unescaped markup characters + var exception = Record.Exception(() => interactionService.DisplayVersionUpdateNotification(version, updateCommand)); + + // Assert + Assert.Null(exception); + var outputString = output.ToString(); + Assert.Contains("13.2.0-preview [beta]", outputString); + Assert.Contains("aspire update --channel [stable]", outputString); + } + + [Fact] + public void DisplayError_WithMarkupCharactersInMessage_DoesNotDoubleEscape() + { + // Arrange - verifies that DisplayError escapes once (callers should NOT pre-escape) + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var interactionService = CreateInteractionService(console, executionContext); + + // Error message with brackets (e.g., from an exception) + var errorMessage = "Failed to connect to service [Prod]: Connection refused "; + + // Act - should not throw + var exception = Record.Exception(() => interactionService.DisplayError(errorMessage)); + + // Assert + Assert.Null(exception); + var outputString = output.ToString(); + // Should contain the original text (not double-escaped like [[Prod]]) + Assert.Contains("[Prod]", outputString); + Assert.DoesNotContain("[[Prod]]", outputString); + } + + [Fact] + public void DisplayMessage_WithUnescapedLogFilePath_Throws() + { + // Arrange - verifies that DisplayMessage requires callers to escape external data + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var interactionService = CreateInteractionService(console, executionContext); + + // Path with brackets that looks like Spectre markup if not escaped + var path = @"C:\Users\[Dev Team]\logs\aspire.log"; + + // Act - unescaped path should cause a Spectre markup error + var exception = Record.Exception(() => interactionService.DisplayMessage("page_facing_up", $"See logs at {path}")); + + // Assert - this should throw because [Dev Team] is interpreted as markup + Assert.NotNull(exception); + } + + [Fact] + public void DisplayMessage_WithEscapedLogFilePath_DoesNotThrow() + { + // Arrange - verifies that properly escaped paths work in DisplayMessage + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var interactionService = CreateInteractionService(console, executionContext); + + // Path with brackets - properly escaped + var path = @"C:\Users\[Dev Team]\logs\aspire.log".EscapeMarkup(); + + // Act + var exception = Record.Exception(() => interactionService.DisplayMessage("page_facing_up", $"See logs at {path}")); + + // Assert + Assert.Null(exception); + var outputString = output.ToString(); + Assert.Contains(@"C:\Users\[Dev Team]\logs\aspire.log", outputString); + } + + [Fact] + public void DisplaySubtleMessage_WithMarkupCharacters_EscapesByDefault() + { + // Arrange - verifies that DisplaySubtleMessage escapes by default + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var executionContext = new CliExecutionContext(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + var interactionService = CreateInteractionService(console, executionContext); + + // Message with all kinds of markup characters + var message = "Error in [Module]: value $.items[0] invalid"; + + // Act + var exception = Record.Exception(() => interactionService.DisplaySubtleMessage(message)); + + // Assert + Assert.Null(exception); + var outputString = output.ToString(); + Assert.Contains("[Module]", outputString); + } } From a30335eda528bf22ec62a1501975056ff49ad357 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 10 Feb 2026 11:57:53 -0600 Subject: [PATCH 071/256] Add NAT Gateway and Public IP Address support to Azure Virtual Network (#14413) * Add NAT Gateway and Public IP Address support to Azure Virtual Network Add AzureNatGatewayResource and AzurePublicIPAddressResource as standalone top-level Azure provisioning resources. A NAT Gateway provides deterministic outbound IP addresses for subnet resources. Key design decisions: - NAT Gateway is a standalone resource (builder.AddNatGateway), not a VNet child - A Public IP Address is auto-created inline in the NAT Gateway bicep module when no explicit PIP is provided via WithPublicIPAddress() - AzurePublicIPAddressResource is a public reusable type for explicit PIP scenarios - Cross-module references use BicepOutputReference + AsProvisioningParameter - Advanced config (idle timeout, zones) available via ConfigureInfrastructure New public API: - AzureNatGatewayResource (.Id, .NameOutput) - AzurePublicIPAddressResource (.Id, .NameOutput) - AddNatGateway() extension on IDistributedApplicationBuilder - AddPublicIPAddress() extension on IDistributedApplicationBuilder - WithPublicIPAddress() extension on IResourceBuilder - WithNatGateway() extension on IResourceBuilder * Address PR feedback Add Tag to auto-created Public IP Address. --- .../Program.cs | 4 + .../aspire-manifest.json | 9 +- .../nat.module.bicep | 38 +++++ .../vnet.module.bicep | 5 + .../AzureNatGatewayExtensions.cs | 149 ++++++++++++++++++ .../AzureNatGatewayResource.cs | 60 +++++++ .../AzurePublicIPAddressExtensions.cs | 84 ++++++++++ .../AzurePublicIPAddressResource.cs | 54 +++++++ .../AzureSubnetResource.cs | 11 ++ .../AzureVirtualNetworkExtensions.cs | 30 ++++ src/Aspire.Hosting.Azure.Network/README.md | 25 ++- .../AzureNatGatewayExtensionsTests.cs | 94 +++++++++++ .../AzureVirtualNetworkExtensionsTests.cs | 15 ++ ...teway_GeneratesCorrectBicep.verified.bicep | 38 +++++ ...licIP_GeneratesCorrectBicep.verified.bicep | 26 +++ ...dress_GeneratesCorrectBicep.verified.bicep | 20 +++ ...teway_GeneratesCorrectBicep.verified.bicep | 36 +++++ 17 files changed, 696 insertions(+), 2 deletions(-) create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/nat.module.bicep create mode 100644 src/Aspire.Hosting.Azure.Network/AzureNatGatewayExtensions.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzurePublicIPAddressExtensions.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzurePublicIPAddressResource.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/AzureNatGatewayExtensionsTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNatGatewayExtensionsTests.AddNatGateway_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNatGatewayExtensionsTests.AddNatGateway_WithExplicitPublicIP_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNatGatewayExtensionsTests.AddPublicIPAddress_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddSubnet_WithNatGateway_GeneratesCorrectBicep.verified.bicep diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs index 1eda44bb945..b44559b0405 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs @@ -11,6 +11,10 @@ var containerAppsSubnet = vnet.AddSubnet("container-apps", "10.0.0.0/23"); var privateEndpointsSubnet = vnet.AddSubnet("private-endpoints", "10.0.2.0/27"); +// Create a NAT Gateway for deterministic outbound IP on the ACA subnet +var natGateway = builder.AddNatGateway("nat"); +containerAppsSubnet.WithNatGateway(natGateway); + // Configure the Container App Environment to use the VNet builder.AddAzureContainerAppEnvironment("env") .WithDelegatedSubnet(containerAppsSubnet); diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json index 2c8a2db9647..03c2843e0a9 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json @@ -3,7 +3,14 @@ "resources": { "vnet": { "type": "azure.bicep.v0", - "path": "vnet.module.bicep" + "path": "vnet.module.bicep", + "params": { + "nat_outputs_id": "{nat.outputs.id}" + } + }, + "nat": { + "type": "azure.bicep.v0", + "path": "nat.module.bicep" }, "env-acr": { "type": "azure.bicep.v0", diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/nat.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/nat.module.bicep new file mode 100644 index 00000000000..e3409233b80 --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/nat.module.bicep @@ -0,0 +1,38 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource nat_pip 'Microsoft.Network/publicIPAddresses@2025-05-01' = { + name: take('nat_pip-${uniqueString(resourceGroup().id)}', 80) + location: location + properties: { + publicIPAllocationMethod: 'Static' + } + sku: { + name: 'Standard' + } + tags: { + 'aspire-resource-name': 'nat' + } +} + +resource nat 'Microsoft.Network/natGateways@2025-05-01' = { + name: take('nat${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + publicIpAddresses: [ + { + id: nat_pip.id + } + ] + } + sku: { + name: 'Standard' + } + tags: { + 'aspire-resource-name': 'nat' + } +} + +output id string = nat.id + +output name string = nat.name \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep index 10c4439e46a..03505a7e601 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep @@ -1,6 +1,8 @@ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location +param nat_outputs_id string + resource vnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { name: take('vnet-${uniqueString(resourceGroup().id)}', 64) properties: { @@ -28,6 +30,9 @@ resource container_apps 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = name: 'Microsoft.App/environments' } ] + natGateway: { + id: nat_outputs_id + } } parent: vnet } diff --git a/src/Aspire.Hosting.Azure.Network/AzureNatGatewayExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureNatGatewayExtensions.cs new file mode 100644 index 00000000000..111d8b7bade --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzureNatGatewayExtensions.cs @@ -0,0 +1,149 @@ +// 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; +using Aspire.Hosting.Azure; +using Azure.Provisioning; +using Azure.Provisioning.Network; +using Azure.Provisioning.Resources; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Azure NAT Gateway resources to the application model. +/// +public static class AzureNatGatewayExtensions +{ + /// + /// Adds an Azure NAT Gateway resource to the application model. + /// + /// The builder for the distributed application. + /// The name of the Azure NAT Gateway resource. + /// A reference to the . + /// + /// The NAT Gateway is created with Standard SKU. If no Public IP Address is explicitly associated + /// via , a Public IP Address is automatically created in the + /// NAT Gateway's bicep module with Standard SKU and Static allocation. + /// + /// + /// This example creates a NAT Gateway and associates it with a subnet: + /// + /// var natGateway = builder.AddNatGateway("nat"); + /// + /// var vnet = builder.AddAzureVirtualNetwork("vnet"); + /// var subnet = vnet.AddSubnet("aca-subnet", "10.0.0.0/23") + /// .WithNatGateway(natGateway); + /// + /// + public static IResourceBuilder AddNatGateway( + this IDistributedApplicationBuilder builder, + [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + builder.AddAzureProvisioning(); + + var resource = new AzureNatGatewayResource(name, ConfigureNatGateway); + + if (builder.ExecutionContext.IsRunMode) + { + return builder.CreateResourceBuilder(resource); + } + + return builder.AddResource(resource); + } + + /// + /// Associates an explicit Public IP Address resource with the NAT Gateway. + /// + /// The NAT Gateway resource builder. + /// The Public IP Address resource to associate. + /// A reference to the for chaining. + /// + /// When an explicit Public IP Address is provided, the NAT Gateway will not auto-create one. + /// + /// + /// This example creates a NAT Gateway with an explicit Public IP: + /// + /// var pip = builder.AddPublicIPAddress("nat-pip"); + /// var natGateway = builder.AddNatGateway("nat") + /// .WithPublicIPAddress(pip); + /// + /// + public static IResourceBuilder WithPublicIPAddress( + this IResourceBuilder builder, + IResourceBuilder publicIPAddress) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(publicIPAddress); + + builder.Resource.PublicIPAddresses.Add(publicIPAddress.Resource); + return builder; + } + + private static void ConfigureNatGateway(AzureResourceInfrastructure infra) + { + var azureResource = (AzureNatGatewayResource)infra.AspireResource; + + var natGw = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, + (identifier, name) => + { + var resource = NatGateway.FromExisting(identifier); + resource.Name = name; + return resource; + }, + (infrastructure) => + { + var natGw = new NatGateway(infrastructure.AspireResource.GetBicepIdentifier()) + { + SkuName = NatGatewaySkuName.Standard, + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + + // If explicit Public IP addresses are provided, reference them via parameters + if (azureResource.PublicIPAddresses.Count > 0) + { + foreach (var pipResource in azureResource.PublicIPAddresses) + { + var pipIdParam = pipResource.Id.AsProvisioningParameter(infrastructure); + natGw.PublicIPAddresses.Add(new WritableSubResource + { + Id = pipIdParam + }); + } + } + else + { + // Auto-create a Public IP Address inline + var pip = new PublicIPAddress($"{infrastructure.AspireResource.GetBicepIdentifier()}_pip") + { + Sku = new PublicIPAddressSku() + { + Name = PublicIPAddressSkuName.Standard, + }, + PublicIPAllocationMethod = NetworkIPAllocationMethod.Static, + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + infrastructure.Add(pip); + + natGw.PublicIPAddresses.Add(new WritableSubResource + { + Id = pip.Id + }); + } + + return natGw; + }); + + infra.Add(new ProvisioningOutput("id", typeof(string)) + { + Value = natGw.Id + }); + + infra.Add(new ProvisioningOutput("name", typeof(string)) + { + Value = natGw.Name + }); + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs b/src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs new file mode 100644 index 00000000000..2a340ef8f21 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzureNatGatewayResource.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.Provisioning.Network; +using Azure.Provisioning.Primitives; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure NAT Gateway resource. +/// +/// +/// A NAT Gateway provides outbound internet connectivity for resources in a virtual network subnet. +/// Use +/// to configure specific properties. +/// +/// The name of the resource. +/// Callback to configure the Azure NAT Gateway resource. +public class AzureNatGatewayResource(string name, Action configureInfrastructure) + : AzureProvisioningResource(name, configureInfrastructure) +{ + /// + /// Gets the "id" output reference from the Azure NAT Gateway resource. + /// + public BicepOutputReference Id => new("id", this); + + /// + /// Gets the "name" output reference for the resource. + /// + public BicepOutputReference NameOutput => new("name", this); + + /// + /// Gets the list of explicit Public IP Address resources associated with this NAT Gateway. + /// + internal List PublicIPAddresses { get; } = []; + + /// + public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) + { + var bicepIdentifier = this.GetBicepIdentifier(); + var resources = infra.GetProvisionableResources(); + + var existing = resources.OfType().SingleOrDefault(r => r.BicepIdentifier == bicepIdentifier); + + if (existing is not null) + { + return existing; + } + + var natGw = NatGateway.FromExisting(bicepIdentifier); + + if (!TryApplyExistingResourceAnnotation(this, infra, natGw)) + { + natGw.Name = NameOutput.AsProvisioningParameter(infra); + } + + infra.Add(natGw); + return natGw; + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzurePublicIPAddressExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzurePublicIPAddressExtensions.cs new file mode 100644 index 00000000000..3eaf9ec2f02 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzurePublicIPAddressExtensions.cs @@ -0,0 +1,84 @@ +// 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; +using Aspire.Hosting.Azure; +using Azure.Provisioning; +using Azure.Provisioning.Network; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Azure Public IP Address resources to the application model. +/// +public static class AzurePublicIPAddressExtensions +{ + /// + /// Adds an Azure Public IP Address resource to the application model. + /// + /// The builder for the distributed application. + /// The name of the Azure Public IP Address resource. + /// A reference to the . + /// + /// The Public IP Address is created with Standard SKU and Static allocation by default. + /// Use + /// to customize properties such as DNS labels, availability zones, or IP version. + /// + /// + /// This example creates a Public IP Address: + /// + /// var pip = builder.AddPublicIPAddress("my-pip"); + /// + /// + public static IResourceBuilder AddPublicIPAddress( + this IDistributedApplicationBuilder builder, + [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + builder.AddAzureProvisioning(); + + var resource = new AzurePublicIPAddressResource(name, ConfigurePublicIPAddress); + + if (builder.ExecutionContext.IsRunMode) + { + return builder.CreateResourceBuilder(resource); + } + + return builder.AddResource(resource); + } + + private static void ConfigurePublicIPAddress(AzureResourceInfrastructure infra) + { + var pip = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, + (identifier, name) => + { + var resource = PublicIPAddress.FromExisting(identifier); + resource.Name = name; + return resource; + }, + (infrastructure) => + { + return new PublicIPAddress(infrastructure.AspireResource.GetBicepIdentifier()) + { + Sku = new PublicIPAddressSku() + { + Name = PublicIPAddressSkuName.Standard, + }, + PublicIPAllocationMethod = NetworkIPAllocationMethod.Static, + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + }); + + infra.Add(new ProvisioningOutput("id", typeof(string)) + { + Value = pip.Id + }); + + infra.Add(new ProvisioningOutput("name", typeof(string)) + { + Value = pip.Name + }); + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzurePublicIPAddressResource.cs b/src/Aspire.Hosting.Azure.Network/AzurePublicIPAddressResource.cs new file mode 100644 index 00000000000..bd1dba2bcc4 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzurePublicIPAddressResource.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.Provisioning.Network; +using Azure.Provisioning.Primitives; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure Public IP Address resource. +/// +/// +/// Use +/// to configure specific properties such as DNS labels, zones, or IP version. +/// +/// The name of the resource. +/// Callback to configure the Azure Public IP Address resource. +public class AzurePublicIPAddressResource(string name, Action configureInfrastructure) + : AzureProvisioningResource(name, configureInfrastructure) +{ + /// + /// Gets the "id" output reference from the Azure Public IP Address resource. + /// + public BicepOutputReference Id => new("id", this); + + /// + /// Gets the "name" output reference for the resource. + /// + public BicepOutputReference NameOutput => new("name", this); + + /// + public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) + { + var bicepIdentifier = this.GetBicepIdentifier(); + var resources = infra.GetProvisionableResources(); + + var existing = resources.OfType().SingleOrDefault(r => r.BicepIdentifier == bicepIdentifier); + + if (existing is not null) + { + return existing; + } + + var pip = PublicIPAddress.FromExisting(bicepIdentifier); + + if (!TryApplyExistingResourceAnnotation(this, infra, pip)) + { + pip.Name = NameOutput.AsProvisioningParameter(infra); + } + + infra.Add(pip); + return pip; + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs index 7717c6d3674..3526a1f815e 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs @@ -77,6 +77,11 @@ public AzureSubnetResource(string name, string subnetName, ParameterResource add /// public AzureVirtualNetworkResource Parent { get; } + /// + /// Gets or sets the NAT Gateway associated with the subnet. + /// + internal AzureNatGatewayResource? NatGateway { get; set; } + private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) => !string.IsNullOrEmpty(argument) ? argument : throw new ArgumentNullException(paramName); @@ -118,6 +123,12 @@ internal SubnetResource ToProvisioningEntity(AzureResourceInfrastructure infra, }); } + if (NatGateway is not null) + { + // The NAT Gateway lives in a separate bicep module, so reference its ID via parameter + subnet.NatGatewayId = NatGateway.Id.AsProvisioningParameter(infra); + } + // add a provisioning output for the subnet ID so it can be referenced by other resources infra.Add(new ProvisioningOutput(Id.Name, typeof(string)) { diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs index 9f464e95b8d..967a9f80b47 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs @@ -273,4 +273,34 @@ public static IResourceBuilder WithDelegatedSubnet( return builder; } + + /// + /// Associates a NAT Gateway with the subnet. + /// + /// The subnet resource builder. + /// The NAT Gateway to associate with the subnet. + /// A reference to the for chaining. + /// + /// A NAT Gateway provides outbound internet connectivity for resources in the subnet. + /// A subnet can have at most one NAT Gateway. + /// + /// + /// This example creates a subnet with an associated NAT Gateway: + /// + /// var natGateway = builder.AddNatGateway("nat"); + /// var vnet = builder.AddAzureVirtualNetwork("vnet"); + /// var subnet = vnet.AddSubnet("aca-subnet", "10.0.0.0/23") + /// .WithNatGateway(natGateway); + /// + /// + public static IResourceBuilder WithNatGateway( + this IResourceBuilder builder, + IResourceBuilder natGateway) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(natGateway); + + builder.Resource.NatGateway = natGateway.Resource; + return builder; + } } diff --git a/src/Aspire.Hosting.Azure.Network/README.md b/src/Aspire.Hosting.Azure.Network/README.md index 09dbf9abf2c..7fafd3b39f6 100644 --- a/src/Aspire.Hosting.Azure.Network/README.md +++ b/src/Aspire.Hosting.Azure.Network/README.md @@ -1,6 +1,6 @@ # Aspire.Hosting.Azure.Network library -Provides extension methods and resource definitions for an Aspire AppHost to configure Azure Virtual Networks, Subnets, and Private Endpoints. +Provides extension methods and resource definitions for an Aspire AppHost to configure Azure Virtual Networks, Subnets, NAT Gateways, Public IP Addresses, and Private Endpoints. ## Getting started @@ -61,6 +61,28 @@ var vnet = builder.AddAzureVirtualNetwork("vnet"); var subnet = vnet.AddSubnet("subnet", "10.0.1.0/24"); ``` +### Adding NAT Gateways + +A NAT Gateway provides outbound internet connectivity with deterministic public IP addresses: + +```csharp +var natGateway = builder.AddNatGateway("nat"); + +var vnet = builder.AddAzureVirtualNetwork("vnet"); +var subnet = vnet.AddSubnet("aca-subnet", "10.0.0.0/23") + .WithNatGateway(natGateway); +``` + +By default, a Public IP Address is automatically created. You can provide an explicit one for full control: + +```csharp +var pip = builder.AddPublicIPAddress("nat-pip"); +var natGateway = builder.AddNatGateway("nat") + .WithPublicIPAddress(pip); +``` + +Use `ConfigureInfrastructure` for advanced settings like idle timeout or availability zones. + ### Adding Private Endpoints Create a private endpoint to securely connect to Azure resources over a private network: @@ -98,6 +120,7 @@ storage.ConfigureInfrastructure(infra => ## Additional documentation * https://learn.microsoft.com/azure/virtual-network/ +* https://learn.microsoft.com/azure/nat-gateway/ * https://learn.microsoft.com/azure/private-link/ ## Feedback & contributing diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureNatGatewayExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureNatGatewayExtensionsTests.cs new file mode 100644 index 00000000000..723c0acd18a --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzureNatGatewayExtensionsTests.cs @@ -0,0 +1,94 @@ +// 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.Hosting.Azure.Tests; + +public class AzureNatGatewayExtensionsTests +{ + [Fact] + public void AddNatGateway_CreatesResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var natGw = builder.AddNatGateway("mynat"); + + Assert.NotNull(natGw); + Assert.Equal("mynat", natGw.Resource.Name); + Assert.IsType(natGw.Resource); + } + + [Fact] + public void AddNatGateway_InRunMode_DoesNotAddToBuilder() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var natGw = builder.AddNatGateway("mynat"); + + Assert.DoesNotContain(natGw.Resource, builder.Resources); + } + + [Fact] + public async Task AddNatGateway_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddNatGateway("mynat"); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(builder.Resources.OfType().Single()); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddNatGateway_WithExplicitPublicIP_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var pip = builder.AddPublicIPAddress("mypip"); + builder.AddNatGateway("mynat") + .WithPublicIPAddress(pip); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(builder.Resources.OfType().Single()); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public void WithNatGateway_SetsSubnetNatGateway() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var natGw = builder.AddNatGateway("mynat"); + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("mysubnet", "10.0.1.0/24") + .WithNatGateway(natGw); + + Assert.Same(natGw.Resource, subnet.Resource.NatGateway); + } + + [Fact] + public void AddPublicIPAddress_CreatesResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var pip = builder.AddPublicIPAddress("mypip"); + + Assert.NotNull(pip); + Assert.Equal("mypip", pip.Resource.Name); + Assert.IsType(pip.Resource); + } + + [Fact] + public async Task AddPublicIPAddress_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + builder.AddPublicIPAddress("mypip"); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(builder.Resources.OfType().Single()); + + await Verify(manifest.BicepText, extension: "bicep"); + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs index 470e252ad89..2de77fe99e5 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs @@ -202,4 +202,19 @@ public async Task AddSubnet_WithParameterResource_GeneratesBicep() await Verify(manifest.BicepText, extension: "bicep"); } + + [Fact] + public async Task AddSubnet_WithNatGateway_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var natGw = builder.AddNatGateway("mynat"); + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + vnet.AddSubnet("mysubnet", "10.0.1.0/24") + .WithNatGateway(natGw); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNatGatewayExtensionsTests.AddNatGateway_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNatGatewayExtensionsTests.AddNatGateway_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..5027a6308f4 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNatGatewayExtensionsTests.AddNatGateway_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,38 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource mynat_pip 'Microsoft.Network/publicIPAddresses@2025-05-01' = { + name: take('mynat_pip-${uniqueString(resourceGroup().id)}', 80) + location: location + properties: { + publicIPAllocationMethod: 'Static' + } + sku: { + name: 'Standard' + } + tags: { + 'aspire-resource-name': 'mynat' + } +} + +resource mynat 'Microsoft.Network/natGateways@2025-05-01' = { + name: take('mynat${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + publicIpAddresses: [ + { + id: mynat_pip.id + } + ] + } + sku: { + name: 'Standard' + } + tags: { + 'aspire-resource-name': 'mynat' + } +} + +output id string = mynat.id + +output name string = mynat.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNatGatewayExtensionsTests.AddNatGateway_WithExplicitPublicIP_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNatGatewayExtensionsTests.AddNatGateway_WithExplicitPublicIP_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..2e548b39177 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNatGatewayExtensionsTests.AddNatGateway_WithExplicitPublicIP_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,26 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param mypip_outputs_id string + +resource mynat 'Microsoft.Network/natGateways@2025-05-01' = { + name: take('mynat${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + publicIpAddresses: [ + { + id: mypip_outputs_id + } + ] + } + sku: { + name: 'Standard' + } + tags: { + 'aspire-resource-name': 'mynat' + } +} + +output id string = mynat.id + +output name string = mynat.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNatGatewayExtensionsTests.AddPublicIPAddress_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNatGatewayExtensionsTests.AddPublicIPAddress_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..46682c0d2ed --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNatGatewayExtensionsTests.AddPublicIPAddress_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,20 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource mypip 'Microsoft.Network/publicIPAddresses@2025-05-01' = { + name: take('mypip-${uniqueString(resourceGroup().id)}', 80) + location: location + properties: { + publicIPAllocationMethod: 'Static' + } + sku: { + name: 'Standard' + } + tags: { + 'aspire-resource-name': 'mypip' + } +} + +output id string = mypip.id + +output name string = mypip.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddSubnet_WithNatGateway_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddSubnet_WithNatGateway_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..4f8a6dc9690 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.AddSubnet_WithNatGateway_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,36 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param mynat_outputs_id string + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource mysubnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'mysubnet' + properties: { + addressPrefix: '10.0.1.0/24' + natGateway: { + id: mynat_outputs_id + } + } + parent: myvnet +} + +output mysubnet_Id string = mysubnet.id + +output id string = myvnet.id + +output name string = myvnet.name \ No newline at end of file From 45d446d84e92e77522f952b514b13fd4bc7cda37 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Tue, 10 Feb 2026 15:35:02 -0600 Subject: [PATCH 072/256] Add Network Security Group (NSG) support for Azure Virtual Networks (#14383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Adds Network Security Group (NSG) support for Azure Virtual Networks, enabling fine-grained network traffic control for subnets. Includes both a **shorthand API** for the common case and an **explicit API** for full control. ### Shorthand API (recommended for most users) Fluent methods on subnet builders that auto-create an NSG, auto-increment priority, and auto-generate rule names: ```csharp var subnet = vnet.AddSubnet("web", "10.0.1.0/24") .AllowInbound(port: "443", from: "AzureLoadBalancer", protocol: SecurityRuleProtocol.Tcp) .DenyInbound(from: "VirtualNetwork") .DenyInbound(from: "Internet"); ``` ### Explicit API (for full control) Create standalone NSG resources with explicit `AzureSecurityRule` objects: ```csharp var nsg = builder.AddNetworkSecurityGroup("web-nsg") .WithSecurityRule(new AzureSecurityRule { Name = "allow-https", Priority = 100, Direction = SecurityRuleDirection.Inbound, Access = SecurityRuleAccess.Allow, Protocol = SecurityRuleProtocol.Tcp, DestinationPortRange = "443" }); var subnet = vnet.AddSubnet("web-subnet", "10.0.1.0/24") .WithNetworkSecurityGroup(nsg); ``` ### New public APIs **Types:** - `AzureNetworkSecurityGroupResource` — standalone `AzureProvisioningResource` with its own bicep module, `Id` and `NameOutput` outputs, and `AddAsExistingResource` support - `AzureSecurityRule` — data class for rule configuration. `SourcePortRange`, `SourceAddressPrefix`, and `DestinationAddressPrefix` default to `"*"` to reduce verbosity **Extension methods on `IDistributedApplicationBuilder`:** - `AddNetworkSecurityGroup(name)` — creates a top-level NSG resource **Extension methods on `IResourceBuilder`:** - `WithSecurityRule(rule)` — adds a security rule (rejects duplicate names) **Extension methods on `IResourceBuilder`:** - `WithNetworkSecurityGroup(nsg)` — associates an explicit NSG with a subnet - `AllowInbound(port, from, to, protocol, priority, name)` — shorthand allow inbound rule - `DenyInbound(...)` — shorthand deny inbound rule - `AllowOutbound(...)` — shorthand allow outbound rule - `DenyOutbound(...)` — shorthand deny outbound rule ### Key design decisions - **NSG is a standalone `AzureProvisioningResource`** — generates its own bicep module (not inline in the VNet module). Subnets reference the NSG via cross-module parameter (`param nsg_outputs_id string`) - **NSG is a top-level resource** (not a child of VNet), matching Azure's actual resource model - **Shorthand methods auto-create an implicit NSG** named `{subnet}-nsg` when no NSG is assigned. Calling `WithNetworkSecurityGroup` after shorthand methods throws `InvalidOperationException` to prevent silent rule loss - **Priority auto-increments** by 100 (100, 200, 300...) from the max existing priority - **Rule names auto-generate** from access/direction/port/source (e.g., `allow-inbound-443-AzureLoadBalancer`) - **Sensible defaults on `AzureSecurityRule`** — `SourcePortRange`, `SourceAddressPrefix`, `DestinationAddressPrefix` all default to `"*"`, reducing the common 10-line rule to ~5 required properties - **A single NSG can be shared across multiple subnets** - **Duplicate rule names** within an NSG are rejected with `ArgumentException` --- .../Program.cs | 14 +- .../aspire-manifest.json | 12 +- .../container-apps-nsg.module.bicep | 59 ++++ .../private-endpoints-nsg.module.bicep | 44 +++ .../vnet.module.bicep | 10 + .../AzureNetworkSecurityGroupExtensions.cs | 153 +++++++++ .../AzureNetworkSecurityGroupResource.cs | 59 ++++ .../AzureSecurityRule.cs | 61 ++++ .../AzureSubnetResource.cs | 11 + .../AzureVirtualNetworkExtensions.cs | 229 +++++++++++++ src/Aspire.Hosting.Azure.Network/README.md | 35 +- ...zureNetworkSecurityGroupExtensionsTests.cs | 303 ++++++++++++++++++ .../AzureVirtualNetworkExtensionsTests.cs | 171 ++++++++++ ...Rules_GeneratesCorrectBicep.verified.bicep | 27 ++ ...Group_GeneratesCorrectBicep.verified.bicep | 36 +++ ...bnets_GeneratesCorrectBicep.verified.bicep | 52 +++ ...Rules_GeneratesCorrectBicep.verified.bicep | 36 +++ ...eratesCorrectNsgModuleBicep.verified.bicep | 44 +++ ...Group_GeneratesCorrectBicep.verified.bicep | 36 +++ ...tesDistinctBicepIdentifiers.verified.bicep | 54 ++++ ...d_GeneratesCorrectBicep#nsg.verified.bicep | 59 ++++ ...thand_GeneratesCorrectBicep.verified.bicep | 36 +++ 22 files changed, 1537 insertions(+), 4 deletions(-) create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/container-apps-nsg.module.bicep create mode 100644 playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-nsg.module.bicep create mode 100644 src/Aspire.Hosting.Azure.Network/AzureNetworkSecurityGroupExtensions.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzureNetworkSecurityGroupResource.cs create mode 100644 src/Aspire.Hosting.Azure.Network/AzureSecurityRule.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/AzureNetworkSecurityGroupExtensionsTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_ExistingWithSecurityRules_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_SharedAcrossSubnets_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_WithSecurityRules_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_WithSecurityRules_GeneratesCorrectNsgModuleBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddSubnet_WithNetworkSecurityGroup_GeneratesCorrectBicep.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.MultipleNSGs_WithSameRuleName_GeneratesDistinctBicepIdentifiers.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.Shorthand_GeneratesCorrectBicep#nsg.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.Shorthand_GeneratesCorrectBicep.verified.bicep diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs index b44559b0405..f7dcb60b2b4 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs @@ -1,6 +1,10 @@ // 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 AZPROVISION001 // Azure.Provisioning.Network is experimental + +using Azure.Provisioning.Network; + var builder = DistributedApplication.CreateBuilder(args); // Create a virtual network with two subnets: @@ -8,13 +12,19 @@ // - One for private endpoints var vnet = builder.AddAzureVirtualNetwork("vnet"); -var containerAppsSubnet = vnet.AddSubnet("container-apps", "10.0.0.0/23"); -var privateEndpointsSubnet = vnet.AddSubnet("private-endpoints", "10.0.2.0/27"); +var containerAppsSubnet = vnet.AddSubnet("container-apps", "10.0.0.0/23") + .AllowInbound(port: "443", from: "AzureLoadBalancer", protocol: SecurityRuleProtocol.Tcp) + .DenyInbound(from: "VirtualNetwork") + .DenyInbound(from: "Internet"); // Create a NAT Gateway for deterministic outbound IP on the ACA subnet var natGateway = builder.AddNatGateway("nat"); containerAppsSubnet.WithNatGateway(natGateway); +var privateEndpointsSubnet = vnet.AddSubnet("private-endpoints", "10.0.2.0/27") + .AllowInbound(port: "443", from: "VirtualNetwork", protocol: SecurityRuleProtocol.Tcp) + .DenyInbound(from: "Internet"); + // Configure the Container App Environment to use the VNet builder.AddAzureContainerAppEnvironment("env") .WithDelegatedSubnet(containerAppsSubnet); diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json index 03c2843e0a9..7b7fdce65d9 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/aspire-manifest.json @@ -5,13 +5,23 @@ "type": "azure.bicep.v0", "path": "vnet.module.bicep", "params": { - "nat_outputs_id": "{nat.outputs.id}" + "nat_outputs_id": "{nat.outputs.id}", + "container_apps_nsg_outputs_id": "{container-apps-nsg.outputs.id}", + "private_endpoints_nsg_outputs_id": "{private-endpoints-nsg.outputs.id}" } }, + "container-apps-nsg": { + "type": "azure.bicep.v0", + "path": "container-apps-nsg.module.bicep" + }, "nat": { "type": "azure.bicep.v0", "path": "nat.module.bicep" }, + "private-endpoints-nsg": { + "type": "azure.bicep.v0", + "path": "private-endpoints-nsg.module.bicep" + }, "env-acr": { "type": "azure.bicep.v0", "path": "env-acr.module.bicep" diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/container-apps-nsg.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/container-apps-nsg.module.bicep new file mode 100644 index 00000000000..22439b892cd --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/container-apps-nsg.module.bicep @@ -0,0 +1,59 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource container_apps_nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: take('container_apps_nsg-${uniqueString(resourceGroup().id)}', 80) + location: location + tags: { + 'aspire-resource-name': 'container-apps-nsg' + } +} + +resource container_apps_nsg_allow_inbound_443_AzureLoadBalancer 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'allow-inbound-443-AzureLoadBalancer' + properties: { + access: 'Allow' + destinationAddressPrefix: '*' + destinationPortRange: '443' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourceAddressPrefix: 'AzureLoadBalancer' + sourcePortRange: '*' + } + parent: container_apps_nsg +} + +resource container_apps_nsg_deny_inbound_VirtualNetwork 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'deny-inbound-VirtualNetwork' + properties: { + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRange: '*' + direction: 'Inbound' + priority: 200 + protocol: '*' + sourceAddressPrefix: 'VirtualNetwork' + sourcePortRange: '*' + } + parent: container_apps_nsg +} + +resource container_apps_nsg_deny_inbound_Internet 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'deny-inbound-Internet' + properties: { + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRange: '*' + direction: 'Inbound' + priority: 300 + protocol: '*' + sourceAddressPrefix: 'Internet' + sourcePortRange: '*' + } + parent: container_apps_nsg +} + +output id string = container_apps_nsg.id + +output name string = container_apps_nsg.name \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-nsg.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-nsg.module.bicep new file mode 100644 index 00000000000..15a52cf84ac --- /dev/null +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/private-endpoints-nsg.module.bicep @@ -0,0 +1,44 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource private_endpoints_nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: take('private_endpoints_nsg-${uniqueString(resourceGroup().id)}', 80) + location: location + tags: { + 'aspire-resource-name': 'private-endpoints-nsg' + } +} + +resource private_endpoints_nsg_allow_inbound_443_VirtualNetwork 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'allow-inbound-443-VirtualNetwork' + properties: { + access: 'Allow' + destinationAddressPrefix: '*' + destinationPortRange: '443' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourceAddressPrefix: 'VirtualNetwork' + sourcePortRange: '*' + } + parent: private_endpoints_nsg +} + +resource private_endpoints_nsg_deny_inbound_Internet 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'deny-inbound-Internet' + properties: { + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRange: '*' + direction: 'Inbound' + priority: 200 + protocol: '*' + sourceAddressPrefix: 'Internet' + sourcePortRange: '*' + } + parent: private_endpoints_nsg +} + +output id string = private_endpoints_nsg.id + +output name string = private_endpoints_nsg.name \ No newline at end of file diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep index 03505a7e601..d5876caa656 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/vnet.module.bicep @@ -3,6 +3,10 @@ param location string = resourceGroup().location param nat_outputs_id string +param container_apps_nsg_outputs_id string + +param private_endpoints_nsg_outputs_id string + resource vnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { name: take('vnet-${uniqueString(resourceGroup().id)}', 64) properties: { @@ -33,6 +37,9 @@ resource container_apps 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = natGateway: { id: nat_outputs_id } + networkSecurityGroup: { + id: container_apps_nsg_outputs_id + } } parent: vnet } @@ -41,6 +48,9 @@ resource private_endpoints 'Microsoft.Network/virtualNetworks/subnets@2025-05-01 name: 'private-endpoints' properties: { addressPrefix: '10.0.2.0/27' + networkSecurityGroup: { + id: private_endpoints_nsg_outputs_id + } } parent: vnet dependsOn: [ diff --git a/src/Aspire.Hosting.Azure.Network/AzureNetworkSecurityGroupExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureNetworkSecurityGroupExtensions.cs new file mode 100644 index 00000000000..d794a1780da --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzureNetworkSecurityGroupExtensions.cs @@ -0,0 +1,153 @@ +// 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; +using Aspire.Hosting.Azure; +using Azure.Provisioning; +using Azure.Provisioning.Network; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Azure Network Security Group resources to the application model. +/// +public static class AzureNetworkSecurityGroupExtensions +{ + /// + /// Adds an Azure Network Security Group to the application model. + /// + /// The builder for the distributed application. + /// The name of the Network Security Group resource. + /// A reference to the . + /// + /// This example adds a Network Security Group with a security rule: + /// + /// var nsg = builder.AddNetworkSecurityGroup("web-nsg") + /// .WithSecurityRule(new AzureSecurityRule + /// { + /// Name = "allow-https", + /// Priority = 100, + /// Direction = SecurityRuleDirection.Inbound, + /// Access = SecurityRuleAccess.Allow, + /// Protocol = SecurityRuleProtocol.Tcp, + /// DestinationPortRange = "443" + /// }); + /// + /// + public static IResourceBuilder AddNetworkSecurityGroup( + this IDistributedApplicationBuilder builder, + [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + builder.AddAzureProvisioning(); + + var resource = new AzureNetworkSecurityGroupResource(name, ConfigureNetworkSecurityGroup); + + if (builder.ExecutionContext.IsRunMode) + { + return builder.CreateResourceBuilder(resource); + } + + return builder.AddResource(resource); + } + + /// + /// Adds a security rule to the Network Security Group. + /// + /// The Network Security Group resource builder. + /// The security rule configuration. + /// A reference to the for chaining. + /// + /// This example adds multiple security rules to a Network Security Group: + /// + /// var nsg = builder.AddNetworkSecurityGroup("web-nsg") + /// .WithSecurityRule(new AzureSecurityRule + /// { + /// Name = "allow-https", + /// Priority = 100, + /// Direction = SecurityRuleDirection.Inbound, + /// Access = SecurityRuleAccess.Allow, + /// Protocol = SecurityRuleProtocol.Tcp, + /// DestinationPortRange = "443" + /// }) + /// .WithSecurityRule(new AzureSecurityRule + /// { + /// Name = "deny-all-inbound", + /// Priority = 4096, + /// Direction = SecurityRuleDirection.Inbound, + /// Access = SecurityRuleAccess.Deny, + /// Protocol = SecurityRuleProtocol.Asterisk, + /// DestinationPortRange = "*" + /// }); + /// + /// + public static IResourceBuilder WithSecurityRule( + this IResourceBuilder builder, + AzureSecurityRule rule) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(rule); + ArgumentException.ThrowIfNullOrEmpty(rule.Name); + + if (builder.Resource.SecurityRules.Any(existing => string.Equals(existing.Name, rule.Name, StringComparison.OrdinalIgnoreCase))) + { + throw new ArgumentException( + $"A security rule named '{rule.Name}' already exists in Network Security Group '{builder.Resource.Name}'.", + nameof(rule)); + } + + builder.Resource.SecurityRules.Add(rule); + return builder; + } + + private static void ConfigureNetworkSecurityGroup(AzureResourceInfrastructure infra) + { + var azureResource = (AzureNetworkSecurityGroupResource)infra.AspireResource; + + var nsg = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infra, + (identifier, name) => + { + var resource = NetworkSecurityGroup.FromExisting(identifier); + resource.Name = name; + return resource; + }, + (infrastructure) => + { + return new NetworkSecurityGroup(infrastructure.AspireResource.GetBicepIdentifier()) + { + Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } + }; + }); + + foreach (var rule in azureResource.SecurityRules) + { + var ruleIdentifier = Infrastructure.NormalizeBicepIdentifier($"{nsg.BicepIdentifier}_{rule.Name}"); + var securityRule = new SecurityRule(ruleIdentifier) + { + Name = rule.Name, + Priority = rule.Priority, + Direction = rule.Direction, + Access = rule.Access, + Protocol = rule.Protocol, + SourceAddressPrefix = rule.SourceAddressPrefix, + SourcePortRange = rule.SourcePortRange, + DestinationAddressPrefix = rule.DestinationAddressPrefix, + DestinationPortRange = rule.DestinationPortRange, + Parent = nsg, + }; + infra.Add(securityRule); + } + + infra.Add(new ProvisioningOutput("id", typeof(string)) + { + Value = nsg.Id + }); + + infra.Add(new ProvisioningOutput("name", typeof(string)) + { + Value = nsg.Name + }); + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzureNetworkSecurityGroupResource.cs b/src/Aspire.Hosting.Azure.Network/AzureNetworkSecurityGroupResource.cs new file mode 100644 index 00000000000..a649501a582 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzureNetworkSecurityGroupResource.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.Provisioning.Network; +using Azure.Provisioning.Primitives; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents an Azure Network Security Group resource. +/// +/// +/// A Network Security Group contains security rules that control inbound and outbound network traffic. +/// Use +/// to configure specific properties. +/// +/// The name of the resource. +/// Callback to configure the Azure Network Security Group resource. +public class AzureNetworkSecurityGroupResource(string name, Action configureInfrastructure) + : AzureProvisioningResource(name, configureInfrastructure) +{ + /// + /// Gets the "id" output reference from the Azure Network Security Group resource. + /// + public BicepOutputReference Id => new("id", this); + + /// + /// Gets the "name" output reference for the resource. + /// + public BicepOutputReference NameOutput => new("name", this); + + internal bool IsImplicitlyCreated { get; set; } + + internal List SecurityRules { get; } = []; + + /// + public override ProvisionableResource AddAsExistingResource(AzureResourceInfrastructure infra) + { + var bicepIdentifier = this.GetBicepIdentifier(); + var resources = infra.GetProvisionableResources(); + + var existing = resources.OfType().SingleOrDefault(r => r.BicepIdentifier == bicepIdentifier); + + if (existing is not null) + { + return existing; + } + + var nsg = NetworkSecurityGroup.FromExisting(bicepIdentifier); + + if (!TryApplyExistingResourceAnnotation(this, infra, nsg)) + { + nsg.Name = NameOutput.AsProvisioningParameter(infra); + } + + infra.Add(nsg); + return nsg; + } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzureSecurityRule.cs b/src/Aspire.Hosting.Azure.Network/AzureSecurityRule.cs new file mode 100644 index 00000000000..c4e04c1dbbf --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzureSecurityRule.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.Provisioning.Network; + +namespace Aspire.Hosting.Azure; + +/// +/// Represents a security rule configuration for an Azure Network Security Group. +/// +/// +/// Security rules control inbound and outbound network traffic for subnets associated with the Network Security Group. +/// Rules are evaluated in priority order, with lower numbers having higher priority. +/// +public sealed class AzureSecurityRule +{ + /// + /// Gets or sets the name of the security rule. This name must be unique within the Network Security Group. + /// + public required string Name { get; set; } + + /// + /// Gets or sets the priority of the rule. Valid values are between 100 and 4096. Lower numbers have higher priority. + /// + public required int Priority { get; set; } + + /// + /// Gets or sets the direction of the rule. + /// + public required SecurityRuleDirection Direction { get; set; } + + /// + /// Gets or sets whether network traffic is allowed or denied. + /// + public required SecurityRuleAccess Access { get; set; } + + /// + /// Gets or sets the network protocol this rule applies to. + /// + public required SecurityRuleProtocol Protocol { get; set; } + + /// + /// Gets or sets the source address prefix. Defaults to "*" (any). + /// + public string SourceAddressPrefix { get; set; } = "*"; + + /// + /// Gets or sets the source port range. Defaults to "*" (any). + /// + public string SourcePortRange { get; set; } = "*"; + + /// + /// Gets or sets the destination address prefix. Defaults to "*" (any). + /// + public string DestinationAddressPrefix { get; set; } = "*"; + + /// + /// Gets or sets the destination port range. Use "*" for any, or a range like "80-443". + /// + public required string DestinationPortRange { get; set; } +} diff --git a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs index 3526a1f815e..a7c3150147b 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureSubnetResource.cs @@ -82,6 +82,11 @@ public AzureSubnetResource(string name, string subnetName, ParameterResource add /// internal AzureNatGatewayResource? NatGateway { get; set; } + /// + /// Gets or sets the Network Security Group associated with the subnet. + /// + internal AzureNetworkSecurityGroupResource? NetworkSecurityGroup { get; set; } + private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) => !string.IsNullOrEmpty(argument) ? argument : throw new ArgumentNullException(paramName); @@ -129,6 +134,12 @@ internal SubnetResource ToProvisioningEntity(AzureResourceInfrastructure infra, subnet.NatGatewayId = NatGateway.Id.AsProvisioningParameter(infra); } + if (NetworkSecurityGroup is not null) + { + // The NSG lives in a separate bicep module, so reference its ID via parameter + subnet.NetworkSecurityGroup.Id = NetworkSecurityGroup.Id.AsProvisioningParameter(infra); + } + // add a provisioning output for the subnet ID so it can be referenced by other resources infra.Add(new ProvisioningOutput(Id.Name, typeof(string)) { diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs index 967a9f80b47..4e15aa7ad3f 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs @@ -303,4 +303,233 @@ public static IResourceBuilder WithNatGateway( builder.Resource.NatGateway = natGateway.Resource; return builder; } + + /// + /// Associates a Network Security Group with the subnet. + /// + /// The subnet resource builder. + /// The Network Security Group to associate with the subnet. + /// A reference to the for chaining. + /// + /// This example creates a subnet with an associated Network Security Group: + /// + /// var nsg = builder.AddNetworkSecurityGroup("web-nsg"); + /// var vnet = builder.AddAzureVirtualNetwork("vnet"); + /// var subnet = vnet.AddSubnet("web-subnet", "10.0.1.0/24") + /// .WithNetworkSecurityGroup(nsg); + /// + /// + /// + /// Thrown when the subnet already has security rules added via shorthand methods + /// (, , , ). + /// Use either shorthand methods or an explicit NSG, not both. + /// + public static IResourceBuilder WithNetworkSecurityGroup( + this IResourceBuilder builder, + IResourceBuilder nsg) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(nsg); + + if (builder.Resource.NetworkSecurityGroup is { IsImplicitlyCreated: true }) + { + throw new InvalidOperationException( + $"The subnet '{builder.Resource.Name}' already has an NSG created via shorthand methods. " + + $"Calling WithNetworkSecurityGroup would replace the existing NSG and discard those rules. " + + $"Use either shorthand methods (AllowInbound, DenyInbound, etc.) or an explicit NSG, not both."); + } + + builder.Resource.NetworkSecurityGroup = nsg.Resource; + return builder; + } + + /// + /// Adds an inbound allow rule to the subnet's Network Security Group. + /// + /// The subnet resource builder. + /// The destination port range (e.g., "443", "80-443"). Defaults to "*" (any). + /// The source address prefix (e.g., "AzureLoadBalancer", "Internet", "10.0.0.0/8"). Defaults to "*" (any). + /// The destination address prefix. Defaults to "*" (any). + /// The network protocol. Defaults to (any). + /// The rule priority (100-4096). If not specified, auto-increments from 100 by 100. + /// The rule name. If not specified, auto-generated from parameters. + /// A reference to the for chaining. + /// + /// If no Network Security Group has been associated with the subnet, one is automatically created. + /// + /// + /// This example allows HTTPS traffic from the Azure Load Balancer: + /// + /// var subnet = vnet.AddSubnet("web", "10.0.1.0/24") + /// .AllowInbound(port: "443", from: "AzureLoadBalancer", protocol: SecurityRuleProtocol.Tcp) + /// .DenyInbound(from: "Internet"); + /// + /// + public static IResourceBuilder AllowInbound( + this IResourceBuilder builder, + string? port = null, + string? from = null, + string? to = null, + SecurityRuleProtocol? protocol = null, + int? priority = null, + string? name = null) + { + return AddSecurityRuleShorthand(builder, SecurityRuleAccess.Allow, SecurityRuleDirection.Inbound, port, from, to, protocol, priority, name); + } + + /// + /// Adds an inbound deny rule to the subnet's Network Security Group. + /// + /// The subnet resource builder. + /// The destination port range (e.g., "443", "80-443"). Defaults to "*" (any). + /// The source address prefix (e.g., "Internet", "VirtualNetwork", "10.0.0.0/8"). Defaults to "*" (any). + /// The destination address prefix. Defaults to "*" (any). + /// The network protocol. Defaults to (any). + /// The rule priority (100-4096). If not specified, auto-increments from 100 by 100. + /// The rule name. If not specified, auto-generated from parameters. + /// A reference to the for chaining. + /// + /// If no Network Security Group has been associated with the subnet, one is automatically created. + /// + public static IResourceBuilder DenyInbound( + this IResourceBuilder builder, + string? port = null, + string? from = null, + string? to = null, + SecurityRuleProtocol? protocol = null, + int? priority = null, + string? name = null) + { + return AddSecurityRuleShorthand(builder, SecurityRuleAccess.Deny, SecurityRuleDirection.Inbound, port, from, to, protocol, priority, name); + } + + /// + /// Adds an outbound allow rule to the subnet's Network Security Group. + /// + /// The subnet resource builder. + /// The destination port range (e.g., "443", "80-443"). Defaults to "*" (any). + /// The source address prefix. Defaults to "*" (any). + /// The destination address prefix (e.g., "Internet", "VirtualNetwork"). Defaults to "*" (any). + /// The network protocol. Defaults to (any). + /// The rule priority (100-4096). If not specified, auto-increments from 100 by 100. + /// The rule name. If not specified, auto-generated from parameters. + /// A reference to the for chaining. + /// + /// If no Network Security Group has been associated with the subnet, one is automatically created. + /// + public static IResourceBuilder AllowOutbound( + this IResourceBuilder builder, + string? port = null, + string? from = null, + string? to = null, + SecurityRuleProtocol? protocol = null, + int? priority = null, + string? name = null) + { + return AddSecurityRuleShorthand(builder, SecurityRuleAccess.Allow, SecurityRuleDirection.Outbound, port, from, to, protocol, priority, name); + } + + /// + /// Adds an outbound deny rule to the subnet's Network Security Group. + /// + /// The subnet resource builder. + /// The destination port range (e.g., "443", "80-443"). Defaults to "*" (any). + /// The source address prefix. Defaults to "*" (any). + /// The destination address prefix (e.g., "Internet", "VirtualNetwork"). Defaults to "*" (any). + /// The network protocol. Defaults to (any). + /// The rule priority (100-4096). If not specified, auto-increments from 100 by 100. + /// The rule name. If not specified, auto-generated from parameters. + /// A reference to the for chaining. + /// + /// If no Network Security Group has been associated with the subnet, one is automatically created. + /// + public static IResourceBuilder DenyOutbound( + this IResourceBuilder builder, + string? port = null, + string? from = null, + string? to = null, + SecurityRuleProtocol? protocol = null, + int? priority = null, + string? name = null) + { + return AddSecurityRuleShorthand(builder, SecurityRuleAccess.Deny, SecurityRuleDirection.Outbound, port, from, to, protocol, priority, name); + } + + private static IResourceBuilder AddSecurityRuleShorthand( + IResourceBuilder builder, + SecurityRuleAccess access, + SecurityRuleDirection direction, + string? port, + string? from, + string? to, + SecurityRuleProtocol? protocol, + int? priority, + string? name) + { + ArgumentNullException.ThrowIfNull(builder); + + var subnet = builder.Resource; + + // Auto-create NSG if one doesn't exist + if (subnet.NetworkSecurityGroup is null) + { + var nsgName = $"{subnet.Name}-nsg"; + var nsgBuilder = builder.ApplicationBuilder.AddNetworkSecurityGroup(nsgName); + nsgBuilder.Resource.IsImplicitlyCreated = true; + subnet.NetworkSecurityGroup = nsgBuilder.Resource; + } + + var nsgResource = subnet.NetworkSecurityGroup; + + // Auto-increment priority + var resolvedPriority = priority ?? (nsgResource.SecurityRules.Count == 0 + ? 100 + : nsgResource.SecurityRules.Max(r => r.Priority) + 100); + + // Auto-generate name + var accessStr = access == SecurityRuleAccess.Allow ? "allow" : "deny"; + var directionStr = direction == SecurityRuleDirection.Inbound ? "inbound" : "outbound"; + var resolvedName = name ?? GenerateRuleName(accessStr, directionStr, port, from); + + var rule = new AzureSecurityRule + { + Name = resolvedName, + Priority = resolvedPriority, + Direction = direction, + Access = access, + Protocol = protocol ?? SecurityRuleProtocol.Asterisk, + DestinationPortRange = port ?? "*", + }; + + if (from is not null) + { + rule.SourceAddressPrefix = from; + } + + if (to is not null) + { + rule.DestinationAddressPrefix = to; + } + + nsgResource.SecurityRules.Add(rule); + + return builder; + } + + private static string GenerateRuleName(string access, string direction, string? port, string? from) + { + var parts = new List { access, direction }; + + if (port is not null) + { + parts.Add(port); + } + + if (from is not null) + { + parts.Add(from); + } + + return string.Join("-", parts); + } } diff --git a/src/Aspire.Hosting.Azure.Network/README.md b/src/Aspire.Hosting.Azure.Network/README.md index 7fafd3b39f6..90975e8bbc6 100644 --- a/src/Aspire.Hosting.Azure.Network/README.md +++ b/src/Aspire.Hosting.Azure.Network/README.md @@ -1,6 +1,6 @@ # Aspire.Hosting.Azure.Network library -Provides extension methods and resource definitions for an Aspire AppHost to configure Azure Virtual Networks, Subnets, NAT Gateways, Public IP Addresses, and Private Endpoints. +Provides extension methods and resource definitions for an Aspire AppHost to configure Azure Virtual Networks, Subnets, NAT Gateways, Public IP Addresses, Network Security Groups, and Private Endpoints. ## Getting started @@ -83,6 +83,39 @@ var natGateway = builder.AddNatGateway("nat") Use `ConfigureInfrastructure` for advanced settings like idle timeout or availability zones. +### Adding Network Security Groups + +Add security rules to control traffic flow on subnets using shorthand methods: + +```csharp +var vnet = builder.AddAzureVirtualNetwork("vnet"); +var subnet = vnet.AddSubnet("web", "10.0.1.0/24") + .AllowInbound(port: "443", from: "AzureLoadBalancer", protocol: SecurityRuleProtocol.Tcp) + .DenyInbound(from: "Internet"); +``` + +An NSG is automatically created when shorthand methods are used. Priority auto-increments (100, 200, 300...) and rule names are auto-generated. + +For full control, create an explicit NSG with `AzureSecurityRule` objects: + +```csharp +var nsg = vnet.AddNetworkSecurityGroup("web-nsg") + .WithSecurityRule(new AzureSecurityRule + { + Name = "allow-https", + Priority = 100, + Direction = SecurityRuleDirection.Inbound, + Access = SecurityRuleAccess.Allow, + Protocol = SecurityRuleProtocol.Tcp, + DestinationPortRange = "443" + }); + +var subnet = vnet.AddSubnet("web-subnet", "10.0.1.0/24") + .WithNetworkSecurityGroup(nsg); +``` + +A single NSG can be shared across multiple subnets. + ### Adding Private Endpoints Create a private endpoint to securely connect to Azure resources over a private network: diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureNetworkSecurityGroupExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureNetworkSecurityGroupExtensionsTests.cs new file mode 100644 index 00000000000..964a030b6a3 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzureNetworkSecurityGroupExtensionsTests.cs @@ -0,0 +1,303 @@ +// 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. +#pragma warning disable AZPROVISION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +using Aspire.Hosting.Utils; +using Azure.Provisioning.Network; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzureNetworkSecurityGroupExtensionsTests +{ + [Fact] + public void AddNetworkSecurityGroup_CreatesResource() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var nsg = builder.AddNetworkSecurityGroup("web-nsg"); + + Assert.NotNull(nsg); + Assert.Equal("web-nsg", nsg.Resource.Name); + Assert.IsType(nsg.Resource); + } + + [Fact] + public void AddNetworkSecurityGroup_InRunMode_DoesNotAddToBuilder() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); + + var nsg = builder.AddNetworkSecurityGroup("web-nsg"); + + Assert.DoesNotContain(nsg.Resource, builder.Resources); + } + + [Fact] + public async Task AddNetworkSecurityGroup_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var nsg = builder.AddNetworkSecurityGroup("web-nsg"); + vnet.AddSubnet("web-subnet", "10.0.1.0/24") + .WithNetworkSecurityGroup(nsg); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddNetworkSecurityGroup_WithSecurityRules_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var nsg = builder.AddNetworkSecurityGroup("web-nsg") + .WithSecurityRule(new AzureSecurityRule + { + Name = "allow-https", + Priority = 100, + Direction = SecurityRuleDirection.Inbound, + Access = SecurityRuleAccess.Allow, + Protocol = SecurityRuleProtocol.Tcp, + DestinationPortRange = "443" + }) + .WithSecurityRule(new AzureSecurityRule + { + Name = "deny-all-inbound", + Priority = 4096, + Direction = SecurityRuleDirection.Inbound, + Access = SecurityRuleAccess.Deny, + Protocol = SecurityRuleProtocol.Asterisk, + DestinationPortRange = "*" + }); + + vnet.AddSubnet("web-subnet", "10.0.1.0/24") + .WithNetworkSecurityGroup(nsg); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddNetworkSecurityGroup_WithSecurityRules_GeneratesCorrectNsgModuleBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var nsg = builder.AddNetworkSecurityGroup("web-nsg") + .WithSecurityRule(new AzureSecurityRule + { + Name = "allow-https", + Priority = 100, + Direction = SecurityRuleDirection.Inbound, + Access = SecurityRuleAccess.Allow, + Protocol = SecurityRuleProtocol.Tcp, + DestinationPortRange = "443" + }) + .WithSecurityRule(new AzureSecurityRule + { + Name = "deny-all-inbound", + Priority = 4096, + Direction = SecurityRuleDirection.Inbound, + Access = SecurityRuleAccess.Deny, + Protocol = SecurityRuleProtocol.Asterisk, + DestinationPortRange = "*" + }); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(nsg.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddSubnet_WithNetworkSecurityGroup_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var nsg = builder.AddNetworkSecurityGroup("web-nsg") + .WithSecurityRule(new AzureSecurityRule + { + Name = "allow-https", + Priority = 100, + Direction = SecurityRuleDirection.Inbound, + Access = SecurityRuleAccess.Allow, + Protocol = SecurityRuleProtocol.Tcp, + SourceAddressPrefix = "*", + SourcePortRange = "*", + DestinationAddressPrefix = "*", + DestinationPortRange = "443" + }); + + vnet.AddSubnet("web-subnet", "10.0.1.0/24") + .WithNetworkSecurityGroup(nsg); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public async Task AddNetworkSecurityGroup_SharedAcrossSubnets_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var nsg = builder.AddNetworkSecurityGroup("shared-nsg") + .WithSecurityRule(new AzureSecurityRule + { + Name = "allow-https", + Priority = 100, + Direction = SecurityRuleDirection.Inbound, + Access = SecurityRuleAccess.Allow, + Protocol = SecurityRuleProtocol.Tcp, + SourceAddressPrefix = "*", + SourcePortRange = "*", + DestinationAddressPrefix = "*", + DestinationPortRange = "443" + }); + + vnet.AddSubnet("subnet1", "10.0.1.0/24") + .WithNetworkSecurityGroup(nsg); + vnet.AddSubnet("subnet2", "10.0.2.0/24") + .WithNetworkSecurityGroup(nsg); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public void WithNetworkSecurityGroup_SetsSubnetNetworkSecurityGroup() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var nsg = builder.AddNetworkSecurityGroup("web-nsg"); + var subnet = vnet.AddSubnet("web-subnet", "10.0.1.0/24") + .WithNetworkSecurityGroup(nsg); + + Assert.Same(nsg.Resource, subnet.Resource.NetworkSecurityGroup); + } + + [Fact] + public void WithSecurityRule_DuplicateName_Throws() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var nsg = builder.AddNetworkSecurityGroup("web-nsg") + .WithSecurityRule(new AzureSecurityRule + { + Name = "allow-https", + Priority = 100, + Direction = SecurityRuleDirection.Inbound, + Access = SecurityRuleAccess.Allow, + Protocol = SecurityRuleProtocol.Tcp, + SourceAddressPrefix = "*", + SourcePortRange = "*", + DestinationAddressPrefix = "*", + DestinationPortRange = "443" + }); + + var exception = Assert.Throws(() => nsg.WithSecurityRule(new AzureSecurityRule + { + Name = "ALLOW-HTTPS", + Priority = 110, + Direction = SecurityRuleDirection.Inbound, + Access = SecurityRuleAccess.Allow, + Protocol = SecurityRuleProtocol.Tcp, + SourceAddressPrefix = "*", + SourcePortRange = "*", + DestinationAddressPrefix = "*", + DestinationPortRange = "443" + })); + + Assert.Contains("allow-https", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task MultipleNSGs_WithSameRuleName_GeneratesDistinctBicepIdentifiers() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + + var nsg1 = builder.AddNetworkSecurityGroup("nsg-one") + .WithSecurityRule(new AzureSecurityRule + { + Name = "allow-https", + Priority = 100, + Direction = SecurityRuleDirection.Inbound, + Access = SecurityRuleAccess.Allow, + Protocol = SecurityRuleProtocol.Tcp, + SourceAddressPrefix = "*", + SourcePortRange = "*", + DestinationAddressPrefix = "*", + DestinationPortRange = "443" + }); + + var nsg2 = builder.AddNetworkSecurityGroup("nsg-two") + .WithSecurityRule(new AzureSecurityRule + { + Name = "allow-https", + Priority = 100, + Direction = SecurityRuleDirection.Inbound, + Access = SecurityRuleAccess.Allow, + Protocol = SecurityRuleProtocol.Tcp, + SourceAddressPrefix = "VirtualNetwork", + SourcePortRange = "*", + DestinationAddressPrefix = "*", + DestinationPortRange = "443" + }); + + vnet.AddSubnet("subnet1", "10.0.1.0/24") + .WithNetworkSecurityGroup(nsg1); + vnet.AddSubnet("subnet2", "10.0.2.0/24") + .WithNetworkSecurityGroup(nsg2); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } + + [Fact] + public void WithNetworkSecurityGroup_AfterShorthand_Throws() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var nsg = builder.AddNetworkSecurityGroup("web-nsg"); + var subnet = vnet.AddSubnet("web-subnet", "10.0.1.0/24") + .AllowInbound(port: "443", from: "AzureLoadBalancer"); + + var exception = Assert.Throws(() => subnet.WithNetworkSecurityGroup(nsg)); + + Assert.Contains("shorthand", exception.Message); + } + + [Fact] + public async Task AddNetworkSecurityGroup_ExistingWithSecurityRules_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var existingName = builder.AddParameter("existingNsgName"); + var nsg = builder.AddNetworkSecurityGroup("web-nsg") + .PublishAsExisting(existingName, resourceGroupParameter: default) + .WithSecurityRule(new AzureSecurityRule + { + Name = "allow-https", + Priority = 100, + Direction = SecurityRuleDirection.Inbound, + Access = SecurityRuleAccess.Allow, + Protocol = SecurityRuleProtocol.Tcp, + DestinationPortRange = "443" + }); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(nsg.Resource); + + await Verify(manifest.BicepText, extension: "bicep"); + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs index 2de77fe99e5..08fafbde1a3 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs @@ -2,8 +2,10 @@ // 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. +#pragma warning disable AZPROVISION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. using Aspire.Hosting.Utils; +using Azure.Provisioning.Network; namespace Aspire.Hosting.Azure.Tests; @@ -217,4 +219,173 @@ public async Task AddSubnet_WithNatGateway_GeneratesCorrectBicep() await Verify(manifest.BicepText, extension: "bicep"); } + + [Fact] + public void AllowInbound_AutoCreatesNsg() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("web", "10.0.1.0/24") + .AllowInbound(port: "443", from: "AzureLoadBalancer", protocol: SecurityRuleProtocol.Tcp); + + Assert.NotNull(subnet.Resource.NetworkSecurityGroup); + Assert.Equal("web-nsg", subnet.Resource.NetworkSecurityGroup.Name); + Assert.Single(subnet.Resource.NetworkSecurityGroup.SecurityRules); + } + + [Fact] + public void AllowInbound_UsesExistingNsg() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var nsg = builder.AddNetworkSecurityGroup("my-nsg"); + var subnet = vnet.AddSubnet("web", "10.0.1.0/24") + .WithNetworkSecurityGroup(nsg) + .AllowInbound(port: "443", from: "AzureLoadBalancer"); + + Assert.Same(nsg.Resource, subnet.Resource.NetworkSecurityGroup); + Assert.Single(nsg.Resource.SecurityRules); + } + + [Fact] + public void Shorthand_AutoIncrementsPriority() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("web", "10.0.1.0/24") + .AllowInbound(port: "443", from: "AzureLoadBalancer") + .DenyInbound(from: "VirtualNetwork") + .DenyInbound(from: "Internet"); + + var rules = subnet.Resource.NetworkSecurityGroup!.SecurityRules; + Assert.Equal(3, rules.Count); + Assert.Equal(100, rules[0].Priority); + Assert.Equal(200, rules[1].Priority); + Assert.Equal(300, rules[2].Priority); + } + + [Fact] + public void Shorthand_ExplicitPriorityOverridesAutoIncrement() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("web", "10.0.1.0/24") + .AllowInbound(port: "443", priority: 500) + .DenyInbound(from: "Internet"); + + var rules = subnet.Resource.NetworkSecurityGroup!.SecurityRules; + Assert.Equal(500, rules[0].Priority); + Assert.Equal(600, rules[1].Priority); // auto-increments from max (500) + 100 + } + + [Fact] + public void Shorthand_AutoGeneratesRuleNames() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("web", "10.0.1.0/24") + .AllowInbound(port: "443", from: "AzureLoadBalancer") + .DenyInbound(from: "Internet") + .AllowOutbound(port: "443") + .DenyOutbound(); + + var rules = subnet.Resource.NetworkSecurityGroup!.SecurityRules; + Assert.Equal("allow-inbound-443-AzureLoadBalancer", rules[0].Name); + Assert.Equal("deny-inbound-Internet", rules[1].Name); + Assert.Equal("allow-outbound-443", rules[2].Name); + Assert.Equal("deny-outbound", rules[3].Name); + } + + [Fact] + public void Shorthand_ExplicitNameOverridesAutoGeneration() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("web", "10.0.1.0/24") + .AllowInbound(port: "443", name: "my-custom-rule"); + + var rules = subnet.Resource.NetworkSecurityGroup!.SecurityRules; + Assert.Equal("my-custom-rule", rules[0].Name); + } + + [Fact] + public void Shorthand_DefaultsProtocolToAsterisk() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("web", "10.0.1.0/24") + .DenyInbound(from: "Internet"); + + var rule = Assert.Single(subnet.Resource.NetworkSecurityGroup!.SecurityRules); + Assert.Equal(SecurityRuleProtocol.Asterisk, rule.Protocol); + } + + [Fact] + public void Shorthand_DefaultsPortsAndAddressesToWildcard() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("web", "10.0.1.0/24") + .DenyInbound(); + + var rule = Assert.Single(subnet.Resource.NetworkSecurityGroup!.SecurityRules); + Assert.Equal("*", rule.SourcePortRange); + Assert.Equal("*", rule.SourceAddressPrefix); + Assert.Equal("*", rule.DestinationAddressPrefix); + Assert.Equal("*", rule.DestinationPortRange); + } + + [Fact] + public async Task Shorthand_GeneratesCorrectBicep() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("web", "10.0.1.0/24") + .AllowInbound(port: "443", from: "AzureLoadBalancer", protocol: SecurityRuleProtocol.Tcp) + .DenyInbound(from: "VirtualNetwork") + .DenyInbound(from: "Internet"); + + var vnetManifest = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource); + var nsgManifest = await AzureManifestUtils.GetManifestWithBicep(vnet.Resource.Subnets[0].NetworkSecurityGroup!); + + await Verify(vnetManifest.BicepText, extension: "bicep") + .AppendContentAsFile(nsgManifest.BicepText, "bicep", "nsg"); + } + + [Fact] + public void AllFourDirectionAccessCombos_SetCorrectly() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("web", "10.0.1.0/24") + .AllowInbound(port: "443") + .DenyInbound(from: "Internet") + .AllowOutbound(port: "443") + .DenyOutbound(to: "Internet"); + + var rules = subnet.Resource.NetworkSecurityGroup!.SecurityRules; + Assert.Equal(4, rules.Count); + + Assert.Equal(SecurityRuleAccess.Allow, rules[0].Access); + Assert.Equal(SecurityRuleDirection.Inbound, rules[0].Direction); + + Assert.Equal(SecurityRuleAccess.Deny, rules[1].Access); + Assert.Equal(SecurityRuleDirection.Inbound, rules[1].Direction); + + Assert.Equal(SecurityRuleAccess.Allow, rules[2].Access); + Assert.Equal(SecurityRuleDirection.Outbound, rules[2].Direction); + + Assert.Equal(SecurityRuleAccess.Deny, rules[3].Access); + Assert.Equal(SecurityRuleDirection.Outbound, rules[3].Direction); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_ExistingWithSecurityRules_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_ExistingWithSecurityRules_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..d905ca7598a --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_ExistingWithSecurityRules_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,27 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param existingNsgName string + +resource web_nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' existing = { + name: existingNsgName +} + +resource web_nsg_allow_https 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'allow-https' + properties: { + access: 'Allow' + destinationAddressPrefix: '*' + destinationPortRange: '443' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourceAddressPrefix: '*' + sourcePortRange: '*' + } + parent: web_nsg +} + +output id string = web_nsg.id + +output name string = existingNsgName \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..879914753d2 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,36 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param web_nsg_outputs_id string + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource web_subnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'web-subnet' + properties: { + addressPrefix: '10.0.1.0/24' + networkSecurityGroup: { + id: web_nsg_outputs_id + } + } + parent: myvnet +} + +output web_subnet_Id string = web_subnet.id + +output id string = myvnet.id + +output name string = myvnet.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_SharedAcrossSubnets_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_SharedAcrossSubnets_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..096c9e508e6 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_SharedAcrossSubnets_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,52 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param shared_nsg_outputs_id string + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource subnet1 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'subnet1' + properties: { + addressPrefix: '10.0.1.0/24' + networkSecurityGroup: { + id: shared_nsg_outputs_id + } + } + parent: myvnet +} + +resource subnet2 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'subnet2' + properties: { + addressPrefix: '10.0.2.0/24' + networkSecurityGroup: { + id: shared_nsg_outputs_id + } + } + parent: myvnet + dependsOn: [ + subnet1 + ] +} + +output subnet1_Id string = subnet1.id + +output subnet2_Id string = subnet2.id + +output id string = myvnet.id + +output name string = myvnet.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_WithSecurityRules_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_WithSecurityRules_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..879914753d2 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_WithSecurityRules_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,36 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param web_nsg_outputs_id string + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource web_subnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'web-subnet' + properties: { + addressPrefix: '10.0.1.0/24' + networkSecurityGroup: { + id: web_nsg_outputs_id + } + } + parent: myvnet +} + +output web_subnet_Id string = web_subnet.id + +output id string = myvnet.id + +output name string = myvnet.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_WithSecurityRules_GeneratesCorrectNsgModuleBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_WithSecurityRules_GeneratesCorrectNsgModuleBicep.verified.bicep new file mode 100644 index 00000000000..737509275d7 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddNetworkSecurityGroup_WithSecurityRules_GeneratesCorrectNsgModuleBicep.verified.bicep @@ -0,0 +1,44 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource web_nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: take('web_nsg-${uniqueString(resourceGroup().id)}', 80) + location: location + tags: { + 'aspire-resource-name': 'web-nsg' + } +} + +resource web_nsg_allow_https 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'allow-https' + properties: { + access: 'Allow' + destinationAddressPrefix: '*' + destinationPortRange: '443' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourceAddressPrefix: '*' + sourcePortRange: '*' + } + parent: web_nsg +} + +resource web_nsg_deny_all_inbound 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'deny-all-inbound' + properties: { + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRange: '*' + direction: 'Inbound' + priority: 4096 + protocol: '*' + sourceAddressPrefix: '*' + sourcePortRange: '*' + } + parent: web_nsg +} + +output id string = web_nsg.id + +output name string = web_nsg.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddSubnet_WithNetworkSecurityGroup_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddSubnet_WithNetworkSecurityGroup_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..879914753d2 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.AddSubnet_WithNetworkSecurityGroup_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,36 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param web_nsg_outputs_id string + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource web_subnet 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'web-subnet' + properties: { + addressPrefix: '10.0.1.0/24' + networkSecurityGroup: { + id: web_nsg_outputs_id + } + } + parent: myvnet +} + +output web_subnet_Id string = web_subnet.id + +output id string = myvnet.id + +output name string = myvnet.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.MultipleNSGs_WithSameRuleName_GeneratesDistinctBicepIdentifiers.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.MultipleNSGs_WithSameRuleName_GeneratesDistinctBicepIdentifiers.verified.bicep new file mode 100644 index 00000000000..f18a0452a12 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureNetworkSecurityGroupExtensionsTests.MultipleNSGs_WithSameRuleName_GeneratesDistinctBicepIdentifiers.verified.bicep @@ -0,0 +1,54 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param nsg_one_outputs_id string + +param nsg_two_outputs_id string + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource subnet1 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'subnet1' + properties: { + addressPrefix: '10.0.1.0/24' + networkSecurityGroup: { + id: nsg_one_outputs_id + } + } + parent: myvnet +} + +resource subnet2 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'subnet2' + properties: { + addressPrefix: '10.0.2.0/24' + networkSecurityGroup: { + id: nsg_two_outputs_id + } + } + parent: myvnet + dependsOn: [ + subnet1 + ] +} + +output subnet1_Id string = subnet1.id + +output subnet2_Id string = subnet2.id + +output id string = myvnet.id + +output name string = myvnet.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.Shorthand_GeneratesCorrectBicep#nsg.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.Shorthand_GeneratesCorrectBicep#nsg.verified.bicep new file mode 100644 index 00000000000..3528fad37ee --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.Shorthand_GeneratesCorrectBicep#nsg.verified.bicep @@ -0,0 +1,59 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource web_nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: take('web_nsg-${uniqueString(resourceGroup().id)}', 80) + location: location + tags: { + 'aspire-resource-name': 'web-nsg' + } +} + +resource web_nsg_allow_inbound_443_AzureLoadBalancer 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'allow-inbound-443-AzureLoadBalancer' + properties: { + access: 'Allow' + destinationAddressPrefix: '*' + destinationPortRange: '443' + direction: 'Inbound' + priority: 100 + protocol: 'Tcp' + sourceAddressPrefix: 'AzureLoadBalancer' + sourcePortRange: '*' + } + parent: web_nsg +} + +resource web_nsg_deny_inbound_VirtualNetwork 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'deny-inbound-VirtualNetwork' + properties: { + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRange: '*' + direction: 'Inbound' + priority: 200 + protocol: '*' + sourceAddressPrefix: 'VirtualNetwork' + sourcePortRange: '*' + } + parent: web_nsg +} + +resource web_nsg_deny_inbound_Internet 'Microsoft.Network/networkSecurityGroups/securityRules@2025-05-01' = { + name: 'deny-inbound-Internet' + properties: { + access: 'Deny' + destinationAddressPrefix: '*' + destinationPortRange: '*' + direction: 'Inbound' + priority: 300 + protocol: '*' + sourceAddressPrefix: 'Internet' + sourcePortRange: '*' + } + parent: web_nsg +} + +output id string = web_nsg.id + +output name string = web_nsg.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.Shorthand_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.Shorthand_GeneratesCorrectBicep.verified.bicep new file mode 100644 index 00000000000..62b1d42e3ae --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureVirtualNetworkExtensionsTests.Shorthand_GeneratesCorrectBicep.verified.bicep @@ -0,0 +1,36 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param web_nsg_outputs_id string + +resource myvnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: take('myvnet-${uniqueString(resourceGroup().id)}', 64) + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + location: location + tags: { + 'aspire-resource-name': 'myvnet' + } +} + +resource web 'Microsoft.Network/virtualNetworks/subnets@2025-05-01' = { + name: 'web' + properties: { + addressPrefix: '10.0.1.0/24' + networkSecurityGroup: { + id: web_nsg_outputs_id + } + } + parent: myvnet +} + +output web_Id string = web.id + +output id string = myvnet.id + +output name string = myvnet.name \ No newline at end of file From ad12501f02c9d278fd4dd9ebde27e103f714dee3 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Tue, 10 Feb 2026 15:00:40 -0800 Subject: [PATCH 073/256] Bump the microsoft_docker_images group across 3 directories with 1 update (#14431) Bumps the microsoft_docker_images group with 1 update in the /playground/withdockerfile/WithDockerfile.AppHost/qots directory: cbl-mariner/base/core. Bumps the microsoft_docker_images group with 1 update in the /playground/publishers/Publishers.AppHost/qots directory: cbl-mariner/base/core. Bumps the microsoft_docker_images group with 1 update in the /playground/withdockerfile/WithDockerfile.AppHost directory: cbl-mariner/base/core. Updates `cbl-mariner/base/core` from 2.0.20251206 to 2.0.20260102 Updates `cbl-mariner/base/core` from 2.0.20251206 to 2.0.20260102 Updates `cbl-mariner/base/core` from 2.0.20251206 to 2.0.20260102 Co-authored-by: Dependabot --- playground/publishers/Publishers.AppHost/qots/Dockerfile | 2 +- .../WithDockerfile.AppHost/dynamic-async.Dockerfile | 2 +- .../WithDockerfile.AppHost/dynamic-sync.Dockerfile | 2 +- .../withdockerfile/WithDockerfile.AppHost/qots/Dockerfile | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/playground/publishers/Publishers.AppHost/qots/Dockerfile b/playground/publishers/Publishers.AppHost/qots/Dockerfile index 61b944b947d..d7b33ca5d05 100644 --- a/playground/publishers/Publishers.AppHost/qots/Dockerfile +++ b/playground/publishers/Publishers.AppHost/qots/Dockerfile @@ -6,7 +6,7 @@ COPY . . RUN go build qots.go # Stage 2: Run the Go program -FROM mcr.microsoft.com/cbl-mariner/base/core:2.0.20251206 +FROM mcr.microsoft.com/cbl-mariner/base/core:2.0.20260102 WORKDIR /app COPY --from=builder /app/qots . CMD ["./qots"] diff --git a/playground/withdockerfile/WithDockerfile.AppHost/dynamic-async.Dockerfile b/playground/withdockerfile/WithDockerfile.AppHost/dynamic-async.Dockerfile index 67fc3f81a3a..e3aa89eea80 100644 --- a/playground/withdockerfile/WithDockerfile.AppHost/dynamic-async.Dockerfile +++ b/playground/withdockerfile/WithDockerfile.AppHost/dynamic-async.Dockerfile @@ -3,6 +3,6 @@ WORKDIR /app COPY . . RUN go build -o qots . -FROM mcr.microsoft.com/cbl-mariner/base/core:2.0 +FROM mcr.microsoft.com/cbl-mariner/base/core:2.0.20260102 COPY --from=builder /app/qots /qots ENTRYPOINT ["/qots"] \ No newline at end of file diff --git a/playground/withdockerfile/WithDockerfile.AppHost/dynamic-sync.Dockerfile b/playground/withdockerfile/WithDockerfile.AppHost/dynamic-sync.Dockerfile index 9e77b046641..4aabbd82d5a 100644 --- a/playground/withdockerfile/WithDockerfile.AppHost/dynamic-sync.Dockerfile +++ b/playground/withdockerfile/WithDockerfile.AppHost/dynamic-sync.Dockerfile @@ -4,7 +4,7 @@ COPY . . RUN echo "Built at 20260119162134" > /build-info.txt RUN go build -o qots . -FROM mcr.microsoft.com/cbl-mariner/base/core:2.0 +FROM mcr.microsoft.com/cbl-mariner/base/core:2.0.20260102 COPY --from=builder /app/qots /qots COPY --from=builder /build-info.txt /build-info.txt ENTRYPOINT ["/qots"] \ No newline at end of file diff --git a/playground/withdockerfile/WithDockerfile.AppHost/qots/Dockerfile b/playground/withdockerfile/WithDockerfile.AppHost/qots/Dockerfile index 0a33fbc89e0..4667f508c32 100644 --- a/playground/withdockerfile/WithDockerfile.AppHost/qots/Dockerfile +++ b/playground/withdockerfile/WithDockerfile.AppHost/qots/Dockerfile @@ -6,7 +6,7 @@ COPY . . RUN go build qots.go # Stage 2: Run the Go program -FROM mcr.microsoft.com/cbl-mariner/base/core:2.0.20251206 +FROM mcr.microsoft.com/cbl-mariner/base/core:2.0.20260102 WORKDIR /app RUN --mount=type=secret,id=SECRET_ASENV cp /run/secrets/SECRET_ASENV /app/SECRET_ASENV COPY --from=builder /app/qots . From bfa424a3519827aace1dc044a80bd8a137f6cf76 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 10 Feb 2026 15:11:01 -0800 Subject: [PATCH 074/256] Make aspire.exe a self-extracting binary for polyglot scenarios (#14398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Self-extracting bundle: trailer format, lazy extraction, setup command, build integration - Add BundleTrailer (src/Shared/) with read/write/extract helpers for the 32-byte trailer appended to native AOT CLI binaries - Change IAppHostServerProjectFactory.Create() → CreateAsync() and update all 8 call sites (GuestAppHostProject, SdkGenerateCommand, SdkDumpCommand, ScaffoldingService, AppHostServerSession) - Add EnsureBundleAsync in AppHostServerProjectFactory that lazily extracts the embedded tar.gz payload on first polyglot command (run/publish/add) - Add 'aspire setup' command for explicit extraction with --install-path and --force options - Add --embed-in-cli option to CreateLayout tool that appends tar.gz payload + trailer to the native CLI binary - Update Bundle.proj to pass --embed-in-cli pointing to the layout CLI binary * Add version check, update --self extraction, tests, and README docs - Version marker file (.aspire-bundle-version) written after extraction; EnsureBundleAsync and SetupCommand skip extraction when version matches - update --self proactively extracts embedded payload after replacing binary - 10 unit tests for BundleTrailer: roundtrip, payload slicing, version marker, and tar.gz extraction with strip-components - README documents self-extracting binary install path * Centralize bundle extraction into BundleService, simplify update, update CI - Extract IBundleService/BundleService with thread-safe extraction - Create ArchiveHelper to deduplicate extraction utilities - Simplify UpdateCommand: remove ExecuteBundleSelfUpdateAsync path - CI: upload self-extracting binary instead of directory tree - PR scripts: simplified to place binary in bin/ (lazy extraction) - Drop --archive from Bundle.proj, remove archive artifact upload - Update bundle spec with self-extracting binary architecture * Fix DI factories to use BundleTrailer for bundle detection The DI factories for INuGetPackageCache and ICertificateToolRunner previously checked DiscoverLayout() to decide between bundle and SDK implementations. This failed on fresh installs where the bundle hadn't been extracted yet, causing the factory to permanently pick the SDK implementation (which requires dotnet). Now the factories check BundleTrailer.TryRead(ProcessPath) to detect if the running binary is a bundle. This works before extraction. The bundle implementations call EnsureExtractedAsync lazily on first use, triggering extraction only when needed. - Program.cs: Use BundleTrailer.TryRead instead of DiscoverLayout - BundleNuGetPackageCache: Add IBundleService, call EnsureExtractedAsync - BundleCertificateToolRunner: Take ILayoutDiscovery+IBundleService instead of LayoutConfiguration, resolve layout lazily * Make BundleTrailer.TryRead not throw on IO errors * Use cross-process Mutex for bundle extraction locking Replace in-process SemaphoreSlim with a named Mutex to prevent concurrent aspire processes from racing during bundle extraction. * Update bundle spec: format versioning, Mutex, checksum verification * Replace Mutex with file lock for cross-process extraction Named Mutex with Global\ prefix doesn't work on Linux, causing 'Object synchronization method was called from an unsynchronized block of code' errors. Use a file lock (.aspire-bundle-lock) in the extraction directory instead — works on all platforms. * Update spec: file lock instead of Mutex * Extract FileLock into a separate class Document why we use a file-based lock instead of Mutex: cross-platform compatibility and async/await safety. * Add retry loop to FileLock for Windows compatibility FileShare.None throws IOException immediately on Windows instead of blocking. Retry with 200ms delay up to a 2 minute timeout. * Add debug logging throughout BundleService - EnsureExtractedAsync: log early exits and extraction target - ExtractAsync: log trailer details, version mismatch - ExtractCoreAsync: log clean, extraction timing, marker write, verification - FileLock: throw TimeoutException with context on timeout * Rewrite FileLock based on NuGet ConcurrencyUtilities pattern - Move from Bundles/ to Utils/ for reusability - Change from IDisposable to ExecuteWithLock/ExecuteWithLockAsync API - Add UnauthorizedAccessException handling (transient during DeleteOnClose) - Use CancellationToken instead of fixed timeout - Use FileOptions.DeleteOnClose to auto-clean lock files - Use 10ms retry delay (matches NuGet) instead of 200ms - Add sync overload for non-async callers * Simplify FileLock to IDisposable with async-only AcquireAsync * Replace appended trailer with embedded resource for bundle payload - Use EmbeddedResource (conditional on BundlePayloadPath) instead of appended trailer - Add BundleService.IsBundle and OpenPayload() for detection and access - Use .NET TarReader for extraction on all platforms (remove system tar dependency) - Add path traversal guard, null symlink check, fix resource leak in CreateLayout - Reorder Bundle.proj: managed+DCP -> tar.gz -> AOT compile with embedded resource - Simplify build-bundle.yml (no concat step) - Remove BundleTrailer, SubStream, EmbedPayloadInCli * Fix ConfigureAwait in CreateLayout, add BundleRuntimePath option to Bundle.proj - Add ConfigureAwait(false) to async disposals in tar archive creation - Add BundleRuntimePath property to Bundle.proj for local builds (skips download) * Remove CLI from bundle layout - the native AOT binary IS the CLI * Validate symlink targets during tar extraction to prevent path traversal * Delete unused BundleTrailer.cs * Forward VersionSuffix to CLI publish in Bundle.proj * Address review comments: timeout, permissions, dedup, hardening, dead code removal - FileLock: add timeout (5min default) to prevent indefinite waits - BundleService: set Unix file permissions from tar entry metadata - BundleService: move IsBundle to IBundleService instance property - BundleService: use VersionHelper to deduplicate version-reading code - IBundleService: add EnsureExtractedAndGetLayoutAsync combined method - ArchiveHelper: harden with path-traversal and symlink validation - Bundle.proj: add comments explaining VersionSuffix and BundleRuntimePath - Rename BundleTrailerTests.cs to BundleServiceTests.cs - Update bundle.md to describe embedded resource approach - Remove dead IBundleDownloader/BundleDownloader/FileAccessRetrier code --- .github/workflows/build-bundle.yml | 56 +- .github/workflows/polyglot-validation.yml | 163 +--- .../polyglot-validation/Dockerfile.go | 25 +- .../polyglot-validation/Dockerfile.java | 25 +- .../polyglot-validation/Dockerfile.python | 25 +- .../polyglot-validation/Dockerfile.rust | 25 +- .../polyglot-validation/Dockerfile.typescript | 25 +- .../polyglot-validation/setup-local-cli.sh | 202 +---- docs/specs/bundle.md | 231 +++++- eng/Bundle.proj | 23 +- eng/scripts/README.md | 42 +- eng/scripts/get-aspire-cli-bundle-pr.ps1 | 58 +- eng/scripts/get-aspire-cli-bundle-pr.sh | 109 +-- src/Aspire.Cli/Aspire.Cli.csproj | 6 + src/Aspire.Cli/Bundles/BundleService.cs | 317 ++++++++ src/Aspire.Cli/Bundles/IBundleService.cs | 60 ++ .../BundleCertificateToolRunner.cs | 11 +- src/Aspire.Cli/Commands/RootCommand.cs | 2 + src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs | 2 +- .../Commands/Sdk/SdkGenerateCommand.cs | 2 +- src/Aspire.Cli/Commands/SetupCommand.cs | 100 +++ src/Aspire.Cli/Commands/UpdateCommand.cs | 120 +-- .../NuGet/BundleNuGetPackageCache.cs | 10 +- src/Aspire.Cli/Program.cs | 24 +- .../Projects/AppHostServerProject.cs | 19 +- .../Projects/AppHostServerSession.cs | 2 +- .../Projects/GuestAppHostProject.cs | 8 +- .../Scaffolding/ScaffoldingService.cs | 2 +- src/Aspire.Cli/Utils/ArchiveHelper.cs | 142 ++++ src/Aspire.Cli/Utils/BundleDownloader.cs | 705 ------------------ src/Aspire.Cli/Utils/FileAccessRetrier.cs | 200 ----- src/Aspire.Cli/Utils/FileLock.cs | 119 +++ .../Dashboard/DashboardEventHandlers.cs | 27 +- tests/Aspire.Cli.Tests/BundleServiceTests.cs | 77 ++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 31 +- tools/CreateLayout/Program.cs | 85 +-- 36 files changed, 1293 insertions(+), 1787 deletions(-) create mode 100644 src/Aspire.Cli/Bundles/BundleService.cs create mode 100644 src/Aspire.Cli/Bundles/IBundleService.cs create mode 100644 src/Aspire.Cli/Commands/SetupCommand.cs create mode 100644 src/Aspire.Cli/Utils/ArchiveHelper.cs delete mode 100644 src/Aspire.Cli/Utils/BundleDownloader.cs delete mode 100644 src/Aspire.Cli/Utils/FileAccessRetrier.cs create mode 100644 src/Aspire.Cli/Utils/FileLock.cs create mode 100644 tests/Aspire.Cli.Tests/BundleServiceTests.cs diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index 44250cf4deb..eb1e98a5c4f 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -1,9 +1,9 @@ name: Build Bundle (Reusable) # This workflow creates the Aspire CLI bundle by: -# 1. Downloading the native CLI from the CLI archives workflow -# 2. Building/publishing managed components (Dashboard, NuGetHelper, AppHostServer) -# 3. Assembling everything into a bundle using CreateLayout +# 1. Building/publishing managed components (Dashboard, NuGetHelper, AppHostServer) +# 2. Creating the tar.gz archive from the layout +# 3. AOT-compiling the CLI with the archive as an embedded resource on: workflow_call: @@ -21,25 +21,18 @@ jobs: targets: - rid: linux-x64 runner: 8-core-ubuntu-latest # Larger runner for bundle disk space - archive_ext: tar.gz + cli_exe: aspire - rid: win-x64 runner: windows-latest - archive_ext: zip + cli_exe: aspire.exe - rid: osx-arm64 runner: macos-latest - archive_ext: tar.gz + cli_exe: aspire steps: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - # Download CLI archive from previous workflow - this avoids rebuilding native CLI - - name: Download CLI archive - uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 - with: - name: cli-native-archives-${{ matrix.targets.rid }} - path: artifacts/packages - # Download RID-specific NuGet packages (for DCP) - name: Download RID-specific NuGets uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 @@ -47,30 +40,7 @@ jobs: name: built-nugets-for-${{ matrix.targets.rid }} path: artifacts/packages/Release/Shipping - # Extract CLI archive to expected location so CreateLayout can find it - - name: Extract CLI archive - shell: pwsh - run: | - $rid = "${{ matrix.targets.rid }}" - $ext = if ($rid -eq "win-x64") { "zip" } else { "tar.gz" } - $destDir = "artifacts/bin/Aspire.Cli/Release/net10.0/$rid/native" - New-Item -ItemType Directory -Path $destDir -Force | Out-Null - - $archive = Get-ChildItem -Path artifacts/packages -Recurse -Filter "aspire-cli-$rid-*.$ext" | Select-Object -First 1 - if (-not $archive) { - Write-Error "CLI archive not found for $rid" - exit 1 - } - Write-Host "Extracting $($archive.FullName) to $destDir" - - if ($ext -eq "zip") { - Expand-Archive -Path $archive.FullName -DestinationPath $destDir -Force - } else { - tar -xzf $archive.FullName -C $destDir - } - Get-ChildItem $destDir - - # Build bundle directly via MSBuild - skips native CLI build, uses pre-extracted CLI + # Build bundle: managed projects → tar.gz → AOT compile CLI with embedded payload - name: Build bundle shell: pwsh run: | @@ -79,7 +49,6 @@ jobs: /restore ` /p:Configuration=Release ` /p:TargetRid=${{ matrix.targets.rid }} ` - /p:SkipNativeBuild=true ` /p:ContinuousIntegrationBuild=true ` /bl:${{ github.workspace }}/artifacts/log/Release/Bundle.binlog ` ${{ inputs.versionOverrideArg }} @@ -89,19 +58,10 @@ jobs: uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: aspire-bundle-${{ matrix.targets.rid }} - path: artifacts/bundle/${{ matrix.targets.rid }} + path: artifacts/bin/Aspire.Cli/Release/net10.0/${{ matrix.targets.rid }}/native/${{ matrix.targets.cli_exe }} retention-days: 15 if-no-files-found: error - - name: Upload bundle archive - if: success() - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: aspire-bundle-archive-${{ matrix.targets.rid }} - path: artifacts/bundle/*.${{ matrix.targets.archive_ext }} - retention-days: 15 - if-no-files-found: warn - - name: Upload logs if: always() uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 diff --git a/.github/workflows/polyglot-validation.yml b/.github/workflows/polyglot-validation.yml index 977b5e7e957..2f103e82a6d 100644 --- a/.github/workflows/polyglot-validation.yml +++ b/.github/workflows/polyglot-validation.yml @@ -37,54 +37,11 @@ jobs: name: built-nugets-for-linux-x64 path: ${{ github.workspace }}/artifacts/nugets-rid - - name: Debug - List all downloaded artifacts + - name: Verify bundle artifact run: | - echo "=== DEBUG: Full artifact tree ===" - echo "Working directory: $(pwd)" - echo "GITHUB_WORKSPACE: ${{ github.workspace }}" - echo "" - echo "=== Setting execute permissions on bundle executables ===" - chmod +x ${{ github.workspace }}/artifacts/bundle/aspire || echo "aspire not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/runtime/dotnet || echo "dotnet not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/tools/aspire-nuget/aspire-nuget || echo "aspire-nuget not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/tools/dev-certs/aspire-dev-certs || echo "aspire-dev-certs not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/dashboard/aspire-dashboard || echo "Dashboard not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/aspire-server/aspire-server || echo "RemoteHost not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/dcp/dcp || echo "dcp not found" - echo "" - echo "=== artifacts/ directory ===" - ls -la ${{ github.workspace }}/artifacts/ || echo "artifacts/ does not exist" - echo "" - echo "=== artifacts/bundle/ directory ===" - ls -la ${{ github.workspace }}/artifacts/bundle/ || echo "artifacts/bundle/ does not exist" - echo "" - echo "=== artifacts/bundle/ recursive (first 3 levels) ===" - find ${{ github.workspace }}/artifacts/bundle -maxdepth 3 -type f 2>/dev/null | head -50 || echo "No files found" - find ${{ github.workspace }}/artifacts/bundle -maxdepth 3 -type d 2>/dev/null || echo "No directories found" - echo "" - echo "=== Check for aspire CLI ===" - ls -la ${{ github.workspace }}/artifacts/bundle/aspire 2>/dev/null || echo "aspire CLI not found at expected path" - find ${{ github.workspace }}/artifacts -name "aspire" -type f 2>/dev/null || echo "aspire CLI not found anywhere" - echo "" - echo "=== Check for runtime/dotnet ===" - ls -la ${{ github.workspace }}/artifacts/bundle/runtime/dotnet 2>/dev/null || echo "runtime/dotnet not found at expected path" - find ${{ github.workspace }}/artifacts -name "dotnet" -type f 2>/dev/null || echo "dotnet not found anywhere" - echo "" - echo "=== Check for key directories ===" - for dir in runtime dashboard dcp aspire-server tools; do - if [ -d "${{ github.workspace }}/artifacts/bundle/$dir" ]; then - echo "✓ $dir/ exists" - ls -la "${{ github.workspace }}/artifacts/bundle/$dir" | head -5 - else - echo "✗ $dir/ MISSING" - fi - done - echo "" - echo "=== artifacts/nugets/ sample ===" - find ${{ github.workspace }}/artifacts/nugets -name "*.nupkg" 2>/dev/null | head -10 || echo "No nupkg files found" - echo "" - echo "=== artifacts/nugets-rid/ sample ===" - find ${{ github.workspace }}/artifacts/nugets-rid -name "*.nupkg" 2>/dev/null | head -10 || echo "No nupkg files found" + echo "=== Verifying self-extracting binary ===" + ls -la ${{ github.workspace }}/artifacts/bundle/aspire || { echo "ERROR: aspire binary not found"; exit 1; } + chmod +x ${{ github.workspace }}/artifacts/bundle/aspire - name: Build Python validation image run: | @@ -125,31 +82,11 @@ jobs: name: built-nugets-for-linux-x64 path: ${{ github.workspace }}/artifacts/nugets-rid - - name: Debug - List all downloaded artifacts + - name: Verify bundle artifact run: | - echo "=== DEBUG: Go validation - artifact tree ===" - echo "=== Setting execute permissions on bundle executables ===" - chmod +x ${{ github.workspace }}/artifacts/bundle/aspire || echo "aspire not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/runtime/dotnet || echo "dotnet not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/tools/aspire-nuget/aspire-nuget || echo "aspire-nuget not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/tools/dev-certs/aspire-dev-certs || echo "aspire-dev-certs not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/dashboard/aspire-dashboard || echo "Dashboard not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/aspire-server/aspire-server || echo "RemoteHost not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/dcp/dcp || echo "dcp not found" - echo "" - echo "=== artifacts/bundle/ ===" - ls -la ${{ github.workspace }}/artifacts/bundle/ || echo "bundle/ does not exist" - echo "" - echo "=== Bundle structure check ===" - for dir in runtime dashboard dcp aspire-server; do - if [ -d "${{ github.workspace }}/artifacts/bundle/$dir" ]; then - echo "✓ $dir/" - else - echo "✗ $dir/ MISSING" - fi - done - ls -la ${{ github.workspace }}/artifacts/bundle/aspire 2>/dev/null || echo "aspire CLI MISSING" - ls -la ${{ github.workspace }}/artifacts/bundle/runtime/dotnet 2>/dev/null || echo "runtime/dotnet MISSING" + echo "=== Verifying self-extracting binary ===" + ls -la ${{ github.workspace }}/artifacts/bundle/aspire || { echo "ERROR: aspire binary not found"; exit 1; } + chmod +x ${{ github.workspace }}/artifacts/bundle/aspire - name: Build Go validation image run: | @@ -190,31 +127,11 @@ jobs: name: built-nugets-for-linux-x64 path: ${{ github.workspace }}/artifacts/nugets-rid - - name: Debug - List all downloaded artifacts + - name: Verify bundle artifact run: | - echo "=== DEBUG: Java validation - artifact tree ===" - echo "=== Setting execute permissions on bundle executables ===" - chmod +x ${{ github.workspace }}/artifacts/bundle/aspire || echo "aspire not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/runtime/dotnet || echo "dotnet not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/tools/aspire-nuget/aspire-nuget || echo "aspire-nuget not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/tools/dev-certs/aspire-dev-certs || echo "aspire-dev-certs not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/dashboard/aspire-dashboard || echo "Dashboard not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/aspire-server/aspire-server || echo "RemoteHost not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/dcp/dcp || echo "dcp not found" - echo "" - echo "=== artifacts/bundle/ ===" - ls -la ${{ github.workspace }}/artifacts/bundle/ || echo "bundle/ does not exist" - echo "" - echo "=== Bundle structure check ===" - for dir in runtime dashboard dcp aspire-server; do - if [ -d "${{ github.workspace }}/artifacts/bundle/$dir" ]; then - echo "✓ $dir/" - else - echo "✗ $dir/ MISSING" - fi - done - ls -la ${{ github.workspace }}/artifacts/bundle/aspire 2>/dev/null || echo "aspire CLI MISSING" - ls -la ${{ github.workspace }}/artifacts/bundle/runtime/dotnet 2>/dev/null || echo "runtime/dotnet MISSING" + echo "=== Verifying self-extracting binary ===" + ls -la ${{ github.workspace }}/artifacts/bundle/aspire || { echo "ERROR: aspire binary not found"; exit 1; } + chmod +x ${{ github.workspace }}/artifacts/bundle/aspire - name: Build Java validation image run: | @@ -257,31 +174,11 @@ jobs: name: built-nugets-for-linux-x64 path: ${{ github.workspace }}/artifacts/nugets-rid - - name: Debug - List all downloaded artifacts + - name: Verify bundle artifact run: | - echo "=== DEBUG: Rust validation - artifact tree ===" - echo "=== Setting execute permissions on bundle executables ===" - chmod +x ${{ github.workspace }}/artifacts/bundle/aspire || echo "aspire not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/runtime/dotnet || echo "dotnet not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/tools/aspire-nuget/aspire-nuget || echo "aspire-nuget not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/tools/dev-certs/aspire-dev-certs || echo "aspire-dev-certs not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/dashboard/aspire-dashboard || echo "Dashboard not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/aspire-server/aspire-server || echo "RemoteHost not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/dcp/dcp || echo "dcp not found" - echo "" - echo "=== artifacts/bundle/ ===" - ls -la ${{ github.workspace }}/artifacts/bundle/ || echo "bundle/ does not exist" - echo "" - echo "=== Bundle structure check ===" - for dir in runtime dashboard dcp aspire-server; do - if [ -d "${{ github.workspace }}/artifacts/bundle/$dir" ]; then - echo "✓ $dir/" - else - echo "✗ $dir/ MISSING" - fi - done - ls -la ${{ github.workspace }}/artifacts/bundle/aspire 2>/dev/null || echo "aspire CLI MISSING" - ls -la ${{ github.workspace }}/artifacts/bundle/runtime/dotnet 2>/dev/null || echo "runtime/dotnet MISSING" + echo "=== Verifying self-extracting binary ===" + ls -la ${{ github.workspace }}/artifacts/bundle/aspire || { echo "ERROR: aspire binary not found"; exit 1; } + chmod +x ${{ github.workspace }}/artifacts/bundle/aspire - name: Build Rust validation image run: | @@ -322,31 +219,11 @@ jobs: name: built-nugets-for-linux-x64 path: ${{ github.workspace }}/artifacts/nugets-rid - - name: Debug - List all downloaded artifacts + - name: Verify bundle artifact run: | - echo "=== DEBUG: TypeScript validation - artifact tree ===" - echo "=== Setting execute permissions on bundle executables ===" - chmod +x ${{ github.workspace }}/artifacts/bundle/aspire || echo "aspire not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/runtime/dotnet || echo "dotnet not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/tools/aspire-nuget/aspire-nuget || echo "aspire-nuget not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/tools/dev-certs/aspire-dev-certs || echo "aspire-dev-certs not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/dashboard/aspire-dashboard || echo "Dashboard not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/aspire-server/aspire-server || echo "RemoteHost not found" - chmod +x ${{ github.workspace }}/artifacts/bundle/dcp/dcp || echo "dcp not found" - echo "" - echo "=== artifacts/bundle/ ===" - ls -la ${{ github.workspace }}/artifacts/bundle/ || echo "bundle/ does not exist" - echo "" - echo "=== Bundle structure check ===" - for dir in runtime dashboard dcp aspire-server; do - if [ -d "${{ github.workspace }}/artifacts/bundle/$dir" ]; then - echo "✓ $dir/" - else - echo "✗ $dir/ MISSING" - fi - done - ls -la ${{ github.workspace }}/artifacts/bundle/aspire 2>/dev/null || echo "aspire CLI MISSING" - ls -la ${{ github.workspace }}/artifacts/bundle/runtime/dotnet 2>/dev/null || echo "runtime/dotnet MISSING" + echo "=== Verifying self-extracting binary ===" + ls -la ${{ github.workspace }}/artifacts/bundle/aspire || { echo "ERROR: aspire binary not found"; exit 1; } + chmod +x ${{ github.workspace }}/artifacts/bundle/aspire - name: Build TypeScript validation image run: | diff --git a/.github/workflows/polyglot-validation/Dockerfile.go b/.github/workflows/polyglot-validation/Dockerfile.go index 95a00d3021a..f860ce7e37d 100644 --- a/.github/workflows/polyglot-validation/Dockerfile.go +++ b/.github/workflows/polyglot-validation/Dockerfile.go @@ -8,7 +8,7 @@ # -v /var/run/docker.sock:/var/run/docker.sock \ # polyglot-go # -# Note: Expects bundle and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ +# Note: Expects self-extracting binary and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ # FROM mcr.microsoft.com/devcontainers/go:1-trixie @@ -22,8 +22,6 @@ RUN apt-get update && apt-get install -y \ jq \ && rm -rf /var/lib/apt/lists/* -# Note: .NET SDK is NOT required - the bundle includes the .NET runtime - # Pre-configure Aspire CLI path ENV PATH="/root/.aspire/bin:${PATH}" @@ -33,28 +31,11 @@ COPY setup-local-cli.sh /scripts/setup-local-cli.sh COPY test-go.sh /scripts/test-go.sh RUN chmod +x /scripts/setup-local-cli.sh /scripts/test-go.sh -# Entrypoint: Set up Aspire CLI from bundle, enable polyglot, run validation -# Note: ASPIRE_LAYOUT_PATH must be exported before running any aspire commands +# Entrypoint: Set up Aspire CLI, enable polyglot, run validation +# Bundle extraction happens lazily on first command that needs the layout ENTRYPOINT ["/bin/bash", "-c", "\ set -e && \ - echo '=== ENTRYPOINT DEBUG ===' && \ - echo 'Starting Docker entrypoint...' && \ - echo 'PWD:' $(pwd) && \ - echo '' && \ - echo '=== Running setup-local-cli.sh ===' && \ /scripts/setup-local-cli.sh && \ - echo '' && \ - echo '=== Post-setup: Setting ASPIRE_LAYOUT_PATH ===' && \ - export ASPIRE_LAYOUT_PATH=/workspace/artifacts/bundle && \ - echo 'ASPIRE_LAYOUT_PATH=' $ASPIRE_LAYOUT_PATH && \ - echo '' && \ - echo '=== Verifying CLI with layout path ===' && \ - echo 'Running: aspire --version' && \ - aspire --version && \ - echo '' && \ - echo '=== Enabling polyglot support ===' && \ aspire config set features:polyglotSupportEnabled true --global && \ - echo '' && \ - echo '=== Running validation ===' && \ /scripts/test-go.sh \ "] diff --git a/.github/workflows/polyglot-validation/Dockerfile.java b/.github/workflows/polyglot-validation/Dockerfile.java index d5666de93f9..ed787b8d988 100644 --- a/.github/workflows/polyglot-validation/Dockerfile.java +++ b/.github/workflows/polyglot-validation/Dockerfile.java @@ -8,7 +8,7 @@ # -v /var/run/docker.sock:/var/run/docker.sock \ # polyglot-java # -# Note: Expects bundle and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ +# Note: Expects self-extracting binary and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ # FROM mcr.microsoft.com/devcontainers/java:17 @@ -22,8 +22,6 @@ jq \ && rm -rf /var/lib/apt/lists/* -# Note: .NET SDK is NOT required - the bundle includes the .NET runtime - # Pre-configure Aspire CLI path ENV PATH="/root/.aspire/bin:${PATH}" @@ -33,28 +31,11 @@ COPY test-java.sh /scripts/test-java.sh RUN chmod +x /scripts/setup-local-cli.sh /scripts/test-java.sh -# Entrypoint: Set up Aspire CLI from bundle, enable polyglot, run validation -# Note: ASPIRE_LAYOUT_PATH must be exported before running any aspire commands +# Entrypoint: Set up Aspire CLI, enable polyglot, run validation +# Bundle extraction happens lazily on first command that needs the layout ENTRYPOINT ["/bin/bash", "-c", "\ set -e && \ - echo '=== ENTRYPOINT DEBUG ===' && \ - echo 'Starting Docker entrypoint...' && \ - echo 'PWD:' $(pwd) && \ - echo '' && \ - echo '=== Running setup-local-cli.sh ===' && \ /scripts/setup-local-cli.sh && \ - echo '' && \ - echo '=== Post-setup: Setting ASPIRE_LAYOUT_PATH ===' && \ - export ASPIRE_LAYOUT_PATH=/workspace/artifacts/bundle && \ - echo 'ASPIRE_LAYOUT_PATH=' $ASPIRE_LAYOUT_PATH && \ - echo '' && \ - echo '=== Verifying CLI with layout path ===' && \ - echo 'Running: aspire --version' && \ - aspire --version && \ - echo '' && \ - echo '=== Enabling polyglot support ===' && \ aspire config set features:polyglotSupportEnabled true --global && \ - echo '' && \ - echo '=== Running validation ===' && \ /scripts/test-java.sh \ "] diff --git a/.github/workflows/polyglot-validation/Dockerfile.python b/.github/workflows/polyglot-validation/Dockerfile.python index 9a8d391c3ea..76dd862f031 100644 --- a/.github/workflows/polyglot-validation/Dockerfile.python +++ b/.github/workflows/polyglot-validation/Dockerfile.python @@ -8,7 +8,7 @@ # -v /var/run/docker.sock:/var/run/docker.sock \ # polyglot-python # -# Note: Expects bundle and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ +# Note: Expects self-extracting binary and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ # FROM mcr.microsoft.com/devcontainers/python:3.12 @@ -22,8 +22,6 @@ RUN apt-get update && apt-get install -y \ jq \ && rm -rf /var/lib/apt/lists/* -# Note: .NET SDK is NOT required - the bundle includes the .NET runtime - # Install uv package manager (Python-specific) RUN curl -LsSf https://astral.sh/uv/install.sh | sh ENV PATH="/root/.local/bin:${PATH}" @@ -37,28 +35,11 @@ COPY setup-local-cli.sh /scripts/setup-local-cli.sh COPY test-python.sh /scripts/test-python.sh RUN chmod +x /scripts/setup-local-cli.sh /scripts/test-python.sh -# Entrypoint: Set up Aspire CLI from bundle, enable polyglot, run validation -# Note: ASPIRE_LAYOUT_PATH must be exported before running any aspire commands +# Entrypoint: Set up Aspire CLI, enable polyglot, run validation +# Bundle extraction happens lazily on first command that needs the layout ENTRYPOINT ["/bin/bash", "-c", "\ set -e && \ - echo '=== ENTRYPOINT DEBUG ===' && \ - echo 'Starting Docker entrypoint...' && \ - echo 'PWD:' $(pwd) && \ - echo '' && \ - echo '=== Running setup-local-cli.sh ===' && \ /scripts/setup-local-cli.sh && \ - echo '' && \ - echo '=== Post-setup: Setting ASPIRE_LAYOUT_PATH ===' && \ - export ASPIRE_LAYOUT_PATH=/workspace/artifacts/bundle && \ - echo 'ASPIRE_LAYOUT_PATH=' $ASPIRE_LAYOUT_PATH && \ - echo '' && \ - echo '=== Verifying CLI with layout path ===' && \ - echo 'Running: aspire --version' && \ - aspire --version && \ - echo '' && \ - echo '=== Enabling polyglot support ===' && \ aspire config set features:polyglotSupportEnabled true --global && \ - echo '' && \ - echo '=== Running validation ===' && \ /scripts/test-python.sh \ "] diff --git a/.github/workflows/polyglot-validation/Dockerfile.rust b/.github/workflows/polyglot-validation/Dockerfile.rust index 6eb4e70959d..8d39bbcd7f5 100644 --- a/.github/workflows/polyglot-validation/Dockerfile.rust +++ b/.github/workflows/polyglot-validation/Dockerfile.rust @@ -8,7 +8,7 @@ # -v /var/run/docker.sock:/var/run/docker.sock \ # polyglot-rust # -# Note: Expects bundle and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ +# Note: Expects self-extracting binary and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ # FROM mcr.microsoft.com/devcontainers/rust:1 @@ -22,8 +22,6 @@ RUN apt-get update && apt-get install -y \ jq \ && rm -rf /var/lib/apt/lists/* -# Note: .NET SDK is NOT required - the bundle includes the .NET runtime - # Pre-configure Aspire CLI path ENV PATH="/root/.aspire/bin:${PATH}" @@ -33,28 +31,11 @@ COPY setup-local-cli.sh /scripts/setup-local-cli.sh COPY test-rust.sh /scripts/test-rust.sh RUN chmod +x /scripts/setup-local-cli.sh /scripts/test-rust.sh -# Entrypoint: Set up Aspire CLI from bundle, enable polyglot, run validation -# Note: ASPIRE_LAYOUT_PATH must be exported before running any aspire commands +# Entrypoint: Set up Aspire CLI, enable polyglot, run validation +# Bundle extraction happens lazily on first command that needs the layout ENTRYPOINT ["/bin/bash", "-c", "\ set -e && \ - echo '=== ENTRYPOINT DEBUG ===' && \ - echo 'Starting Docker entrypoint...' && \ - echo 'PWD:' $(pwd) && \ - echo '' && \ - echo '=== Running setup-local-cli.sh ===' && \ /scripts/setup-local-cli.sh && \ - echo '' && \ - echo '=== Post-setup: Setting ASPIRE_LAYOUT_PATH ===' && \ - export ASPIRE_LAYOUT_PATH=/workspace/artifacts/bundle && \ - echo 'ASPIRE_LAYOUT_PATH=' $ASPIRE_LAYOUT_PATH && \ - echo '' && \ - echo '=== Verifying CLI with layout path ===' && \ - echo 'Running: aspire --version' && \ - aspire --version && \ - echo '' && \ - echo '=== Enabling polyglot support ===' && \ aspire config set features:polyglotSupportEnabled true --global && \ - echo '' && \ - echo '=== Running validation ===' && \ /scripts/test-rust.sh \ "] diff --git a/.github/workflows/polyglot-validation/Dockerfile.typescript b/.github/workflows/polyglot-validation/Dockerfile.typescript index c6bfb80b4c1..a18dd215694 100644 --- a/.github/workflows/polyglot-validation/Dockerfile.typescript +++ b/.github/workflows/polyglot-validation/Dockerfile.typescript @@ -8,7 +8,7 @@ # -v /var/run/docker.sock:/var/run/docker.sock \ # polyglot-typescript # -# Note: Expects bundle and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ +# Note: Expects self-extracting binary and NuGet artifacts to be pre-downloaded to /workspace/artifacts/ # FROM mcr.microsoft.com/devcontainers/typescript-node:22 @@ -22,8 +22,6 @@ RUN apt-get update && apt-get install -y \ jq \ && rm -rf /var/lib/apt/lists/* -# Note: .NET SDK is NOT required - the bundle includes the .NET runtime - # Pre-configure Aspire CLI path ENV PATH="/root/.aspire/bin:${PATH}" @@ -33,28 +31,11 @@ COPY setup-local-cli.sh /scripts/setup-local-cli.sh COPY test-typescript.sh /scripts/test-typescript.sh RUN chmod +x /scripts/setup-local-cli.sh /scripts/test-typescript.sh -# Entrypoint: Set up Aspire CLI from bundle, enable polyglot, run validation -# Note: ASPIRE_LAYOUT_PATH must be exported before running any aspire commands +# Entrypoint: Set up Aspire CLI, enable polyglot, run validation +# Bundle extraction happens lazily on first command that needs the layout ENTRYPOINT ["/bin/bash", "-c", "\ set -e && \ - echo '=== ENTRYPOINT DEBUG ===' && \ - echo 'Starting Docker entrypoint...' && \ - echo 'PWD:' $(pwd) && \ - echo '' && \ - echo '=== Running setup-local-cli.sh ===' && \ /scripts/setup-local-cli.sh && \ - echo '' && \ - echo '=== Post-setup: Setting ASPIRE_LAYOUT_PATH ===' && \ - export ASPIRE_LAYOUT_PATH=/workspace/artifacts/bundle && \ - echo 'ASPIRE_LAYOUT_PATH=' $ASPIRE_LAYOUT_PATH && \ - echo '' && \ - echo '=== Verifying CLI with layout path ===' && \ - echo 'Running: aspire --version' && \ - aspire --version && \ - echo '' && \ - echo '=== Enabling polyglot support ===' && \ aspire config set features:polyglotSupportEnabled true --global && \ - echo '' && \ - echo '=== Running validation ===' && \ /scripts/test-typescript.sh \ "] diff --git a/.github/workflows/polyglot-validation/setup-local-cli.sh b/.github/workflows/polyglot-validation/setup-local-cli.sh index 08d47d4e283..08e65e936b9 100644 --- a/.github/workflows/polyglot-validation/setup-local-cli.sh +++ b/.github/workflows/polyglot-validation/setup-local-cli.sh @@ -2,9 +2,8 @@ # setup-local-cli.sh - Set up Aspire CLI and NuGet packages from local artifacts # Used by polyglot validation Dockerfiles to use pre-built artifacts from the workflow # -# This version uses the BUNDLE instead of CLI archive: -# - Bundle includes: CLI, runtime, dashboard, dcp, aspire-server -# - No .NET SDK required (uses bundled runtime) +# The artifact is a self-extracting binary that embeds the runtime, dashboard, dcp, etc. +# Bundle extraction happens lazily on first command that needs the layout. set -e @@ -14,218 +13,61 @@ NUGETS_DIR="$ARTIFACTS_DIR/nugets" NUGETS_RID_DIR="$ARTIFACTS_DIR/nugets-rid" ASPIRE_HOME="$HOME/.aspire" -echo "==============================================" -echo "=== SETUP-LOCAL-CLI.SH - DEBUG OUTPUT ===" -echo "==============================================" -echo "" -echo "=== Environment ===" -echo "PWD: $(pwd)" -echo "HOME: $HOME" -echo "USER: $(whoami)" -echo "ARTIFACTS_DIR: $ARTIFACTS_DIR" -echo "BUNDLE_DIR: $BUNDLE_DIR" -echo "ASPIRE_HOME: $ASPIRE_HOME" -echo "" - -echo "=== /workspace contents ===" -ls -la /workspace 2>/dev/null || echo "/workspace does not exist" -echo "" - -echo "=== /workspace/artifacts contents ===" -ls -la "$ARTIFACTS_DIR" 2>/dev/null || echo "artifacts dir does not exist" -echo "" - -echo "=== /workspace/artifacts/bundle contents ===" -ls -la "$BUNDLE_DIR" 2>/dev/null || echo "bundle dir does not exist" -echo "" - -echo "=== Full bundle tree (all files) ===" -find "$BUNDLE_DIR" -type f 2>/dev/null | sort || echo "No files in bundle" -echo "" - -echo "=== Full bundle tree (all directories) ===" -find "$BUNDLE_DIR" -type d 2>/dev/null | sort || echo "No directories in bundle" -echo "" - -# Verify bundle exists -if [ ! -d "$BUNDLE_DIR" ]; then - echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" - echo "ERROR: Bundle directory does not exist: $BUNDLE_DIR" - echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" - echo "" - echo "=== Checking parent directories ===" - ls -la /workspace 2>/dev/null || echo "/workspace does not exist" - ls -la "$ARTIFACTS_DIR" 2>/dev/null || echo "Artifacts directory does not exist" - echo "" - echo "=== Find any 'aspire' executables ===" - find /workspace -name "aspire" -type f 2>/dev/null || echo "None found" - exit 1 -fi - -echo "=== Checking required bundle structure ===" -MISSING_DIRS="" -for dir in runtime dashboard dcp aspire-server; do - if [ -d "$BUNDLE_DIR/$dir" ]; then - echo " ✓ $dir/ exists" - echo " Contents: $(ls "$BUNDLE_DIR/$dir" | head -5 | tr '\n' ' ')" - else - echo " ✗ $dir/ MISSING" - MISSING_DIRS="$MISSING_DIRS $dir" - fi -done -echo "" - -# Check for muxer -echo "=== Checking for .NET muxer ===" -if [ -f "$BUNDLE_DIR/runtime/dotnet" ]; then - echo " ✓ runtime/dotnet exists" - echo " Size: $(ls -lh "$BUNDLE_DIR/runtime/dotnet" | awk '{print $5}')" - echo " Permissions: $(ls -l "$BUNDLE_DIR/runtime/dotnet" | awk '{print $1}')" - file "$BUNDLE_DIR/runtime/dotnet" 2>/dev/null || true -else - echo " ✗ runtime/dotnet MISSING" - echo " Looking for dotnet anywhere in bundle:" - find "$BUNDLE_DIR" -name "dotnet*" -type f 2>/dev/null || echo " None found" -fi -echo "" - -# Report any missing directories -if [ -n "$MISSING_DIRS" ]; then - echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" - echo "WARNING: Missing directories:$MISSING_DIRS" - echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" - echo "" -fi - -# Fix executable permissions (lost when downloading artifacts) -echo "=== Fixing executable permissions ===" -chmod +x "$BUNDLE_DIR/aspire" 2>/dev/null || true -chmod +x "$BUNDLE_DIR/runtime/dotnet" 2>/dev/null || true -# DCP executables -find "$BUNDLE_DIR/dcp" -type f -name "dcp*" -exec chmod +x {} \; 2>/dev/null || true -find "$BUNDLE_DIR/dcp" -type f ! -name "*.*" -exec chmod +x {} \; 2>/dev/null || true -echo " ✓ Permissions fixed" -echo "" - -# Check if aspire CLI exists in bundle -echo "=== Checking for aspire CLI executable ===" +# Install the self-extracting binary +echo "=== Installing Aspire CLI ===" if [ ! -f "$BUNDLE_DIR/aspire" ]; then - echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" - echo "ERROR: aspire CLI not found in bundle at: $BUNDLE_DIR/aspire" - echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" - echo "" - echo "Bundle contents:" - ls -la "$BUNDLE_DIR" - echo "" - echo "Looking for 'aspire' anywhere:" - find /workspace -name "aspire" -type f 2>/dev/null || echo "Not found anywhere" + echo "ERROR: aspire binary not found at $BUNDLE_DIR/aspire" + ls -la "$BUNDLE_DIR" 2>/dev/null || echo "Bundle directory does not exist" exit 1 fi -echo " ✓ aspire CLI found" -echo " Size: $(ls -lh "$BUNDLE_DIR/aspire" | awk '{print $5}')" -echo " Permissions: $(ls -l "$BUNDLE_DIR/aspire" | awk '{print $1}')" -file "$BUNDLE_DIR/aspire" 2>/dev/null || true -echo "" - -# Create CLI directory and copy CLI -echo "=== Installing CLI to $ASPIRE_HOME/bin ===" mkdir -p "$ASPIRE_HOME/bin" cp "$BUNDLE_DIR/aspire" "$ASPIRE_HOME/bin/" chmod +x "$ASPIRE_HOME/bin/aspire" -echo " ✓ CLI copied and made executable" -echo "" +echo " ✓ Installed to $ASPIRE_HOME/bin/aspire" -# Set ASPIRE_LAYOUT_PATH to point to the bundle so CLI uses bundled runtime/components -export ASPIRE_LAYOUT_PATH="$BUNDLE_DIR" -echo "=== Environment variable set ===" -echo " ASPIRE_LAYOUT_PATH=$ASPIRE_LAYOUT_PATH" -echo "" - -# Verify CLI works (this also tests that the bundled runtime works) -echo "=== Testing CLI with --version ===" -echo " Running: $ASPIRE_HOME/bin/aspire --version" +# Verify CLI works +echo "=== Verifying CLI ===" "$ASPIRE_HOME/bin/aspire" --version || { - echo "" - echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" - echo "ERROR: CLI --version failed!" - echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" - echo "" - echo "=== Debug: Running CLI with ASPIRE_DEBUG_LAYOUT=1 ===" - ASPIRE_DEBUG_LAYOUT=1 "$ASPIRE_HOME/bin/aspire" --version 2>&1 || true + echo "ERROR: aspire --version failed" + exit 1 +} + +# Extract the embedded bundle so runtime/dotnet and other components are available +# Commands like 'aspire init' and 'aspire add' need the bundled dotnet for NuGet operations +echo "=== Extracting bundle ===" +"$ASPIRE_HOME/bin/aspire" setup || { + echo "ERROR: aspire setup failed" exit 1 } -echo "" # Set up NuGet hive echo "=== Setting up NuGet package hive ===" HIVE_DIR="$ASPIRE_HOME/hives/local/packages" mkdir -p "$HIVE_DIR" -echo " Hive directory: $HIVE_DIR" -echo "" - -# Debug NuGet directories -echo "=== NuGet artifact directories ===" -echo " NUGETS_DIR: $NUGETS_DIR" -ls -la "$NUGETS_DIR" 2>/dev/null || echo " Does not exist" -echo "" -echo " NUGETS_RID_DIR: $NUGETS_RID_DIR" -ls -la "$NUGETS_RID_DIR" 2>/dev/null || echo " Does not exist" -echo "" -# Find NuGet packages in the shipping directory SHIPPING_DIR="$NUGETS_DIR/Release/Shipping" if [ ! -d "$SHIPPING_DIR" ]; then - echo " Release/Shipping not found, trying $NUGETS_DIR directly" SHIPPING_DIR="$NUGETS_DIR" fi if [ -d "$SHIPPING_DIR" ]; then - echo " Copying NuGet packages from $SHIPPING_DIR to hive" - # Copy all .nupkg files, handling nested directories find "$SHIPPING_DIR" -name "*.nupkg" -exec cp {} "$HIVE_DIR/" \; - PKG_COUNT=$(find "$HIVE_DIR" -name "*.nupkg" | wc -l) - echo " ✓ Copied $PKG_COUNT packages to hive" -else - echo " ✗ Warning: Could not find NuGet packages directory" - ls -la "$NUGETS_DIR" 2>/dev/null || echo " Directory does not exist" + echo " ✓ Copied $(find "$HIVE_DIR" -name "*.nupkg" | wc -l) packages" fi -# Copy RID-specific packages (Aspire.Hosting.Orchestration.linux-x64, Aspire.Dashboard.Sdk.linux-x64) if [ -d "$NUGETS_RID_DIR" ]; then - echo " Copying RID-specific NuGet packages from $NUGETS_RID_DIR to hive" find "$NUGETS_RID_DIR" -name "*.nupkg" -exec cp {} "$HIVE_DIR/" \; - RID_PKG_COUNT=$(find "$NUGETS_RID_DIR" -name "*.nupkg" | wc -l) - echo " ✓ Copied $RID_PKG_COUNT RID-specific packages to hive" -else - echo " ✗ Warning: Could not find RID-specific NuGet packages directory at $NUGETS_RID_DIR" + echo " ✓ Copied RID-specific packages" fi -# Total package count -TOTAL_PKG_COUNT=$(find "$HIVE_DIR" -name "*.nupkg" | wc -l) -echo "" -echo " Total packages in hive: $TOTAL_PKG_COUNT" -echo " Sample packages:" -find "$HIVE_DIR" -name "*.nupkg" | head -5 | while read f; do echo " - $(basename "$f")"; done -echo "" +echo " Total packages in hive: $(find "$HIVE_DIR" -name "*.nupkg" | wc -l)" # Set the channel to 'local' so CLI uses our hive echo "=== Configuring CLI channel ===" -echo " Setting channel to 'local'" "$ASPIRE_HOME/bin/aspire" config set channel local --global || { - echo " ✗ Warning: Failed to set channel" + echo " Warning: Failed to set channel" } -echo "" - -# Export ASPIRE_LAYOUT_PATH for child processes (like aspire run) -# This tells the CLI to use the bundled runtime, dashboard, dcp, etc. -echo "export ASPIRE_LAYOUT_PATH=$BUNDLE_DIR" >> ~/.bashrc -echo " ✓ Added ASPIRE_LAYOUT_PATH to ~/.bashrc" echo "" -echo "==============================================" echo "=== Aspire CLI setup complete ===" -echo "==============================================" -echo "Bundle mode enabled - using bundled runtime (no .NET SDK required)" -echo "ASPIRE_LAYOUT_PATH=$BUNDLE_DIR" -echo "" diff --git a/docs/specs/bundle.md b/docs/specs/bundle.md index 8762ac532ca..579eb94d202 100644 --- a/docs/specs/bundle.md +++ b/docs/specs/bundle.md @@ -12,15 +12,17 @@ This document specifies the **Aspire Bundle**, a self-contained distribution pac 3. [Goals and Non-Goals](#goals-and-non-goals) 4. [Architecture](#architecture) 5. [Bundle Layout](#bundle-layout) -6. [Component Discovery](#component-discovery) -7. [NuGet Operations](#nuget-operations) -8. [Certificate Management](#certificate-management) -9. [AppHost Server](#apphost-server) -10. [CLI Integration](#cli-integration) -11. [Configuration](#configuration) -12. [Size and Distribution](#size-and-distribution) -13. [Security Considerations](#security-considerations) -14. [Build Process](#build-process) +6. [Self-Extracting Binary](#self-extracting-binary) +7. [Component Discovery](#component-discovery) +8. [NuGet Operations](#nuget-operations) +9. [Certificate Management](#certificate-management) +10. [AppHost Server](#apphost-server) +11. [CLI Integration](#cli-integration) +12. [Installation](#installation) +13. [Configuration](#configuration) +14. [Size and Distribution](#size-and-distribution) +15. [Security Considerations](#security-considerations) +16. [Build Process](#build-process) --- @@ -218,6 +220,102 @@ aspire-{version}-{platform}/ --- +## Self-Extracting Binary + +The Aspire CLI can be distributed as a **self-extracting binary** — a single native AOT executable with the full bundle tarball embedded inside. This is the simplest installation method: download one file, run `aspire setup`, done. + +### Binary Format + +The payload is embedded as a .NET assembly resource (`bundle.tar.gz`) in the native AOT CLI binary. This means the payload lives inside the PE/ELF binary's resource section, which is covered by code signing. + +```text +┌─────────────────────────────────────────────────┐ +│ Native AOT CLI (~29 MB) │ +│ (fully functional without payload) │ +│ │ +│ Embedded resource: bundle.tar.gz │ +│ (~100 MB compressed payload: │ +│ runtime, dashboard, dcp, etc.) │ +└─────────────────────────────────────────────────┘ +``` + +**Detection**: `Assembly.GetManifestResourceInfo("bundle.tar.gz")` — metadata-only check, no I/O. + +**Payload access**: `Assembly.GetManifestResourceStream("bundle.tar.gz")` — demand-paged by the OS, zero memory overhead at startup until extraction is needed. + +A CLI binary without an embedded resource (dev build, dotnet tool install, or previously-extracted copy) has no payload. All extraction commands gracefully no-op. + +### BundleService + +All extraction logic is centralized in `IBundleService` / `BundleService` (`src/Aspire.Cli/Bundles/`): + +| Method | Purpose | Used by | +|--------|---------|---------| +| `EnsureExtractedAsync()` | Lazy extraction from `Environment.ProcessPath` | `AppHostServerProjectFactory` | +| `ExtractAsync(dest, force)` | Explicit extraction, returns `BundleExtractResult` | `SetupCommand` | +| `IsBundle` | Whether the CLI binary contains an embedded bundle | DI factory methods | +| `EnsureExtractedAndGetLayoutAsync()` | Ensures extraction + returns discovered layout | `BundleNuGetPackageCache`, etc. | + +The service uses a file lock (`.aspire-bundle-lock`) in the extraction directory for cross-process synchronization and is registered as a singleton. + +**Extraction flow:** +1. Check for embedded `bundle.tar.gz` resource — if absent, return `NoPayload` +2. Check version marker (`.aspire-bundle-version`) — if version matches, return `AlreadyUpToDate` +3. Clean well-known layout directories (runtime, dashboard, dcp, aspire-server, tools) — preserves `bin/` +4. Extract payload using .NET `TarReader` with path-traversal and symlink validation +5. Set Unix file permissions from tar entry metadata (execute bit, etc.) +6. Write version marker with assembly informational version +7. Validate layout via `LayoutDiscovery` + +### Extraction Modes + +#### Explicit: `aspire setup` + +```bash +aspire setup [--install-path ] [--force] +``` + +Best for install scripts — reduces to: + +```bash +mkdir -p ~/.aspire/bin +curl -fsSL .../aspire -o ~/.aspire/bin/aspire && chmod +x ~/.aspire/bin/aspire +~/.aspire/bin/aspire setup +export PATH="$HOME/.aspire/bin:$PATH" +``` + +#### Lazy: first polyglot command + +When a polyglot project runs `aspire run`, `AppHostServerProjectFactory.CreateAsync()` calls `BundleService.EnsureExtractedAsync()`. This transparently extracts on first use, before the normal command flow. C# projects never trigger extraction — they use `dotnet` directly. + +#### After self-update + +`aspire update --self` downloads the new self-extracting binary, swaps it, then calls `BundleService.ExtractAsync(force: true)` to proactively extract the updated payload. + +### Version Tracking + +The file `.aspire-bundle-version` in the layout root contains the assembly informational version string (e.g., `13.2.0-pr.14398.gabc1234`). This enables: + +- **Skip extraction** when version matches (normal startup is free) +- **Re-extract** when CLI binary is updated (version changes) +- **Force re-extract** with `aspire setup --force` (ignores version) + +### Platform Notes + +- **macOS**: Archives are created with `COPYFILE_DISABLE=1` to suppress `SCHILY.xattr` PAX headers that break .NET's `TarReader`. +- **Unix**: `TarReader` extraction preserves file permissions from tar entry metadata (execute bit, etc.). +- **Windows**: Uses .NET `TarReader` for extraction (no system `tar` dependency). Unix file permissions are not applicable. +- **Layout cleanup**: Before re-extraction, well-known directories are removed to avoid file conflicts. + +### Shared Code + +| File | Purpose | +|------|---------| +| `src/Aspire.Cli/Bundles/IBundleService.cs` | Interface + `BundleExtractResult` enum | +| `src/Aspire.Cli/Bundles/BundleService.cs` | Implementation with .NET TarReader extraction | + +--- + ## Component Discovery The CLI and `Aspire.Hosting` both need to discover DCP, Dashboard, and .NET runtime locations. During the transition period, different versions of CLI and Aspire.Hosting may be used together, so both components implement discovery with graceful fallback. @@ -500,22 +598,26 @@ No user configuration or flags are required - the experience is identical regard ### Self-Update Command -When running from a bundle, `aspire update --self` updates the bundle to the latest version: +`aspire update --self` updates the CLI to the latest version: ```bash -# Update the bundle to the latest version +# Update the CLI to the latest version aspire update --self -# Check for updates without installing -aspire update --self --check +# Update to a specific channel +aspire update --self --channel daily ``` -The update process: -1. Queries GitHub releases API for latest version -2. Downloads the appropriate platform-specific archive -3. Extracts to a temporary location -4. Replaces the current bundle (preserving user config) -5. Restarts the CLI if needed +With self-extracting binaries, the update process is: +1. User selects a channel (stable, staging, daily) +2. Downloads the new self-extracting CLI binary (platform-specific archive) +3. Extracts archive to temp, finds new binary +4. Backs up current binary, swaps in the new one +5. Verifies new binary with `--version` +6. Calls `BundleService.ExtractAsync(force: true)` to proactively extract the embedded payload +7. Saves selected channel to global settings + +The old bundle-update path (downloading a full tarball and applying via `IBundleDownloader`) has been removed. The self-extracting binary IS the bundle — one download, one file, everything included. When running via `dotnet tool`, `aspire update --self` displays instructions to use `dotnet tool update`. @@ -567,14 +669,14 @@ irm https://aka.ms/install-aspire.ps1 | iex ### Script Behavior -The install scripts: +With self-extracting binaries, install scripts can be simplified to: 1. Detect the current platform (OS + architecture) -2. Query GitHub releases for the latest bundle version -3. Download the appropriate archive -4. Extract to the default location (`~/.aspire/`) -5. Move CLI binary to `bin/` subdirectory for consistent PATH -6. Add `~/.aspire/bin` to PATH (with user confirmation) -7. Verify installation with `aspire --version` +2. Download the self-extracting binary to `~/.aspire/bin/aspire` +3. Run `aspire setup` to extract the embedded payload +4. Add `~/.aspire/bin` to PATH +5. Verify installation with `aspire --version` + +The install scripts now exclusively support this self-extracting binary flow; archive-based bundles are no longer downloaded or extracted, and CI uploads only the self-extracting CLI binary. ### Installed Layout @@ -583,9 +685,10 @@ The bundle installs components as siblings under `~/.aspire/`, with the CLI bina ```text ~/.aspire/ ├── bin/ # CLI binary (shared path for both install methods) -│ └── aspire # - Native AOT CLI executable (bundle install) +│ └── aspire # - Self-extracting native AOT CLI (bundle install) │ # - Or SDK-based CLI (CLI-only install) │ +├── .aspire-bundle-version # Version marker (hex FNV-1a hash, written after extraction) ├── layout.json # Bundle metadata (present only for bundle install) │ ├── runtime/ # Bundled .NET runtime @@ -613,8 +716,10 @@ The bundle installs components as siblings under `~/.aspire/`, with the CLI bina **Key behaviors:** - The CLI lives at `~/.aspire/bin/aspire` regardless of install method +- With self-extracting binaries, the CLI in `bin/` contains the embedded payload; `aspire setup` extracts siblings +- `.aspire-bundle-version` tracks the extracted version — extraction is skipped when hash matches - Bundle components (`runtime/`, `dashboard/`, `dcp/`, etc.) are siblings at the `~/.aspire/` root -- NuGet hives and settings are preserved across installations +- NuGet hives and settings are preserved across installations and re-extractions - `LayoutDiscovery` finds the bundle by checking the CLI's parent directory for components ### Script Options @@ -713,13 +818,15 @@ Downloaded integration packages are cached in: ### Distribution Formats -| Platform | Format | Filename | -|----------|--------|----------| -| Windows x64 | ZIP | `aspire-13.2.0-win-x64.zip` | -| Linux x64 | tar.gz | `aspire-13.2.0-linux-x64.tar.gz` | -| Linux ARM64 | tar.gz | `aspire-13.2.0-linux-arm64.tar.gz` | -| macOS x64 | tar.gz | `aspire-13.2.0-osx-x64.tar.gz` | -| macOS ARM64 | tar.gz | `aspire-13.2.0-osx-arm64.tar.gz` | +| Platform | Archive | Self-Extracting Binary | +|----------|---------|----------------------| +| Windows x64 | `aspire-{ver}-win-x64.zip` | `aspire.exe` (~134 MB) | +| Linux x64 | `aspire-{ver}-linux-x64.tar.gz` | `aspire` (~134 MB) | +| Linux ARM64 | `aspire-{ver}-linux-arm64.tar.gz` | `aspire` (~134 MB) | +| macOS x64 | `aspire-{ver}-osx-x64.tar.gz` | `aspire` (~134 MB) | +| macOS ARM64 | `aspire-{ver}-osx-arm64.tar.gz` | `aspire` (~134 MB) | + +The self-extracting binary is the **recommended distribution format** — one file containing the CLI and full bundle payload. Archive format is still produced for compatibility with existing install scripts. ### Download Locations @@ -778,13 +885,17 @@ To test bundle infrastructure during development without affecting the normal de ### Checksum Verification -Each release includes SHA256 checksums: +Each release publishes SHA-256 checksum files alongside the bundle binaries: ```text aspire-13.2.0-linux-x64.tar.gz.sha256 aspire-13.2.0-win-x64.zip.sha256 ``` +Install scripts and `aspire update --self` should verify the downloaded file's SHA-256 hash against the `.sha256` file before installing. This catches corruption from partial downloads, network errors, or disk issues. The checksum covers the entire file (CLI binary + payload + trailer), providing end-to-end integrity verification. + +The bundle trailer itself does **not** contain a payload hash. Integrity verification is the responsibility of the download/install path, not the extraction path. This keeps the trailer format simple and avoids a double-read of the ~150 MB payload during extraction. + ### Runtime Isolation The bundled .NET runtime is isolated from any globally-installed .NET: @@ -1191,10 +1302,25 @@ This section tracks the implementation progress of the bundle feature. - Copies DCP, Dashboard, aspire-server, NuGetHelper - Generates layout.json metadata - Enables RollForward=Major for all managed tools + - `--embed-in-cli` option creates self-extracting binary - [x] **Installation scripts** - `eng/scripts/get-aspire-cli-bundle-pr.sh`, `eng/scripts/get-aspire-cli-bundle-pr.ps1` - Downloads bundle archive from PR build artifacts - Extracts to `~/.aspire/` with CLI in `bin/` subdirectory - Downloads and installs NuGet hive packages for PR channel +- [x] **Self-extracting binary** - `src/Shared/BundleTrailer.cs`, `src/Aspire.Cli/Bundles/` + - 32-byte trailer format (magic + offset + size + version hash) + - `IBundleService` / `BundleService` for centralized extraction logic + - Thread-safe extraction with `SemaphoreSlim` + - Platform-aware extraction (system `tar` on Unix, .NET `TarReader` on Windows) + - Version tracking via `.aspire-bundle-version` marker file +- [x] **Setup command** - `src/Aspire.Cli/Commands/SetupCommand.cs` + - `aspire setup [--install-path] [--force]` + - Delegates to `IBundleService.ExtractAsync()` +- [x] **Self-update simplified** - `src/Aspire.Cli/Commands/UpdateCommand.cs` + - `aspire update --self` downloads new CLI, swaps binary, extracts via `IBundleService` + - Removed old `ExecuteBundleSelfUpdateAsync` / `IBundleDownloader` dependency +- [x] **Unit tests** - `tests/Aspire.Cli.Tests/BundleServiceTests.cs` + - 10 tests: roundtrip, edge cases, version marker, tar.gz extraction with strip-components ### In Progress @@ -1202,8 +1328,8 @@ This section tracks the implementation progress of the bundle feature. ### Pending -- [ ] Self-update command (`aspire update --self`) - BundleDownloader exists but not wired - [ ] Multi-platform build workflow (GitHub Actions) +- [ ] Simplify install scripts to thin download + `aspire setup` wrappers ### Key Files @@ -1220,7 +1346,14 @@ This section tracks the implementation progress of the bundle feature. | `src/Aspire.Cli/Certificates/ICertificateToolRunner.cs` | Certificate tool abstraction | | `src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs` | Bundled dev-certs runner | | `src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs` | SDK-based dev-certs runner | -| `tools/CreateLayout/Program.cs` | Bundle build tool | +| `src/Shared/BundleTrailer.cs` | (Deleted) Previously held trailer read/write logic | +| `src/Aspire.Cli/Bundles/IBundleService.cs` | Bundle extraction interface + result enum | +| `src/Aspire.Cli/Bundles/BundleService.cs` | Centralized extraction with .NET TarReader | +| `src/Aspire.Cli/Commands/SetupCommand.cs` | `aspire setup` command | +| `src/Aspire.Cli/Utils/ArchiveHelper.cs` | Shared .zip/.tar.gz extraction utility | +| `tools/CreateLayout/Program.cs` | Bundle build tool (layout assembly + self-extracting binary) | +| `eng/Bundle.proj` | MSBuild orchestration for bundle creation | +| `tests/Aspire.Cli.Tests/BundleServiceTests.cs` | Unit tests for bundle service and extraction | --- @@ -1279,4 +1412,24 @@ The CreateLayout tool automatically patches all `*.runtimeconfig.json` files: 5. **Download and copy DCP** binaries 6. **Patch runtimeconfig.json files** to enable RollForward=Major 7. **Generate layout.json** with component metadata -8. **Create archive** (ZIP for Windows, tar.gz for Unix) +8. **Create archive** (tar.gz for Unix, ZIP for Windows) with `COPYFILE_DISABLE=1` to suppress macOS xattr headers +9. **Create self-extracting binary** — appends tar.gz payload + 32-byte trailer to native AOT CLI + +### Self-Extracting Binary Build + +`Bundle.proj` passes `--embed-in-cli` to `CreateLayout`, which: + +1. Takes the native AOT CLI binary and the tar.gz archive +2. Copies the CLI binary to `{output}.bundle` +3. Appends the tar.gz payload +4. Writes the 32-byte trailer (magic + offset + size + version hash) +5. Replaces the original CLI binary with the bundle + +```bash +# Build command +dotnet msbuild eng/Bundle.proj /p:TargetRid=osx-arm64 /p:Configuration=Release /p:BundleRuntimeVersion=10.0.102 + +# Output: artifacts/bundle/osx-arm64/aspire (self-extracting, ~134 MB) +``` + +The resulting binary is a valid native executable that also contains the full bundle. Running `aspire --version` works immediately; `aspire setup` extracts the payload. diff --git a/eng/Bundle.proj b/eng/Bundle.proj index 560501bbc72..e671888fea6 100644 --- a/eng/Bundle.proj +++ b/eng/Bundle.proj @@ -57,10 +57,10 @@ + _RunCreateLayout; + _PublishNativeCli"> @@ -87,9 +87,15 @@ <_CliBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishCli.binlog + + <_BundleArchivePath>$(ArtifactsDir)bundle\aspire-$(BundleVersion)-$(TargetRid).tar.gz + + <_VersionSuffixArg Condition="'$(VersionSuffix)' != ''">/p:VersionSuffix=$(VersionSuffix) - - + + @@ -118,7 +124,14 @@ <_BundleOutputDirArg>$(BundleOutputDir.TrimEnd('\').TrimEnd('/')) <_ArtifactsDirArg>$(ArtifactsDir.TrimEnd('\').TrimEnd('/')) - <_CreateLayoutArgs>--output "$(_BundleOutputDirArg)" --artifacts "$(_ArtifactsDirArg)" --rid $(TargetRid) --bundle-version $(BundleVersion) --runtime-version $(BundleRuntimeVersion) --archive --verbose --download-runtime + + + <_RuntimeArgs Condition="'$(BundleRuntimePath)' != ''">--runtime "$(BundleRuntimePath)" + <_RuntimeArgs Condition="'$(BundleRuntimePath)' == ''">--download-runtime + + <_CreateLayoutArgs>--output "$(_BundleOutputDirArg)" --artifacts "$(_ArtifactsDirArg)" --rid $(TargetRid) --bundle-version $(BundleVersion) --runtime-version $(BundleRuntimeVersion) --verbose $(_RuntimeArgs) --archive diff --git a/eng/scripts/README.md b/eng/scripts/README.md index 2ae38de8a94..b5b628cdc38 100644 --- a/eng/scripts/README.md +++ b/eng/scripts/README.md @@ -303,10 +303,38 @@ Remove-Item -Recurse -Force "$env:LOCALAPPDATA\Aspire" ### Bundle vs CLI-Only -| Feature | CLI-Only Scripts | Bundle Scripts | -|---------|-----------------|----------------| -| Requires .NET SDK | Yes | No | -| Package size | ~25 MB | ~200 MB compressed | -| Polyglot support | Partial | Full | -| Components included | CLI only | CLI, Runtime, Dashboard, DCP | -| Use case | .NET developers | TypeScript, Python, Go developers | +| Feature | CLI-Only Scripts | Bundle Scripts | Self-Extracting Binary | +|---------|-----------------|----------------|----------------------| +| Requires .NET SDK | Yes | No | No | +| Package size | ~25 MB | ~200 MB compressed | ~210 MB (single file) | +| Polyglot support | Partial | Full | Full | +| Components included | CLI only | CLI, Runtime, Dashboard, DCP | CLI, Runtime, Dashboard, DCP | +| Installation steps | Download + PATH | Download + extract + PATH | Download + `aspire setup` | +| Use case | .NET developers | TypeScript, Python, Go developers | Simplest install path | + +### Self-Extracting Binary + +The Aspire CLI can also be distributed as a self-extracting binary that embeds the full bundle +payload inside the native AOT executable. This is the simplest installation method: + +```bash +# Linux/macOS - download and extract +mkdir -p ~/.aspire/bin +curl -fsSL /aspire -o ~/.aspire/bin/aspire +chmod +x ~/.aspire/bin/aspire +~/.aspire/bin/aspire setup + +# Add to PATH +export PATH="$HOME/.aspire/bin:$PATH" +``` + +```powershell +# Windows - download and extract +New-Item -ItemType Directory -Force -Path "$env:LOCALAPPDATA\Aspire\bin" +Invoke-WebRequest -Uri /aspire.exe -OutFile "$env:LOCALAPPDATA\Aspire\bin\aspire.exe" +& "$env:LOCALAPPDATA\Aspire\bin\aspire.exe" setup +``` + +The `aspire setup` command extracts the embedded payload to the parent directory of the CLI binary. +Alternatively, extraction happens lazily on the first command that needs the bundle layout +(e.g., `aspire run` with a polyglot project). diff --git a/eng/scripts/get-aspire-cli-bundle-pr.ps1 b/eng/scripts/get-aspire-cli-bundle-pr.ps1 index 0149df0deab..4f429c593da 100644 --- a/eng/scripts/get-aspire-cli-bundle-pr.ps1 +++ b/eng/scripts/get-aspire-cli-bundle-pr.ps1 @@ -2,21 +2,15 @@ <# .SYNOPSIS - Download and unpack the Aspire CLI Bundle from a specific PR's build artifacts + Download and install the Aspire CLI Bundle from a specific PR's build artifacts .DESCRIPTION Downloads and installs the Aspire CLI Bundle from a specific pull request's latest successful build. Automatically detects the current platform (OS and architecture) and downloads the appropriate artifact. - The bundle is a self-contained distribution that includes: - - Native AOT Aspire CLI - - .NET runtime (for running managed components) - - Dashboard (web-based monitoring UI) - - DCP (Developer Control Plane for orchestration) - - AppHost Server (for polyglot apps - TypeScript, Python, Go, etc.) - - NuGet Helper tools - - This bundle allows running Aspire applications WITHOUT requiring a globally-installed .NET SDK. + The bundle artifact contains a self-extracting Aspire CLI binary that embeds all runtime components. + The script downloads the binary, places it in the install directory, and runs `aspire setup` to + extract the embedded components (Dashboard, DCP, runtime, AppHost Server, NuGet tools). .PARAMETER PRNumber Pull request number (required) @@ -353,31 +347,31 @@ function Install-AspireBundle { return $true } - # Create install directory (may already exist with other aspire state like logs, certs, etc.) - Write-VerboseMessage "Installing bundle from $DownloadDir to $InstallDir" - - try { - Copy-Item -Path "$DownloadDir/*" -Destination $InstallDir -Recurse -Force - - # Move CLI binary into bin/ subdirectory so it shares the same path as CLI-only install - # Layout: ~/.aspire/bin/aspire (CLI) + ~/.aspire/runtime/ + ~/.aspire/dashboard/ + ... - $binDir = Join-Path $InstallDir "bin" - if (-not (Test-Path $binDir)) { - New-Item -ItemType Directory -Path $binDir -Force | Out-Null - } - $cliExe = if ($IsWindows -or $env:OS -eq "Windows_NT") { "aspire.exe" } else { "aspire" } - $cliSource = Join-Path $InstallDir $cliExe - if (Test-Path $cliSource) { - Move-Item -Path $cliSource -Destination (Join-Path $binDir $cliExe) -Force + # Find the self-extracting binary in the downloaded artifact + $cliExe = if ($IsWindows -or $env:OS -eq "Windows_NT") { "aspire.exe" } else { "aspire" } + $binaryPath = Join-Path $DownloadDir $cliExe + if (-not (Test-Path $binaryPath)) { + # Search in subdirectories + $found = Get-ChildItem -Path $DownloadDir -Filter $cliExe -Recurse | Select-Object -First 1 + if ($found) { + $binaryPath = $found.FullName + } else { + Write-ErrorMessage "Could not find $cliExe in downloaded artifact" + return $false } - - Write-SuccessMessage "Aspire CLI bundle successfully installed to: $InstallDir" - return $true } - catch { - Write-ErrorMessage "Failed to copy bundle files: $_" - return $false + + # Place the self-extracting binary in bin/ + $binDir = Join-Path $InstallDir "bin" + if (-not (Test-Path $binDir)) { + New-Item -ItemType Directory -Path $binDir -Force | Out-Null } + $cliPath = Join-Path $binDir $cliExe + Copy-Item -Path $binaryPath -Destination $cliPath -Force + + # Bundle extraction happens lazily on first command that needs the layout + Write-SuccessMessage "Aspire CLI bundle successfully installed to: $InstallDir" + return $true } # ============================================================================= diff --git a/eng/scripts/get-aspire-cli-bundle-pr.sh b/eng/scripts/get-aspire-cli-bundle-pr.sh index 333767c24cd..e0679db2c0c 100644 --- a/eng/scripts/get-aspire-cli-bundle-pr.sh +++ b/eng/scripts/get-aspire-cli-bundle-pr.sh @@ -1,15 +1,11 @@ #!/usr/bin/env bash -# get-aspire-cli-bundle-pr.sh - Download and unpack the Aspire CLI Bundle from a specific PR's build artifacts +# get-aspire-cli-bundle-pr.sh - Download and install the Aspire CLI Bundle from a specific PR's build artifacts # Usage: ./get-aspire-cli-bundle-pr.sh PR_NUMBER [OPTIONS] # -# The bundle is a self-contained distribution that includes: -# - Native AOT Aspire CLI -# - .NET runtime -# - Dashboard -# - DCP (Developer Control Plane) -# - AppHost Server (for polyglot apps) -# - NuGet Helper tools +# The bundle artifact contains a self-extracting Aspire CLI binary that embeds all +# runtime components. The script downloads the binary, places it in the install +# directory, and runs `aspire setup` to extract the embedded components. set -euo pipefail @@ -50,15 +46,16 @@ DESCRIPTION: Downloads and installs the Aspire CLI Bundle from a specific pull request's latest successful build. Automatically detects the current platform (OS and architecture) and downloads the appropriate artifact. - The bundle is a self-contained distribution that includes: - - Native AOT Aspire CLI - - .NET runtime (for running managed components) + The bundle artifact contains a self-extracting Aspire CLI binary that embeds all runtime + components. The script downloads the binary, places it in the install directory, and runs + `aspire setup` to extract the embedded components: - Dashboard (web-based monitoring UI) - DCP (Developer Control Plane for orchestration) + - .NET runtime (for running managed components) - AppHost Server (for polyglot apps - TypeScript, Python, Go, etc.) - NuGet Helper tools - This bundle allows running Aspire applications WITHOUT requiring a globally-installed .NET SDK. + This enables running Aspire applications WITHOUT requiring a globally-installed .NET SDK. The script queries the GitHub API to find the latest successful run of the 'ci.yml' workflow for the specified PR, then downloads and extracts the bundle archive for your platform. @@ -313,52 +310,6 @@ remove_temp_dir() { fi } -# ============================================================================= -# Archive handling -# ============================================================================= - -install_archive() { - local archive_file="$1" - local destination_path="$2" - - if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would install archive $archive_file to $destination_path" - return 0 - fi - - say_verbose "Installing archive to: $destination_path" - - if [[ ! -d "$destination_path" ]]; then - say_verbose "Creating install directory: $destination_path" - mkdir -p "$destination_path" - fi - - if [[ "$archive_file" =~ \.zip$ ]]; then - if ! command -v unzip >/dev/null 2>&1; then - say_error "unzip command not found. Please install unzip." - return 1 - fi - if ! unzip -o "$archive_file" -d "$destination_path"; then - say_error "Failed to extract ZIP archive: $archive_file" - return 1 - fi - elif [[ "$archive_file" =~ \.tar\.gz$ ]]; then - if ! command -v tar >/dev/null 2>&1; then - say_error "tar command not found. Please install tar." - return 1 - fi - if ! tar -xzf "$archive_file" -C "$destination_path"; then - say_error "Failed to extract tar.gz archive: $archive_file" - return 1 - fi - else - say_error "Unsupported archive format: $archive_file" - return 1 - fi - - say_verbose "Successfully installed archive" -} - # ============================================================================= # PATH management # ============================================================================= @@ -573,36 +524,28 @@ install_aspire_bundle() { return 0 fi - # Create install directory (may already exist with other aspire state like logs, certs, etc.) - mkdir -p "$install_dir" - - # Copy bundle contents, overwriting existing files - say_verbose "Installing bundle from $download_dir to $install_dir" - if ! cp -rf "$download_dir"/* "$install_dir"/; then - say_error "Failed to copy bundle files" - return 1 - fi - - # Move CLI binary into bin/ subdirectory so it shares the same path as CLI-only install - # Layout: ~/.aspire/bin/aspire (CLI) + ~/.aspire/runtime/ + ~/.aspire/dashboard/ + ... - mkdir -p "$install_dir/bin" - if [[ -f "$install_dir/aspire" ]]; then - mv "$install_dir/aspire" "$install_dir/bin/aspire" + # Find the self-extracting binary in the downloaded artifact + local binary_name="aspire" + local binary_path="" + if [[ -f "$download_dir/$binary_name" ]]; then + binary_path="$download_dir/$binary_name" + else + # Search for the binary in subdirectories + binary_path=$(find "$download_dir" -name "$binary_name" -type f | head -1) fi - # Make CLI executable - local cli_path="$install_dir/bin/aspire" - if [[ -f "$cli_path" ]]; then - chmod +x "$cli_path" + if [[ -z "$binary_path" || ! -f "$binary_path" ]]; then + say_error "Could not find aspire binary in downloaded artifact" + return 1 fi - # Make other executables executable - for exe in "$install_dir"/dcp/dcp "$install_dir"/runtime/dotnet; do - if [[ -f "$exe" ]]; then - chmod +x "$exe" - fi - done + # Place the self-extracting binary in bin/ + local bin_dir="$install_dir/bin" + mkdir -p "$bin_dir" + cp "$binary_path" "$bin_dir/aspire" + chmod +x "$bin_dir/aspire" + # Bundle extraction happens lazily on first command that needs the layout say_success "Aspire CLI bundle successfully installed to: $install_dir" } diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 1da9ab1f7b1..654c480a3a3 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -51,6 +51,12 @@ + + + + + diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs new file mode 100644 index 00000000000..a69179b0254 --- /dev/null +++ b/src/Aspire.Cli/Bundles/BundleService.cs @@ -0,0 +1,317 @@ +// 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.Formats.Tar; +using System.IO.Compression; +using Aspire.Cli.Layout; +using Aspire.Cli.Utils; +using Aspire.Shared; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Bundles; + +/// +/// Manages extraction of the embedded bundle payload from self-extracting CLI binaries. +/// +internal sealed class BundleService(ILayoutDiscovery layoutDiscovery, ILogger logger) : IBundleService +{ + private const string PayloadResourceName = "bundle.tar.gz"; + + /// + /// Name of the marker file written after successful extraction. + /// + internal const string VersionMarkerFileName = ".aspire-bundle-version"; + + private static readonly bool s_isBundle = + typeof(BundleService).Assembly.GetManifestResourceInfo(PayloadResourceName) is not null; + + /// + public bool IsBundle => s_isBundle; + + /// + /// Opens a read-only stream over the embedded bundle payload. + /// Returns if no payload is embedded. + /// + public static Stream? OpenPayload() => + typeof(BundleService).Assembly.GetManifestResourceStream(PayloadResourceName); + + /// + /// Well-known layout subdirectories that are cleaned before re-extraction. + /// The bin/ directory is intentionally excluded since it contains the running CLI binary. + /// + internal static readonly string[] s_layoutDirectories = [ + BundleDiscovery.RuntimeDirectoryName, + BundleDiscovery.DashboardDirectoryName, + BundleDiscovery.DcpDirectoryName, + BundleDiscovery.AppHostServerDirectoryName, + "tools" + ]; + + /// + public async Task EnsureExtractedAsync(CancellationToken cancellationToken = default) + { + if (!IsBundle) + { + logger.LogDebug("No embedded bundle payload, skipping extraction."); + return; + } + + var processPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(processPath)) + { + logger.LogDebug("ProcessPath is null or empty, skipping bundle extraction."); + return; + } + + var extractDir = GetDefaultExtractDir(processPath); + if (extractDir is null) + { + logger.LogDebug("Could not determine extraction directory from {ProcessPath}, skipping.", processPath); + return; + } + + logger.LogDebug("Ensuring bundle is extracted to {ExtractDir}.", extractDir); + var result = await ExtractAsync(extractDir, force: false, cancellationToken); + + if (result is BundleExtractResult.ExtractionFailed) + { + throw new InvalidOperationException( + "Bundle extraction failed. Run 'aspire setup --force' to retry, or reinstall the Aspire CLI."); + } + } + + /// + public async Task EnsureExtractedAndGetLayoutAsync(CancellationToken cancellationToken = default) + { + await EnsureExtractedAsync(cancellationToken).ConfigureAwait(false); + return layoutDiscovery.DiscoverLayout(); + } + + /// + public async Task ExtractAsync(string destinationPath, bool force = false, CancellationToken cancellationToken = default) + { + if (!IsBundle) + { + logger.LogDebug("No embedded bundle payload."); + return BundleExtractResult.NoPayload; + } + + // Use a file lock for cross-process synchronization + var lockPath = Path.Combine(destinationPath, ".aspire-bundle-lock"); + logger.LogDebug("Acquiring bundle extraction lock at {LockPath}...", lockPath); + using var fileLock = await FileLock.AcquireAsync(lockPath, cancellationToken).ConfigureAwait(false); + logger.LogDebug("Bundle extraction lock acquired."); + + try + { + // Re-check after acquiring lock — another process may have already extracted + if (!force && layoutDiscovery.DiscoverLayout() is not null) + { + var existingVersion = ReadVersionMarker(destinationPath); + var currentVersion = GetCurrentVersion(); + if (existingVersion == currentVersion) + { + logger.LogDebug("Bundle already extracted and up to date (version: {Version}).", existingVersion); + return BundleExtractResult.AlreadyUpToDate; + } + + logger.LogDebug("Version mismatch: existing={ExistingVersion}, current={CurrentVersion}. Re-extracting.", existingVersion, currentVersion); + } + + return await ExtractCoreAsync(destinationPath, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to extract bundle to {Path}", destinationPath); + return BundleExtractResult.ExtractionFailed; + } + } + + private async Task ExtractCoreAsync(string destinationPath, CancellationToken cancellationToken) + { + logger.LogInformation("Extracting embedded bundle to {Path}...", destinationPath); + + // Clean existing layout directories before extraction to avoid file conflicts + logger.LogDebug("Cleaning existing layout directories in {Path}.", destinationPath); + CleanLayoutDirectories(destinationPath); + + var sw = Stopwatch.StartNew(); + await ExtractPayloadAsync(destinationPath, cancellationToken); + sw.Stop(); + logger.LogDebug("Payload extraction completed in {ElapsedMs}ms.", sw.ElapsedMilliseconds); + + // Write version marker so subsequent runs skip extraction + var currentVersion = GetCurrentVersion(); + WriteVersionMarker(destinationPath, currentVersion); + logger.LogDebug("Version marker written (version: {Version}).", currentVersion); + + // Verify extraction produced a valid layout + if (layoutDiscovery.DiscoverLayout() is null) + { + logger.LogError("Extraction completed but no valid layout found in {Path}.", destinationPath); + return BundleExtractResult.ExtractionFailed; + } + + logger.LogDebug("Bundle extraction verified successfully."); + return BundleExtractResult.Extracted; + } + + /// + /// Determines the default extraction directory for the current CLI binary. + /// If CLI is at ~/.aspire/bin/aspire, returns ~/.aspire/ so layout discovery + /// finds components via the bin/ layout pattern. + /// + internal static string? GetDefaultExtractDir(string processPath) + { + var cliDir = Path.GetDirectoryName(processPath); + if (string.IsNullOrEmpty(cliDir)) + { + return null; + } + + return Path.GetDirectoryName(cliDir) ?? cliDir; + } + + /// + /// Removes well-known layout subdirectories before re-extraction. + /// Preserves the bin/ directory (which contains the CLI binary itself). + /// + internal static void CleanLayoutDirectories(string layoutPath) + { + foreach (var dir in s_layoutDirectories) + { + var fullPath = Path.Combine(layoutPath, dir); + if (Directory.Exists(fullPath)) + { + Directory.Delete(fullPath, recursive: true); + } + } + + // Remove version marker so it's rewritten after extraction + var markerPath = Path.Combine(layoutPath, VersionMarkerFileName); + if (File.Exists(markerPath)) + { + File.Delete(markerPath); + } + } + + /// + /// Gets the assembly informational version of the current CLI binary. + /// Used as the version marker to detect when re-extraction is needed. + /// + internal static string GetCurrentVersion() + { + return VersionHelper.GetDefaultTemplateVersion(); + } + + /// + /// Writes a version marker file to the extraction directory. + /// + internal static void WriteVersionMarker(string extractDir, string version) + { + var markerPath = Path.Combine(extractDir, VersionMarkerFileName); + File.WriteAllText(markerPath, version); + } + + /// + /// Reads the version string from a previously written marker file. + /// Returns null if the marker doesn't exist or is empty. + /// + internal static string? ReadVersionMarker(string extractDir) + { + var markerPath = Path.Combine(extractDir, VersionMarkerFileName); + if (!File.Exists(markerPath)) + { + return null; + } + + var content = File.ReadAllText(markerPath).Trim(); + return string.IsNullOrEmpty(content) ? null : content; + } + + /// + /// Extracts the embedded tar.gz payload to the specified directory using .NET TarReader. + /// + internal static async Task ExtractPayloadAsync(string destinationPath, CancellationToken cancellationToken) + { + Directory.CreateDirectory(destinationPath); + + using var payloadStream = OpenPayload() ?? throw new InvalidOperationException("No embedded bundle payload."); + await using var gzipStream = new GZipStream(payloadStream, CompressionMode.Decompress); + await using var tarReader = new TarReader(gzipStream); + + while (await tarReader.GetNextEntryAsync(cancellationToken: cancellationToken) is { } entry) + { + // Strip the top-level directory (equivalent to tar --strip-components=1) + var name = entry.Name; + var slashIndex = name.IndexOf('/'); + if (slashIndex < 0) + { + continue; // Top-level directory entry itself, skip + } + + var relativePath = name[(slashIndex + 1)..]; + if (string.IsNullOrEmpty(relativePath)) + { + continue; + } + + var fullPath = Path.GetFullPath(Path.Combine(destinationPath, relativePath)); + var normalizedDestination = Path.GetFullPath(destinationPath); + + // Guard against path traversal attacks (e.g., entries containing ".." segments) + if (!fullPath.StartsWith(normalizedDestination + Path.DirectorySeparatorChar, StringComparison.Ordinal) && + !fullPath.Equals(normalizedDestination, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Tar entry '{entry.Name}' would extract outside the destination directory."); + } + + switch (entry.EntryType) + { + case TarEntryType.Directory: + Directory.CreateDirectory(fullPath); + break; + + case TarEntryType.RegularFile: + var dir = Path.GetDirectoryName(fullPath); + if (dir is not null) + { + Directory.CreateDirectory(dir); + } + await entry.ExtractToFileAsync(fullPath, overwrite: true, cancellationToken); + + // Preserve Unix file permissions from tar entry (e.g., execute bit) + if (!OperatingSystem.IsWindows() && entry.Mode != default) + { + File.SetUnixFileMode(fullPath, (UnixFileMode)entry.Mode); + } + break; + + case TarEntryType.SymbolicLink: + if (string.IsNullOrEmpty(entry.LinkName)) + { + continue; + } + // Validate symlink target stays within the extraction directory + var linkTarget = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(fullPath)!, entry.LinkName)); + if (!linkTarget.StartsWith(normalizedDestination + Path.DirectorySeparatorChar, StringComparison.Ordinal) && + !linkTarget.Equals(normalizedDestination, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Symlink '{entry.Name}' targets '{entry.LinkName}' which resolves outside the destination directory."); + } + var linkDir = Path.GetDirectoryName(fullPath); + if (linkDir is not null) + { + Directory.CreateDirectory(linkDir); + } + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + } + File.CreateSymbolicLink(fullPath, entry.LinkName); + break; + } + } + } +} diff --git a/src/Aspire.Cli/Bundles/IBundleService.cs b/src/Aspire.Cli/Bundles/IBundleService.cs new file mode 100644 index 00000000000..a486ef788a1 --- /dev/null +++ b/src/Aspire.Cli/Bundles/IBundleService.cs @@ -0,0 +1,60 @@ +// 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.Layout; + +namespace Aspire.Cli.Bundles; + +/// +/// Manages extraction of the embedded bundle payload from self-extracting CLI binaries. +/// +internal interface IBundleService +{ + /// + /// Gets whether the current CLI binary contains an embedded bundle payload. + /// + bool IsBundle { get; } + + /// + /// Ensures the bundle is extracted for the current CLI binary if it contains an embedded payload. + /// No-ops if no payload is embedded, or if the layout is already extracted and up to date. + /// + /// The cancellation token. + Task EnsureExtractedAsync(CancellationToken cancellationToken = default); + + /// + /// Extracts the bundle payload to the specified directory. + /// + /// Directory to extract into. + /// If true, re-extract even if the version matches. + /// The cancellation token. + /// The result of the extraction attempt. + Task ExtractAsync(string destinationPath, bool force = false, CancellationToken cancellationToken = default); + + /// + /// Ensures the bundle is extracted and returns the discovered layout. + /// Combines and layout discovery into a single call + /// so callers cannot forget to extract before discovering the layout. + /// + /// The cancellation token. + /// The discovered layout, or if no layout is found. + Task EnsureExtractedAndGetLayoutAsync(CancellationToken cancellationToken = default); +} + +/// +/// Result of a bundle extraction attempt. +/// +internal enum BundleExtractResult +{ + /// No embedded payload found in the binary. + NoPayload, + + /// Layout already exists and version matches — extraction skipped. + AlreadyUpToDate, + + /// Extraction completed successfully. + Extracted, + + /// Extraction completed but layout validation failed. + ExtractionFailed +} diff --git a/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs index 3a78444d1c1..bf0469f8364 100644 --- a/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Text; using System.Text.Json; +using Aspire.Cli.Bundles; using Aspire.Cli.DotNet; using Aspire.Cli.Layout; using Microsoft.Extensions.Logging; @@ -14,13 +15,20 @@ namespace Aspire.Cli.Certificates; /// Certificate tool runner that uses the bundled dev-certs DLL with the bundled runtime. /// internal sealed class BundleCertificateToolRunner( - LayoutConfiguration layout, + IBundleService bundleService, ILogger logger) : ICertificateToolRunner { + private async Task GetLayoutAsync(CancellationToken cancellationToken) + { + return await bundleService.EnsureExtractedAndGetLayoutAsync(cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("Bundle layout not found after extraction."); + } + public async Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync( DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { + var layout = await GetLayoutAsync(cancellationToken); var muxerPath = layout.GetMuxerPath(); var devCertsPath = layout.GetDevCertsPath(); @@ -130,6 +138,7 @@ public async Task TrustHttpCertificateAsync( DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { + var layout = await GetLayoutAsync(cancellationToken); var muxerPath = layout.GetMuxerPath(); var devCertsPath = layout.GetDevCertsPath(); diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 64da8930f04..2246ed8a872 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -128,6 +128,7 @@ public RootCommand( TelemetryCommand telemetryCommand, DocsCommand docsCommand, SdkCommand sdkCommand, + SetupCommand setupCommand, ExtensionInternalCommand extensionInternalCommand, IFeatures featureFlags, IInteractionService interactionService) @@ -205,6 +206,7 @@ public RootCommand( Subcommands.Add(agentCommand); Subcommands.Add(telemetryCommand); Subcommands.Add(docsCommand); + Subcommands.Add(setupCommand); if (featureFlags.IsFeatureEnabled(KnownFeatures.ExecCommandEnabled, false)) { diff --git a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs index 95470a14386..dbec9c91fc9 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkDumpCommand.cs @@ -124,7 +124,7 @@ private async Task DumpCapabilitiesAsync( // TODO: Support bundle mode by using DLL references instead of project references. // In bundle mode, we'd need to add integration DLLs to the probing path rather than // using additionalProjectReferences. For now, SDK dump only works with .NET SDK. - var appHostServerProjectInterface = _appHostServerProjectFactory.Create(tempDir); + var appHostServerProjectInterface = await _appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken); if (appHostServerProjectInterface is not DotNetBasedAppHostServerProject appHostServerProject) { InteractionService.DisplayError("SDK dump is only available with .NET SDK installed."); diff --git a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs index 03332bbb49f..604d74536cf 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkGenerateCommand.cs @@ -123,7 +123,7 @@ private async Task GenerateSdkAsync( // TODO: Support bundle mode by using DLL references instead of project references. // In bundle mode, we'd need to add integration DLLs to the probing path rather than // using additionalProjectReferences. For now, SDK generation only works with .NET SDK. - var appHostServerProjectInterface = _appHostServerProjectFactory.Create(tempDir); + var appHostServerProjectInterface = await _appHostServerProjectFactory.CreateAsync(tempDir, cancellationToken); if (appHostServerProjectInterface is not DotNetBasedAppHostServerProject appHostServerProject) { InteractionService.DisplayError("SDK generation is only available with .NET SDK installed."); diff --git a/src/Aspire.Cli/Commands/SetupCommand.cs b/src/Aspire.Cli/Commands/SetupCommand.cs new file mode 100644 index 00000000000..f82e5d53093 --- /dev/null +++ b/src/Aspire.Cli/Commands/SetupCommand.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Aspire.Cli.Bundles; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Commands; + +/// +/// Extracts the embedded bundle payload from a self-extracting Aspire CLI binary. +/// +internal sealed class SetupCommand : BaseCommand +{ + private readonly IBundleService _bundleService; + + private static readonly Option s_installPathOption = new("--install-path") + { + Description = "Directory to extract the bundle into. Defaults to the parent of the CLI binary's directory. Non-default paths require ASPIRE_LAYOUT_PATH to be set for auto-discovery." + }; + + private static readonly Option s_forceOption = new("--force") + { + Description = "Force extraction even if the layout already exists." + }; + + public SetupCommand( + IBundleService bundleService, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + IInteractionService interactionService, + AspireCliTelemetry telemetry) + : base("setup", "Extract the embedded bundle to set up the Aspire CLI runtime.", features, updateNotifier, executionContext, interactionService, telemetry) + { + _bundleService = bundleService; + + Options.Add(s_installPathOption); + Options.Add(s_forceOption); + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var installPath = parseResult.GetValue(s_installPathOption); + var force = parseResult.GetValue(s_forceOption); + + var processPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(processPath)) + { + InteractionService.DisplayError("Could not determine the CLI executable path."); + return ExitCodeConstants.FailedToBuildArtifacts; + } + + // Determine extraction directory + if (string.IsNullOrEmpty(installPath)) + { + installPath = BundleService.GetDefaultExtractDir(processPath); + } + + if (string.IsNullOrEmpty(installPath)) + { + InteractionService.DisplayError("Could not determine the installation path."); + return ExitCodeConstants.FailedToBuildArtifacts; + } + + // Extract with spinner + BundleExtractResult result = BundleExtractResult.NoPayload; + var exitCode = await InteractionService.ShowStatusAsync( + ":package: Extracting Aspire bundle...", + async () => + { + result = await _bundleService.ExtractAsync(installPath, force, cancellationToken); + return ExitCodeConstants.Success; + }); + + switch (result) + { + case BundleExtractResult.NoPayload: + InteractionService.DisplayMessage(":information:", "This CLI binary does not contain an embedded bundle. No extraction needed."); + break; + + case BundleExtractResult.AlreadyUpToDate: + InteractionService.DisplayMessage(":white_check_mark:", "Bundle is already extracted and up to date. Use --force to re-extract."); + break; + + case BundleExtractResult.Extracted: + InteractionService.DisplayMessage(":white_check_mark:", $"Bundle extracted to {installPath}"); + break; + + case BundleExtractResult.ExtractionFailed: + InteractionService.DisplayError($"Bundle was extracted to {installPath} but layout validation failed."); + return ExitCodeConstants.FailedToBuildArtifacts; + } + + return exitCode; + } +} diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index d69e8841c92..7427721146e 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -3,14 +3,11 @@ using System.CommandLine; using System.Diagnostics; -using System.Formats.Tar; using System.Globalization; -using System.IO.Compression; using System.Runtime.InteropServices; using Aspire.Cli.Configuration; using Aspire.Cli.Exceptions; using Aspire.Cli.Interaction; -using Aspire.Cli.Layout; using Aspire.Cli.Packaging; using Aspire.Cli.Projects; using Aspire.Cli.Resources; @@ -28,8 +25,6 @@ internal sealed class UpdateCommand : BaseCommand private readonly IAppHostProjectFactory _projectFactory; private readonly ILogger _logger; private readonly ICliDownloader? _cliDownloader; - private readonly IBundleDownloader? _bundleDownloader; - private readonly ILayoutDiscovery _layoutDiscovery; private readonly ICliUpdateNotifier _updateNotifier; private readonly IFeatures _features; private readonly IConfigurationService _configurationService; @@ -51,8 +46,6 @@ public UpdateCommand( IAppHostProjectFactory projectFactory, ILogger logger, ICliDownloader? cliDownloader, - IBundleDownloader? bundleDownloader, - ILayoutDiscovery layoutDiscovery, IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, @@ -66,8 +59,6 @@ public UpdateCommand( _projectFactory = projectFactory; _logger = logger; _cliDownloader = cliDownloader; - _bundleDownloader = bundleDownloader; - _layoutDiscovery = layoutDiscovery; _updateNotifier = updateNotifier; _features = features; _configurationService = configurationService; @@ -128,22 +119,6 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return 0; } - // Check if we're running from a bundle layout - var layout = _layoutDiscovery.DiscoverLayout(); - if (layout is not null && _bundleDownloader is not null) - { - try - { - return await ExecuteBundleSelfUpdateAsync(layout, cancellationToken); - } - catch (OperationCanceledException) - { - InteractionService.DisplayCancellationMessage(); - return ExitCodeConstants.InvalidCommand; - } - } - - // Fall back to CLI-only update if (_cliDownloader is null) { InteractionService.DisplayError("CLI self-update is not available in this environment."); @@ -344,77 +319,6 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, Cancella } } - private async Task ExecuteBundleSelfUpdateAsync(LayoutConfiguration layout, CancellationToken cancellationToken) - { - if (_bundleDownloader is null) - { - InteractionService.DisplayError("Bundle update is not available in this environment."); - return ExitCodeConstants.InvalidCommand; - } - - var currentVersion = layout.Version ?? "unknown"; - var installPath = layout.LayoutPath; - - if (string.IsNullOrEmpty(installPath)) - { - InteractionService.DisplayError("Unable to determine bundle installation path."); - return ExitCodeConstants.InvalidCommand; - } - - InteractionService.DisplayMessage("package", $"Current bundle version: {currentVersion}"); - InteractionService.DisplayMessage("package", $"Bundle location: {installPath}"); - - // Check for updates - var latestVersion = await _bundleDownloader.GetLatestVersionAsync(cancellationToken); - if (string.IsNullOrEmpty(latestVersion)) - { - InteractionService.DisplayError("Unable to determine latest bundle version."); - return ExitCodeConstants.InvalidCommand; - } - - var isUpdateAvailable = await _bundleDownloader.IsUpdateAvailableAsync(currentVersion, cancellationToken); - if (!isUpdateAvailable) - { - InteractionService.DisplaySuccess($"You are already on the latest version ({currentVersion})."); - return 0; - } - - InteractionService.DisplayMessage("up_arrow", $"Updating to version: {latestVersion}"); - - try - { - // Download the bundle - var archivePath = await _bundleDownloader.DownloadLatestBundleAsync(cancellationToken); - - // Apply the update - var result = await _bundleDownloader.ApplyUpdateAsync(archivePath, installPath, cancellationToken); - - if (result.Success) - { - if (result.RestartRequired) - { - InteractionService.DisplayMessage("warning", "Update staged. Please restart to complete the update."); - if (!string.IsNullOrEmpty(result.PendingUpdateScript)) - { - InteractionService.DisplayMessage("information", $"Or run: {result.PendingUpdateScript}"); - } - } - return 0; - } - else - { - InteractionService.DisplayError($"Update failed: {result.ErrorMessage}"); - return ExitCodeConstants.InvalidCommand; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to update bundle"); - InteractionService.DisplayError($"Failed to update bundle: {ex.Message}"); - return ExitCodeConstants.InvalidCommand; - } - } - private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken cancellationToken) { // Install to the same directory as the current CLI executable @@ -438,7 +342,7 @@ private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken c { // Extract archive InteractionService.DisplayMessage("package", "Extracting new CLI..."); - await ExtractArchiveAsync(archivePath, tempExtractDir, cancellationToken); + await ArchiveHelper.ExtractAsync(archivePath, tempExtractDir, cancellationToken); // Find the aspire executable in the extracted files var newExePath = Path.Combine(tempExtractDir, exeName); @@ -485,6 +389,10 @@ private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken c // If we get here, the update was successful, clean up old backups CleanupOldBackupFiles(targetExePath); + // The new binary will extract its embedded bundle on first run via EnsureExtractedAsync. + // No proactive extraction needed — the payload is inside the new binary's embedded resources, + // which are only accessible when that binary is running. + // Display helpful message about PATH if (!IsInPath(installDir)) { @@ -537,24 +445,6 @@ private static bool IsInPath(string directory) : StringComparison.Ordinal)); } - private static async Task ExtractArchiveAsync(string archivePath, string destinationPath, CancellationToken cancellationToken) - { - if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) - { - ZipFile.ExtractToDirectory(archivePath, destinationPath, overwriteFiles: true); - } - else if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) - { - await using var fileStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read); - await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress); - await TarFile.ExtractToDirectoryAsync(gzipStream, destinationPath, overwriteFiles: true, cancellationToken); - } - else - { - throw new NotSupportedException($"Unsupported archive format: {archivePath}"); - } - } - private void SetExecutablePermission(string filePath) { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs index f868ba5fd78..ce2a5e61136 100644 --- a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Aspire.Cli.Bundles; using Aspire.Cli.Configuration; using Aspire.Cli.Layout; using Microsoft.Extensions.Logging; @@ -16,7 +17,7 @@ namespace Aspire.Cli.NuGet; /// internal sealed class BundleNuGetPackageCache : INuGetPackageCache { - private readonly ILayoutDiscovery _layoutDiscovery; + private readonly IBundleService _bundleService; private readonly ILogger _logger; private readonly IFeatures _features; @@ -27,11 +28,11 @@ internal sealed class BundleNuGetPackageCache : INuGetPackageCache }; public BundleNuGetPackageCache( - ILayoutDiscovery layoutDiscovery, + IBundleService bundleService, ILogger logger, IFeatures features) { - _layoutDiscovery = layoutDiscovery; + _bundleService = bundleService; _logger = logger; _features = features; } @@ -110,7 +111,8 @@ private async Task> SearchPackagesInternalAsync( FileInfo? nugetConfigFile, CancellationToken cancellationToken) { - var layout = _layoutDiscovery.DiscoverLayout(); + // Ensure the bundle is extracted and get the layout in a single call + var layout = await _bundleService.EnsureExtractedAndGetLayoutAsync(cancellationToken).ConfigureAwait(false); if (layout is null) { throw new InvalidOperationException("Bundle layout not found. Cannot perform NuGet search in bundle mode."); diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index bc023fcb88a..f58c684790f 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -12,6 +12,7 @@ using Aspire.Cli.Agents.OpenCode; using Aspire.Cli.Agents.VsCode; using Aspire.Cli.Backchannel; +using Aspire.Cli.Bundles; using Aspire.Cli.Caching; using Aspire.Cli.Certificates; using Aspire.Cli.Commands; @@ -221,17 +222,17 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTelemetryServices(); builder.Services.AddTransient(); - // Register certificate tool runner implementations - factory chooses based on layout availability + // Register certificate tool runner implementations - factory chooses based on embedded bundle builder.Services.AddSingleton(sp => { - var layoutDiscovery = sp.GetRequiredService(); - var layout = layoutDiscovery.DiscoverLayout(); var loggerFactory = sp.GetRequiredService(); + var bundleService = sp.GetRequiredService(); - // Use bundle runner if layout exists and has dev-certs tool - if (layout is not null && layout.GetDevCertsPath() is string devCertsPath && File.Exists(devCertsPath)) + if (bundleService.IsBundle) { - return new BundleCertificateToolRunner(layout, loggerFactory.CreateLogger()); + return new BundleCertificateToolRunner( + bundleService, + loggerFactory.CreateLogger()); } // Fall back to SDK-based runner @@ -243,16 +244,12 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddSingleton(); builder.Services.AddTransient(); - // Register both NuGetPackageCache implementations - factory chooses based on layout availability + // Register both NuGetPackageCache implementations - factory chooses based on embedded bundle builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => { - var layoutDiscovery = sp.GetRequiredService(); - var layout = layoutDiscovery.DiscoverLayout(); - - // Use bundle cache if layout exists and has NuGetHelper - if (layout is not null && layout.GetNuGetHelperPath() is string helperPath && File.Exists(helperPath)) + if (sp.GetRequiredService().IsBundle) { return sp.GetRequiredService(); } @@ -268,10 +265,10 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(_ => new FirstTimeUseNoticeSentinel(GetUsersAspirePath())); - builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddMemoryCache(); @@ -375,6 +372,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Projects/AppHostServerProject.cs b/src/Aspire.Cli/Projects/AppHostServerProject.cs index 21602eed2b0..df3784ea968 100644 --- a/src/Aspire.Cli/Projects/AppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/AppHostServerProject.cs @@ -3,9 +3,9 @@ using System.Security.Cryptography; using System.Text; +using Aspire.Cli.Bundles; using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; -using Aspire.Cli.Layout; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; using Microsoft.Extensions.Logging; @@ -17,7 +17,7 @@ namespace Aspire.Cli.Projects; /// internal interface IAppHostServerProjectFactory { - IAppHostServerProject Create(string appPath); + Task CreateAsync(string appPath, CancellationToken cancellationToken = default); } /// @@ -28,11 +28,11 @@ internal sealed class AppHostServerProjectFactory( IDotNetCliRunner dotNetCliRunner, IPackagingService packagingService, IConfigurationService configurationService, - ILayoutDiscovery layoutDiscovery, + IBundleService bundleService, BundleNuGetService bundleNuGetService, ILoggerFactory loggerFactory) : IAppHostServerProjectFactory { - public IAppHostServerProject Create(string appPath) + public async Task CreateAsync(string appPath, CancellationToken cancellationToken = default) { // Normalize the path var normalizedPath = Path.GetFullPath(appPath); @@ -71,8 +71,10 @@ public IAppHostServerProject Create(string appPath) loggerFactory.CreateLogger()); } - // Priority 2: Check if we have a bundle layout with a pre-built AppHost server - var layout = layoutDiscovery.DiscoverLayout(); + // Priority 2: Ensure bundle is extracted and check for layout + var layout = await bundleService.EnsureExtractedAndGetLayoutAsync(cancellationToken); + + // Priority 3: Check if we have a bundle layout with a pre-built AppHost server if (layout is not null && layout.GetAppHostServerPath() is string serverPath && File.Exists(serverPath)) { return new PrebuiltAppHostServer( @@ -86,9 +88,8 @@ public IAppHostServerProject Create(string appPath) } throw new InvalidOperationException( - "No Aspire AppHost server is available. Either set the ASPIRE_REPO_ROOT environment variable " + - "to the root of the Aspire repository for development, or ensure the Aspire CLI is installed " + - "with a valid bundle layout."); + "No Aspire AppHost server is available. Ensure the Aspire CLI is installed " + + "with a valid bundle layout, or reinstall using 'aspire setup --force'."); } /// diff --git a/src/Aspire.Cli/Projects/AppHostServerSession.cs b/src/Aspire.Cli/Projects/AppHostServerSession.cs index 35a9fb06ee6..133181f30e1 100644 --- a/src/Aspire.Cli/Projects/AppHostServerSession.cs +++ b/src/Aspire.Cli/Projects/AppHostServerSession.cs @@ -105,7 +105,7 @@ public async Task CreateAsync( bool debug, CancellationToken cancellationToken) { - var appHostServerProject = _projectFactory.Create(appHostPath); + var appHostServerProject = await _projectFactory.CreateAsync(appHostPath, cancellationToken); // Prepare the server (create files + build for dev mode, restore packages for prebuilt mode) var prepareResult = await appHostServerProject.PrepareAsync(sdkVersion, packages, cancellationToken); diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 87177f18b46..1ce14fd6e12 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -167,7 +167,7 @@ private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, Cancellatio var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); var packages = await GetAllPackagesAsync(config, cancellationToken); - var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName); + var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); if (!buildSuccess) @@ -274,7 +274,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); var packages = await GetAllPackagesAsync(config, cancellationToken); - var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName); + var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); var buildResult = await _interactionService.ShowStatusAsync( ":gear: Preparing Aspire server...", @@ -561,7 +561,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); var packages = await GetAllPackagesAsync(config, cancellationToken); - var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName); + var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); // Prepare the AppHost server (build for dev mode, restore for prebuilt) var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); @@ -945,7 +945,7 @@ public async Task CheckAndHandleRunningInstanceAsync(File return RunningInstanceResult.NoRunningInstance; // No directory, nothing to check } - var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName); + var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); var genericAppHostPath = appHostServerProject.GetInstanceIdentifier(); // Find matching sockets for this AppHost diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index 4120a219f1a..4dfcb1fe116 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -69,7 +69,7 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Cancellat packages.Add((codeGenPackage, config.SdkVersion!)); } - var appHostServerProject = _appHostServerProjectFactory.Create(directory.FullName); + var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); var prepareResult = await _interactionService.ShowStatusAsync( ":gear: Preparing Aspire server...", diff --git a/src/Aspire.Cli/Utils/ArchiveHelper.cs b/src/Aspire.Cli/Utils/ArchiveHelper.cs new file mode 100644 index 00000000000..1eadb100b1a --- /dev/null +++ b/src/Aspire.Cli/Utils/ArchiveHelper.cs @@ -0,0 +1,142 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Formats.Tar; +using System.IO.Compression; + +namespace Aspire.Cli.Utils; + +/// +/// Shared utilities for extracting archive files (.zip and .tar.gz). +/// +internal static class ArchiveHelper +{ + /// + /// Extracts an archive to the specified directory, supporting .zip and .tar.gz formats. + /// .zip is used for Windows CLI downloads; .tar.gz for Unix. + /// + internal static async Task ExtractAsync(string archivePath, string destinationPath, CancellationToken cancellationToken) + { + Directory.CreateDirectory(destinationPath); + + if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + ExtractZipSafe(archivePath, destinationPath); + } + else if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) + { + await ExtractTarGzSafeAsync(archivePath, destinationPath, cancellationToken).ConfigureAwait(false); + } + else + { + throw new NotSupportedException($"Unsupported archive format: {archivePath}"); + } + } + + private static void ExtractZipSafe(string archivePath, string destinationPath) + { + var normalizedDestination = Path.GetFullPath(destinationPath); + + using var archive = ZipFile.OpenRead(archivePath); + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.FullName)) + { + continue; + } + + var fullPath = Path.GetFullPath(Path.Combine(destinationPath, entry.FullName)); + if (!fullPath.StartsWith(normalizedDestination + Path.DirectorySeparatorChar, StringComparison.Ordinal) && + !fullPath.Equals(normalizedDestination, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Zip entry '{entry.FullName}' would extract outside the destination directory."); + } + + if (entry.FullName.EndsWith('/') || entry.FullName.EndsWith('\\')) + { + Directory.CreateDirectory(fullPath); + } + else + { + var dir = Path.GetDirectoryName(fullPath); + if (dir is not null) + { + Directory.CreateDirectory(dir); + } + entry.ExtractToFile(fullPath, overwrite: true); + } + } + } + + private static async Task ExtractTarGzSafeAsync(string archivePath, string destinationPath, CancellationToken cancellationToken) + { + var normalizedDestination = Path.GetFullPath(destinationPath); + + await using var fileStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read); + await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress); + await using var tarReader = new TarReader(gzipStream); + + while (await tarReader.GetNextEntryAsync(cancellationToken: cancellationToken).ConfigureAwait(false) is { } entry) + { + if (string.IsNullOrEmpty(entry.Name)) + { + continue; + } + + var fullPath = Path.GetFullPath(Path.Combine(destinationPath, entry.Name)); + + // Guard against path traversal attacks (e.g., entries containing ".." segments) + if (!fullPath.StartsWith(normalizedDestination + Path.DirectorySeparatorChar, StringComparison.Ordinal) && + !fullPath.Equals(normalizedDestination, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Tar entry '{entry.Name}' would extract outside the destination directory."); + } + + switch (entry.EntryType) + { + case TarEntryType.Directory: + Directory.CreateDirectory(fullPath); + break; + + case TarEntryType.RegularFile: + var dir = Path.GetDirectoryName(fullPath); + if (dir is not null) + { + Directory.CreateDirectory(dir); + } + await entry.ExtractToFileAsync(fullPath, overwrite: true, cancellationToken).ConfigureAwait(false); + + // Preserve Unix file permissions from tar entry + if (!OperatingSystem.IsWindows() && entry.Mode != default) + { + File.SetUnixFileMode(fullPath, (UnixFileMode)entry.Mode); + } + break; + + case TarEntryType.SymbolicLink: + if (string.IsNullOrEmpty(entry.LinkName)) + { + continue; + } + // Validate symlink target stays within the extraction directory + var linkTarget = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(fullPath)!, entry.LinkName)); + if (!linkTarget.StartsWith(normalizedDestination + Path.DirectorySeparatorChar, StringComparison.Ordinal) && + !linkTarget.Equals(normalizedDestination, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Symlink '{entry.Name}' targets '{entry.LinkName}' which resolves outside the destination directory."); + } + var linkDir = Path.GetDirectoryName(fullPath); + if (linkDir is not null) + { + Directory.CreateDirectory(linkDir); + } + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + } + File.CreateSymbolicLink(fullPath, entry.LinkName); + break; + } + } + } +} diff --git a/src/Aspire.Cli/Utils/BundleDownloader.cs b/src/Aspire.Cli/Utils/BundleDownloader.cs deleted file mode 100644 index 6d40ca8f5ee..00000000000 --- a/src/Aspire.Cli/Utils/BundleDownloader.cs +++ /dev/null @@ -1,705 +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.Formats.Tar; -using System.IO.Compression; -using System.Runtime.InteropServices; -using System.Security.Cryptography; -using System.Text.Json; -using Aspire.Cli.Interaction; -using Microsoft.Extensions.Logging; - -namespace Aspire.Cli.Utils; - -/// -/// Handles downloading and updating the Aspire Bundle. -/// -internal interface IBundleDownloader -{ - /// - /// Downloads the latest bundle version. - /// - /// Cancellation token. - /// Path to the downloaded bundle archive. - Task DownloadLatestBundleAsync(CancellationToken cancellationToken); - - /// - /// Gets the latest available bundle version. - /// - /// Cancellation token. - /// The latest version string. - Task GetLatestVersionAsync(CancellationToken cancellationToken); - - /// - /// Gets whether a bundle update is available. - /// - /// Current bundle version. - /// Cancellation token. - /// True if an update is available. - Task IsUpdateAvailableAsync(string currentVersion, CancellationToken cancellationToken); - - /// - /// Applies a downloaded bundle update to the specified installation directory. - /// Handles file locking by staging updates and using atomic swaps where possible. - /// - /// Path to the downloaded bundle archive. - /// Target installation directory. - /// Cancellation token. - /// Result indicating success or if a restart is required. - Task ApplyUpdateAsync(string archivePath, string installPath, CancellationToken cancellationToken); -} - -/// -/// Result of applying a bundle update. -/// -internal sealed class BundleUpdateResult -{ - /// - /// Whether the update was successfully applied. - /// - public bool Success { get; init; } - - /// - /// Whether a restart is required to complete the update. - /// - public bool RestartRequired { get; init; } - - /// - /// Path to a script that should be run to complete the update (Windows only). - /// - public string? PendingUpdateScript { get; init; } - - /// - /// Error message if the update failed. - /// - public string? ErrorMessage { get; init; } - - /// - /// The new version that was installed. - /// - public string? InstalledVersion { get; init; } - - public static BundleUpdateResult Succeeded(string version) => new() - { - Success = true, - InstalledVersion = version - }; - - public static BundleUpdateResult RequiresRestart(string scriptPath) => new() - { - Success = true, - RestartRequired = true, - PendingUpdateScript = scriptPath - }; - - public static BundleUpdateResult Failed(string error) => new() - { - Success = false, - ErrorMessage = error - }; -} - -internal sealed class BundleDownloader : IBundleDownloader -{ - private const string GitHubRepo = "dotnet/aspire"; - private const string GitHubReleasesApi = $"https://api.github.com/repos/{GitHubRepo}/releases"; - private const int DownloadTimeoutSeconds = 600; - private const int ApiTimeoutSeconds = 30; - private const string PendingUpdateDir = ".pending-update"; - private const string BackupDir = ".backup"; - - private static readonly HttpClient s_httpClient = new() - { - DefaultRequestHeaders = - { - { "User-Agent", "aspire-bundle-updater/1.0" }, - { "Accept", "application/vnd.github+json" } - } - }; - - private readonly ILogger _logger; - private readonly IInteractionService _interactionService; - - public BundleDownloader( - ILogger logger, - IInteractionService interactionService) - { - _logger = logger; - _interactionService = interactionService; - } - - public async Task GetLatestVersionAsync(CancellationToken cancellationToken) - { - try - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(ApiTimeoutSeconds)); - - var response = await s_httpClient.GetStringAsync($"{GitHubReleasesApi}/latest", cts.Token); - using var doc = JsonDocument.Parse(response); - - if (doc.RootElement.TryGetProperty("tag_name", out var tagName)) - { - var version = tagName.GetString(); - // Remove 'v' prefix if present - if (version?.StartsWith("v", StringComparison.OrdinalIgnoreCase) == true) - { - version = version[1..]; - } - return version; - } - - return null; - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to get latest version from GitHub"); - return null; - } - } - - public async Task IsUpdateAvailableAsync(string currentVersion, CancellationToken cancellationToken) - { - var latestVersion = await GetLatestVersionAsync(cancellationToken); - if (string.IsNullOrEmpty(latestVersion)) - { - return false; - } - - // Try to parse as semver and compare - if (Version.TryParse(NormalizeVersion(currentVersion), out var current) && - Version.TryParse(NormalizeVersion(latestVersion), out var latest)) - { - return latest > current; - } - - // Fall back to string comparison - return !string.Equals(currentVersion, latestVersion, StringComparison.OrdinalIgnoreCase); - } - - public async Task DownloadLatestBundleAsync(CancellationToken cancellationToken) - { - var version = await GetLatestVersionAsync(cancellationToken); - if (string.IsNullOrEmpty(version)) - { - throw new InvalidOperationException("Failed to determine latest bundle version"); - } - - var rid = GetRuntimeIdentifier(); - var extension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "zip" : "tar.gz"; - var filename = $"aspire-bundle-{version}-{rid}.{extension}"; - var downloadUrl = $"https://github.com/{GitHubRepo}/releases/download/v{version}/{filename}"; - - _logger.LogDebug("Downloading bundle from {Url}", downloadUrl); - - // Create temp directory - var tempDir = Directory.CreateTempSubdirectory("aspire-bundle-download").FullName; - var archivePath = Path.Combine(tempDir, filename); - - try - { - await _interactionService.ShowStatusAsync($"Downloading Aspire Bundle v{version}...", async () => - { - const int maxRetries = 3; - for (var attempt = 1; attempt <= maxRetries; attempt++) - { - try - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(DownloadTimeoutSeconds)); - - using var response = await s_httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cts.Token); - response.EnsureSuccessStatusCode(); - - await using var contentStream = await response.Content.ReadAsStreamAsync(cts.Token); - await using var fileStream = new FileStream(archivePath, FileMode.Create, FileAccess.Write, FileShare.None); - await contentStream.CopyToAsync(fileStream, cts.Token); - - return 0; - } - catch (HttpRequestException) when (attempt < maxRetries) - { - _logger.LogDebug("Download attempt {Attempt} failed, retrying...", attempt); - await Task.Delay(TimeSpan.FromSeconds(attempt * 2), cancellationToken); - } - } - - return 0; - }); - - // Try to download and validate checksum - var checksumUrl = $"{downloadUrl}.sha512"; - - try - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(30)); - - var checksumContent = await s_httpClient.GetStringAsync(checksumUrl, cts.Token); - await ValidateChecksumAsync(archivePath, checksumContent, cancellationToken); - _interactionService.DisplayMessage("check_mark", "Checksum validated"); - } - catch (HttpRequestException) - { - // Checksum file may not exist for all releases - _logger.LogDebug("Checksum file not available, skipping validation"); - } - - return archivePath; - } - catch - { - // Clean up temp directory on failure - CleanupDirectory(tempDir); - throw; - } - } - - public async Task ApplyUpdateAsync(string archivePath, string installPath, CancellationToken cancellationToken) - { - var stagingPath = Path.Combine(installPath, PendingUpdateDir); - var backupPath = Path.Combine(installPath, BackupDir); - - try - { - // Step 1: Extract to staging directory - _interactionService.DisplayMessage("package", "Extracting update..."); - CleanupDirectory(stagingPath); - Directory.CreateDirectory(stagingPath); - - await ExtractArchiveAsync(archivePath, stagingPath, cancellationToken); - - // Read version from extracted layout.json - var version = await ReadVersionFromLayoutAsync(stagingPath); - - // Step 2: Try atomic swap approach first - if (await TryAtomicSwapAsync(installPath, stagingPath, backupPath)) - { - _interactionService.DisplaySuccess($"Updated to version {version ?? "unknown"}"); - CleanupDirectory(backupPath); - return BundleUpdateResult.Succeeded(version ?? "unknown"); - } - - // Step 3: If atomic swap fails (files locked), try incremental update - var lockedFiles = await TryIncrementalUpdateAsync(installPath, stagingPath); - - if (lockedFiles.Count == 0) - { - _interactionService.DisplaySuccess($"Updated to version {version ?? "unknown"}"); - CleanupDirectory(stagingPath); - return BundleUpdateResult.Succeeded(version ?? "unknown"); - } - - // Step 4: If files are locked (Windows), create a pending update script - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var scriptPath = CreatePendingUpdateScript(installPath, stagingPath, lockedFiles); - _interactionService.DisplayMessage("warning", "Some files are in use. Update will complete on next restart."); - _interactionService.DisplayMessage("information", $"Or run: {scriptPath}"); - return BundleUpdateResult.RequiresRestart(scriptPath); - } - - // On Unix, locked files are less common but handle gracefully - _interactionService.DisplayMessage("warning", $"Could not update {lockedFiles.Count} locked files. Please close Aspire and try again."); - return BundleUpdateResult.Failed($"Files locked: {string.Join(", ", lockedFiles.Take(5))}"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to apply bundle update"); - return BundleUpdateResult.Failed(ex.Message); - } - } - - private Task TryAtomicSwapAsync(string installPath, string stagingPath, string backupPath) - { - // On Unix, we can try to do an atomic directory swap using rename - // On Windows, this typically fails if any files are in use - - try - { - CleanupDirectory(backupPath); - - // Get list of items to move (excluding staging and backup dirs) - var itemsToBackup = Directory.EnumerateFileSystemEntries(installPath) - .Where(p => !p.EndsWith(PendingUpdateDir) && !p.EndsWith(BackupDir)) - .ToList(); - - if (itemsToBackup.Count == 0) - { - // Fresh install, just move staging contents - foreach (var item in Directory.EnumerateFileSystemEntries(stagingPath)) - { - var destPath = Path.Combine(installPath, Path.GetFileName(item)); - if (Directory.Exists(item)) - { - Directory.Move(item, destPath); - } - else - { - File.Move(item, destPath); - } - } - return Task.FromResult(true); - } - - // Create backup directory - Directory.CreateDirectory(backupPath); - - // Try to move all existing items to backup - foreach (var item in itemsToBackup) - { - var destPath = Path.Combine(backupPath, Path.GetFileName(item)); - if (Directory.Exists(item)) - { - Directory.Move(item, destPath); - } - else - { - File.Move(item, destPath); - } - } - - // Move staged items to install location - foreach (var item in Directory.EnumerateFileSystemEntries(stagingPath)) - { - var destPath = Path.Combine(installPath, Path.GetFileName(item)); - if (Directory.Exists(item)) - { - Directory.Move(item, destPath); - } - else - { - File.Move(item, destPath); - } - } - - return Task.FromResult(true); - } - catch (IOException ex) when (IsFileLockedException(ex)) - { - _logger.LogDebug(ex, "Atomic swap failed due to locked files, falling back to incremental update"); - - // Restore from backup if partial swap occurred - if (Directory.Exists(backupPath)) - { - foreach (var item in Directory.EnumerateFileSystemEntries(backupPath)) - { - var destPath = Path.Combine(installPath, Path.GetFileName(item)); - if (!File.Exists(destPath) && !Directory.Exists(destPath)) - { - try - { - if (Directory.Exists(item)) - { - Directory.Move(item, destPath); - } - else - { - File.Move(item, destPath); - } - } - catch - { - // Best effort restore - } - } - } - } - - return Task.FromResult(false); - } - catch (UnauthorizedAccessException ex) - { - _logger.LogDebug(ex, "Atomic swap failed due to permission issues"); - return Task.FromResult(false); - } - } - - private Task> TryIncrementalUpdateAsync(string installPath, string stagingPath) - { - var lockedFiles = new List(); - - foreach (var sourceFile in Directory.EnumerateFiles(stagingPath, "*", SearchOption.AllDirectories)) - { - var relativePath = Path.GetRelativePath(stagingPath, sourceFile); - - // Validate no path traversal sequences to prevent writing outside install directory - if (relativePath.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(relativePath)) - { - _logger.LogWarning("Skipping file with suspicious path: {Path}", relativePath); - continue; - } - - var destFile = Path.Combine(installPath, relativePath); - - // Additional safety: ensure destination is within install path - var normalizedDest = Path.GetFullPath(destFile); - var normalizedInstall = Path.GetFullPath(installPath); - if (!normalizedDest.StartsWith(normalizedInstall, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogWarning("Skipping file that would escape install directory: {Path}", relativePath); - continue; - } - - // Ensure destination directory exists - var destDir = Path.GetDirectoryName(destFile); - if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) - { - Directory.CreateDirectory(destDir); - } - - // Try to update the file with retry logic - var updated = FileAccessRetrier.TryFileOperation(() => - { - if (File.Exists(destFile)) - { - // Try rename-move-delete pattern (works even for running executables on Unix) - // Use GUID for unique backup filename to avoid collisions - var backupFile = $"{destFile}.old.{Guid.NewGuid():N}"; - FileAccessRetrier.RetryOnFileAccessFailure(() => - { - // Handle case where backup file already exists (shouldn't happen with GUID, but be safe) - if (File.Exists(backupFile)) - { - FileAccessRetrier.SafeDeleteFile(backupFile); - } - File.Move(destFile, backupFile); - }, maxRetries: 3); - - try - { - File.Move(sourceFile, destFile); - // Clean up backup - FileAccessRetrier.SafeDeleteFile(backupFile); - } - catch - { - // Restore backup on failure - if (File.Exists(backupFile) && !File.Exists(destFile)) - { - File.Move(backupFile, destFile); - } - throw; - } - } - else - { - File.Move(sourceFile, destFile); - } - }); - - if (updated) - { - // Set executable permissions on Unix - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - SetExecutablePermissionIfNeeded(destFile); - } - } - else - { - _logger.LogDebug("File locked, will update later: {File}", relativePath); - lockedFiles.Add(relativePath); - } - } - - return Task.FromResult(lockedFiles); - } - - private static string CreatePendingUpdateScript(string installPath, string stagingPath, List lockedFiles) - { - var scriptPath = Path.Combine(installPath, "complete-update.cmd"); - - var script = $""" - @echo off - echo Completing Aspire Bundle update... - echo Waiting for locked files to be released... - - REM Wait a moment for processes to exit - timeout /t 2 /nobreak > nul - - REM Try to copy locked files - """; - - foreach (var file in lockedFiles) - { - // Skip files with path traversal sequences (silently - this is a static method) - if (file.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(file)) - { - continue; - } - - var sourceFile = Path.Combine(stagingPath, file); - var destFile = Path.Combine(installPath, file); - script += $""" - - copy /Y "{sourceFile}" "{destFile}" > nul 2>&1 - if errorlevel 1 ( - echo Failed to update: {file} - ) else ( - echo Updated: {file} - ) - """; - } - - script += $""" - - REM Cleanup staging directory - rmdir /S /Q "{stagingPath}" > nul 2>&1 - - echo. - echo Update complete. You can delete this script. - del "%~f0" - """; - - File.WriteAllText(scriptPath, script); - return scriptPath; - } - - private static bool IsFileLockedException(IOException ex) - { - // Check for common file-in-use error codes - const int ERROR_SHARING_VIOLATION = 32; - const int ERROR_LOCK_VIOLATION = 33; - - var hResult = ex.HResult & 0xFFFF; - return hResult == ERROR_SHARING_VIOLATION || hResult == ERROR_LOCK_VIOLATION; - } - - private async Task ReadVersionFromLayoutAsync(string path) - { - var layoutJsonPath = Path.Combine(path, "layout.json"); - if (!File.Exists(layoutJsonPath)) - { - return null; - } - - try - { - var json = await File.ReadAllTextAsync(layoutJsonPath); - using var doc = JsonDocument.Parse(json); - if (doc.RootElement.TryGetProperty("version", out var versionProp)) - { - return versionProp.GetString(); - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to read version from layout.json"); - } - - return null; - } - - private static async Task ExtractArchiveAsync(string archivePath, string destinationPath, CancellationToken cancellationToken) - { - if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) - { - ZipFile.ExtractToDirectory(archivePath, destinationPath, overwriteFiles: true); - } - else if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) - { - await using var fileStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read); - await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress); - await TarFile.ExtractToDirectoryAsync(gzipStream, destinationPath, overwriteFiles: true, cancellationToken); - } - else - { - throw new NotSupportedException($"Unsupported archive format: {archivePath}"); - } - } - - private static string GetRuntimeIdentifier() - { - var os = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "win" - : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx" - : "linux"; - - var arch = RuntimeInformation.OSArchitecture switch - { - Architecture.X64 => "x64", - Architecture.Arm64 => "arm64", - _ => throw new PlatformNotSupportedException($"Unsupported architecture: {RuntimeInformation.OSArchitecture}") - }; - - return $"{os}-{arch}"; - } - - private static string NormalizeVersion(string version) - { - // Remove prerelease suffixes for comparison - var dashIndex = version.IndexOf('-'); - if (dashIndex > 0) - { - version = version[..dashIndex]; - } - - // Ensure we have at least major.minor.patch - var parts = version.Split('.'); - return parts.Length switch - { - 1 => $"{parts[0]}.0.0", - 2 => $"{parts[0]}.{parts[1]}.0", - _ => version - }; - } - - private async Task ValidateChecksumAsync(string archivePath, string checksumContent, CancellationToken cancellationToken) - { - var expectedChecksum = checksumContent - .Split(' ', StringSplitOptions.RemoveEmptyEntries)[0] - .Trim() - .ToUpperInvariant(); - - await using var stream = File.OpenRead(archivePath); - var actualHash = await SHA512.HashDataAsync(stream, cancellationToken); - var actualChecksum = Convert.ToHexString(actualHash); - - if (!string.Equals(expectedChecksum, actualChecksum, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Checksum validation failed. Expected: {expectedChecksum}, Actual: {actualChecksum}"); - } - - _logger.LogDebug("Checksum validation passed"); - } - - private void SetExecutablePermissionIfNeeded(string filePath) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return; - } - - // Set executable bit for known executables - var fileName = Path.GetFileName(filePath); - if (fileName == "aspire" || fileName == "dotnet" || fileName.EndsWith(".sh")) - { - try - { - var mode = File.GetUnixFileMode(filePath); - mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; - File.SetUnixFileMode(filePath, mode); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to set executable permission on {FilePath}", filePath); - } - } - } - - private void CleanupDirectory(string path) - { - try - { - if (Directory.Exists(path)) - { - Directory.Delete(path, recursive: true); - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to cleanup directory {Path}", path); - } - } -} diff --git a/src/Aspire.Cli/Utils/FileAccessRetrier.cs b/src/Aspire.Cli/Utils/FileAccessRetrier.cs deleted file mode 100644 index bbd37012012..00000000000 --- a/src/Aspire.Cli/Utils/FileAccessRetrier.cs +++ /dev/null @@ -1,200 +0,0 @@ -// 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.Utils; - -/// -/// Provides retry logic for file operations that may fail due to transient file locks. -/// Based on patterns from dotnet/sdk FileAccessRetrier. -/// -internal static class FileAccessRetrier -{ - /// - /// Retries an action on file access failure (IOException, UnauthorizedAccessException). - /// - /// The action to perform. - /// Maximum number of retry attempts. - /// Initial delay in milliseconds (doubles with each retry). - public static void RetryOnFileAccessFailure(Action action, int maxRetries = 10, int initialDelayMs = 10) - { - var remainingRetries = maxRetries; - var delayMs = initialDelayMs; - - while (true) - { - try - { - action(); - return; - } - catch (IOException) when (remainingRetries > 0) - { - Thread.Sleep(delayMs); - delayMs *= 2; - remainingRetries--; - } - catch (UnauthorizedAccessException) when (remainingRetries > 0) - { - Thread.Sleep(delayMs); - delayMs *= 2; - remainingRetries--; - } - } - } - - /// - /// Retries an async action on file access failure. - /// - /// The async action to perform. - /// Maximum number of retry attempts. - /// Initial delay in milliseconds (doubles with each retry). - /// Cancellation token. - public static async Task RetryOnFileAccessFailureAsync( - Func action, - int maxRetries = 10, - int initialDelayMs = 10, - CancellationToken cancellationToken = default) - { - var remainingRetries = maxRetries; - var delayMs = initialDelayMs; - - while (true) - { - try - { - await action(); - return; - } - catch (IOException) when (remainingRetries > 0) - { - await Task.Delay(delayMs, cancellationToken); - delayMs *= 2; - remainingRetries--; - } - catch (UnauthorizedAccessException) when (remainingRetries > 0) - { - await Task.Delay(delayMs, cancellationToken); - delayMs *= 2; - remainingRetries--; - } - } - } - - /// - /// Safely moves a file, handling the case where the destination exists. - /// On failure, retries with exponential backoff. - /// - /// Source file path. - /// Destination file path. - /// Whether to overwrite the destination if it exists. - public static void SafeMoveFile(string sourcePath, string destPath, bool overwrite = true) - { - RetryOnFileAccessFailure(() => - { - if (overwrite && File.Exists(destPath)) - { - File.Delete(destPath); - } - File.Move(sourcePath, destPath); - }); - } - - /// - /// Safely copies a file with retry on access failure. - /// - /// Source file path. - /// Destination file path. - /// Whether to overwrite the destination if it exists. - public static void SafeCopyFile(string sourcePath, string destPath, bool overwrite = true) - { - RetryOnFileAccessFailure(() => - { - File.Copy(sourcePath, destPath, overwrite); - }); - } - - /// - /// Safely deletes a file with retry on access failure. - /// - /// File path to delete. - public static void SafeDeleteFile(string path) - { - if (!File.Exists(path)) - { - return; - } - - RetryOnFileAccessFailure(() => - { - File.Delete(path); - }); - } - - /// - /// Safely deletes a directory with retry on access failure. - /// - /// Directory path to delete. - /// Whether to delete recursively. - public static void SafeDeleteDirectory(string path, bool recursive = true) - { - if (!Directory.Exists(path)) - { - return; - } - - RetryOnFileAccessFailure(() => - { - Directory.Delete(path, recursive); - }); - } - - /// - /// Safely moves a directory with retry on access failure. - /// - /// Source directory path. - /// Destination directory path. - public static void SafeMoveDirectory(string sourcePath, string destPath) - { - RetryOnFileAccessFailure(() => - { - Directory.Move(sourcePath, destPath); - }); - } - - /// - /// Tries to perform a file operation, returning false if it fails due to file locking. - /// - /// The action to attempt. - /// True if the action succeeded, false if it failed due to file locking. - public static bool TryFileOperation(Action action) - { - try - { - action(); - return true; - } - catch (IOException ex) when (IsFileLockedException(ex)) - { - return false; - } - catch (UnauthorizedAccessException) - { - return false; - } - } - - /// - /// Checks if an IOException is due to file locking. - /// - /// The exception to check. - /// True if the exception indicates the file is locked. - public static bool IsFileLockedException(IOException ex) - { - // Windows error codes for file locking - const int ERROR_SHARING_VIOLATION = 32; - const int ERROR_LOCK_VIOLATION = 33; - - var hResult = ex.HResult & 0xFFFF; - return hResult == ERROR_SHARING_VIOLATION || hResult == ERROR_LOCK_VIOLATION; - } -} diff --git a/src/Aspire.Cli/Utils/FileLock.cs b/src/Aspire.Cli/Utils/FileLock.cs new file mode 100644 index 00000000000..99613f99ac5 --- /dev/null +++ b/src/Aspire.Cli/Utils/FileLock.cs @@ -0,0 +1,119 @@ +// 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.Utils; + +/// +/// A cross-process file-based lock that is safe to use with async/await. +/// +/// +/// +/// This is used instead of because: +/// +/// +/// Named mutexes with Global\ prefix behave differently on Linux vs Windows. +/// has thread affinity — ReleaseMutex must be +/// called from the same thread that called WaitOne, which is incompatible with +/// async/await where continuations may run on a different thread. +/// +/// +/// A opened with provides exclusive +/// access that works cross-platform and has no thread affinity. The lock is released when +/// the stream is disposed. +/// +/// +/// Based on the locking pattern from NuGet.Common.ConcurrencyUtilities. +/// +/// +internal sealed class FileLock : IDisposable +{ + // Short delay keeps latency low under contention without busy-spinning. + // Matches the delay used by NuGet.Common.ConcurrencyUtilities. + private static readonly TimeSpan s_defaultRetryDelay = TimeSpan.FromMilliseconds(10); + + private readonly FileStream _stream; + + private FileLock(FileStream stream) + { + _stream = stream; + } + + /// + /// Default maximum time to wait for the lock before giving up. + /// + private static readonly TimeSpan s_defaultTimeout = TimeSpan.FromMinutes(5); + + /// + /// Acquires an exclusive file lock, retrying on contention. + /// Uses between retries to avoid blocking the thread pool. + /// + /// The full path of the lock file. + /// Token to cancel the wait for the lock. + /// Maximum time to wait for the lock. Defaults to 5 minutes. + /// A that releases the lock when disposed. + /// Thrown if the lock cannot be acquired within the timeout period. + public static async Task AcquireAsync(string lockPath, CancellationToken cancellationToken = default, TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? s_defaultTimeout; + var deadline = DateTime.UtcNow + effectiveTimeout; + + var directory = Path.GetDirectoryName(lockPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + return new FileLock(CreateLockStream(lockPath)); + } + catch (IOException) + { + // Sharing violation — another process holds the lock. On Windows the + // FileStream constructor throws immediately; on Unix it may also throw + // if the file is exclusively locked. Wait and retry. + } + catch (UnauthorizedAccessException) + { + // Can occur transiently when the lock file is being deleted + // (DeleteOnClose) by the process that just released the lock, + // or if an admin/antivirus has the file temporarily locked. + } + + if (DateTime.UtcNow >= deadline) + { + throw new TimeoutException($"Failed to acquire file lock '{lockPath}' within {effectiveTimeout.TotalSeconds:F0} seconds."); + } + + await Task.Delay(s_defaultRetryDelay, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Releases the OS-level file lock and deletes the lock file (). + /// + public void Dispose() + { + _stream.Dispose(); + } + + /// + /// Opens the lock file with exclusive access. Only one process can hold the + /// handle at a time. ensures the lock + /// file is cleaned up automatically when the handle is released. + /// + private static FileStream CreateLockStream(string lockPath) + { + return new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.DeleteOnClose); + } +} diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 90e0bf54cc5..6f20bb6a086 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -932,16 +932,17 @@ public async ValueTask DisposeAsync() /// private static bool IsSingleFileExecutable(string path) { - // Single-file apps are executables without a corresponding DLL - var extension = Path.GetExtension(path); - - // Must be an exe (Windows) or no extension (Unix) - if (!extension.Equals(".exe", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(extension)) + // Single-file apps are executables without a corresponding DLL. + // On Windows the file ends with .exe; on Unix there is no reliable + // extension (e.g. "Aspire.Dashboard" has a dot but is still an executable). + // The definitive check is: executable exists on disk and there is no + // matching .dll next to it. + + if (string.Equals(".dll", Path.GetExtension(path), StringComparison.OrdinalIgnoreCase)) { return false; } - - // The executable itself must exist to be considered a single-file exe + if (!File.Exists(path)) { return false; @@ -951,23 +952,23 @@ private static bool IsSingleFileExecutable(string path) if (!OperatingSystem.IsWindows()) { var fileInfo = new FileInfo(path); - // Check if file has any execute permission (owner, group, or other) var mode = fileInfo.UnixFileMode; if ((mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) == 0) { return false; } } - - // Check if there's a corresponding DLL + + // Check if there's a corresponding DLL — strip .exe on Windows, + // but on Unix the filename may contain dots (e.g. "Aspire.Dashboard"), + // so always derive the DLL name by appending .dll to the full filename. var directory = Path.GetDirectoryName(path)!; var fileName = Path.GetFileName(path); var baseName = fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) - ? fileName.Substring(0, fileName.Length - 4) + ? fileName[..^4] : fileName; var dllPath = Path.Combine(directory, $"{baseName}.dll"); - - // If no DLL exists alongside the executable, it's a single-file executable + return !File.Exists(dllPath); } } diff --git a/tests/Aspire.Cli.Tests/BundleServiceTests.cs b/tests/Aspire.Cli.Tests/BundleServiceTests.cs new file mode 100644 index 00000000000..9d8f5f015f5 --- /dev/null +++ b/tests/Aspire.Cli.Tests/BundleServiceTests.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. + +using Aspire.Cli.Bundles; + +namespace Aspire.Cli.Tests; + +public class BundleServiceTests +{ + [Fact] + public void IsBundle_ReturnsFalse_WhenNoEmbeddedResource() + { + // Test assembly has no embedded bundle.tar.gz resource — verify via OpenPayload + Assert.Null(BundleService.OpenPayload()); + } + + [Fact] + public void OpenPayload_ReturnsNull_WhenNoEmbeddedResource() + { + Assert.Null(BundleService.OpenPayload()); + } + + [Fact] + public void VersionMarker_WriteAndRead_Roundtrips() + { + var tempDir = Directory.CreateTempSubdirectory("aspire-test"); + try + { + BundleService.WriteVersionMarker(tempDir.FullName, "13.2.0-dev"); + + var readVersion = BundleService.ReadVersionMarker(tempDir.FullName); + Assert.Equal("13.2.0-dev", readVersion); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void VersionMarker_ReturnsNull_WhenMissing() + { + var tempDir = Directory.CreateTempSubdirectory("aspire-test"); + try + { + var readVersion = BundleService.ReadVersionMarker(tempDir.FullName); + Assert.Null(readVersion); + } + finally + { + tempDir.Delete(recursive: true); + } + } + + [Fact] + public void GetDefaultExtractDir_ReturnsParentOfParent() + { + if (OperatingSystem.IsWindows()) + { + var result = BundleService.GetDefaultExtractDir(@"C:\Users\test\.aspire\bin\aspire.exe"); + Assert.Equal(@"C:\Users\test\.aspire", result); + } + else + { + var result = BundleService.GetDefaultExtractDir("/home/test/.aspire/bin/aspire"); + Assert.Equal("/home/test/.aspire", result); + } + } + + [Fact] + public void GetCurrentVersion_ReturnsNonNull() + { + var version = BundleService.GetCurrentVersion(); + Assert.NotNull(version); + Assert.NotEqual("unknown", version); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 1289030cc5c..cde2b95b41e 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -4,6 +4,7 @@ using System.Text; using Aspire.Cli.Agents; using Aspire.Cli.Backchannel; +using Aspire.Cli.Bundles; using Aspire.Cli.Certificates; using Aspire.Cli.Commands; using Aspire.Cli.Commands.Sdk; @@ -129,7 +130,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work // Bundle layout services - return null/no-op implementations to trigger SDK mode fallback // This ensures backward compatibility: no layout found = use legacy SDK mode services.AddSingleton(options.LayoutDiscoveryFactory); - services.AddSingleton(options.BundleDownloaderFactory); + services.AddSingleton(); services.AddSingleton(); // AppHost project handlers - must match Program.cs registration pattern @@ -178,6 +179,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -498,13 +500,6 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser // Layout discovery - returns null by default (no bundle layout), causing SDK mode fallback public Func LayoutDiscoveryFactory { get; set; } = _ => new NullLayoutDiscovery(); - // Bundle downloader - returns a no-op implementation that indicates no bundle mode - // This causes UpdateCommand to fall back to CLI-only update or show dotnet tool instructions - public Func BundleDownloaderFactory { get; set; } = (IServiceProvider serviceProvider) => - { - return new NullBundleDownloader(); - }; - public Func McpServerTransportFactory { get; set; } = (IServiceProvider serviceProvider) => { var loggerFactory = serviceProvider.GetService(); @@ -541,22 +536,20 @@ internal sealed class NullLayoutDiscovery : ILayoutDiscovery } /// -/// A no-op bundle downloader that always returns "no updates available". -/// Used in tests to ensure backward compatibility - no layout = SDK mode. +/// A no-op bundle service that never extracts anything. +/// Used in tests to ensure SDK mode fallback. /// -internal sealed class NullBundleDownloader : IBundleDownloader +internal sealed class NullBundleService : IBundleService { - public Task DownloadLatestBundleAsync(CancellationToken cancellationToken) - => throw new NotSupportedException("Bundle downloads not available in test environment"); + public bool IsBundle => false; - public Task GetLatestVersionAsync(CancellationToken cancellationToken) - => Task.FromResult(null); + public Task EnsureExtractedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task IsUpdateAvailableAsync(string currentVersion, CancellationToken cancellationToken) - => Task.FromResult(false); + public Task ExtractAsync(string destinationPath, bool force = false, CancellationToken cancellationToken = default) + => Task.FromResult(BundleExtractResult.NoPayload); - public Task ApplyUpdateAsync(string archivePath, string installPath, CancellationToken cancellationToken) - => Task.FromResult(BundleUpdateResult.Failed("Bundle updates not available in test environment")); + public Task EnsureExtractedAndGetLayoutAsync(CancellationToken cancellationToken = default) + => Task.FromResult(null); } internal sealed class TestOutputTextWriter : TextWriter diff --git a/tools/CreateLayout/Program.cs b/tools/CreateLayout/Program.cs index 7a898abfd12..dbddc286b66 100644 --- a/tools/CreateLayout/Program.cs +++ b/tools/CreateLayout/Program.cs @@ -118,6 +118,7 @@ public static async Task Main(string[] args) return await rootCommand.Parse(args).InvokeAsync().ConfigureAwait(false); } + } /// @@ -165,8 +166,8 @@ public async Task BuildAsync() } Directory.CreateDirectory(_outputPath); - // Copy components - await CopyCliAsync().ConfigureAwait(false); + // Copy components (CLI is not included - the native AOT binary IS the CLI, + // and the bundle payload is embedded as a resource inside it) await CopyRuntimeAsync().ConfigureAwait(false); await CopyNuGetHelperAsync().ConfigureAwait(false); await CopyAppHostServerAsync().ConfigureAwait(false); @@ -179,36 +180,6 @@ public async Task BuildAsync() Log("Layout build complete!"); } - private async Task CopyCliAsync() - { - Log("Copying CLI..."); - - var cliPublishPath = FindPublishPath("Aspire.Cli"); - if (cliPublishPath is null) - { - throw new InvalidOperationException("CLI publish output not found. Run 'dotnet publish' on Aspire.Cli first."); - } - - var cliExe = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase) ? "aspire.exe" : "aspire"; - var sourceExe = Path.Combine(cliPublishPath, cliExe); - - if (!File.Exists(sourceExe)) - { - throw new InvalidOperationException($"CLI executable not found at {sourceExe}"); - } - - var destExe = Path.Combine(_outputPath, cliExe); - File.Copy(sourceExe, destExe, overwrite: true); - - // Make executable on Unix - if (!_rid.StartsWith("win", StringComparison.OrdinalIgnoreCase)) - { - await SetExecutableAsync(destExe).ConfigureAwait(false); - } - - Log($" Copied {cliExe}"); - } - private async Task CopyRuntimeAsync() { Log("Copying .NET runtime..."); @@ -593,35 +564,40 @@ private Task CopyDcpAsync() return Task.CompletedTask; } - public async Task CreateArchiveAsync() + public async Task CreateArchiveAsync() { var archiveName = $"aspire-{_version}-{_rid}"; - var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase); - var archiveExt = isWindows ? ".zip" : ".tar.gz"; - var archivePath = Path.Combine(Path.GetDirectoryName(_outputPath)!, archiveName + archiveExt); + var archivePath = Path.Combine(Path.GetDirectoryName(_outputPath)!, archiveName + ".tar.gz"); Log($"Creating archive: {archivePath}"); - if (isWindows) + if (OperatingSystem.IsWindows()) { - // Use PowerShell for zip - var psi = new ProcessStartInfo - { - FileName = "powershell", - Arguments = $"-NoProfile -Command \"Compress-Archive -Path '{_outputPath}\\*' -DestinationPath '{archivePath}' -Force\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using var process = Process.Start(psi); - if (process is not null) + // Use .NET TarWriter + GZip on Windows (no system tar available) + var fileStream = File.Create(archivePath); + await using (fileStream.ConfigureAwait(false)) { - await process.WaitForExitAsync().ConfigureAwait(false); - if (process.ExitCode != 0) + var gzipStream = new GZipStream(fileStream, CompressionLevel.Optimal); + await using (gzipStream.ConfigureAwait(false)) { - var stderr = await process.StandardError.ReadToEndAsync().ConfigureAwait(false); - throw new InvalidOperationException($"Failed to create archive (exit code {process.ExitCode}): {stderr}"); + var tarWriter = new System.Formats.Tar.TarWriter(gzipStream, leaveOpen: true); + await using (tarWriter.ConfigureAwait(false)) + { + var topLevelDir = Path.GetFileName(_outputPath); + foreach (var filePath in Directory.EnumerateFiles(_outputPath, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(Path.GetDirectoryName(_outputPath)!, filePath).Replace('\\', '/'); + var dataStream = File.OpenRead(filePath); + await using (dataStream.ConfigureAwait(false)) + { + var entry = new System.Formats.Tar.PaxTarEntry(System.Formats.Tar.TarEntryType.RegularFile, relativePath) + { + DataStream = dataStream + }; + await tarWriter.WriteEntryAsync(entry).ConfigureAwait(false); + } + } + } } } } @@ -636,6 +612,8 @@ public async Task CreateArchiveAsync() RedirectStandardError = true, UseShellExecute = false }; + // Prevent macOS from including resource forks/extended attributes in the archive + psi.Environment["COPYFILE_DISABLE"] = "1"; using var process = Process.Start(psi); if (process is not null) @@ -650,6 +628,7 @@ public async Task CreateArchiveAsync() } Log($"Archive created: {archivePath}"); + return archivePath; } private string? FindPublishPath(string projectName) From 0a17ede208527b45aa93180fa8c5028eba222dec Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 11 Feb 2026 10:37:40 +1100 Subject: [PATCH 075/256] Add VNet deployment E2E tests for Storage Blob, Key Vault, and SQL Server (#14417) * Add VNet deployment E2E tests for Storage Blob, Key Vault, and SQL Server Add P0 VNet deployment end-to-end tests covering two verification levels for each of the three highest-priority Azure resources: L1 (Infrastructure): Deploy VNet + subnets + ACA delegation + PE via aspire init, then verify VNet, private endpoints, and DNS zones via az CLI commands. L2+L3 (Connectivity): Deploy a starter app with VNet + PE + Aspire client library wired into the service project. Curl the app endpoint to prove the ACA environment runs inside the VNet and the client can connect to the PE-protected resource (public access is disabled). Tests added: - VnetStorageBlobInfraDeploymentTests (L1) - VnetStorageBlobConnectivityDeploymentTests (L2+L3) - VnetKeyVaultInfraDeploymentTests (L1) - VnetKeyVaultConnectivityDeploymentTests (L2+L3) - VnetSqlServerInfraDeploymentTests (L1) - VnetSqlServerConnectivityDeploymentTests (L2+L3) All tests use the experimental ASPIREAZURE003 VNet APIs: AddAzureVirtualNetwork, AddSubnet, WithDelegatedSubnet, AddPrivateEndpoint * Fix aspire add prompt handling: exact package names skip integration selection aspire add Aspire.Hosting.Azure.AppContainers is an exact package name, so the CLI goes directly to version selection without showing the integration selection prompt. Remove the WaitUntil for the integration selection prompt that was causing all 6 VNet tests to hang and timeout. * Fix client package installation: add --prerelease for dev builds dotnet add package without --prerelease only resolves stable versions. In CI, the Aspire client packages are 13.2.0-dev (prerelease) in the local hive, causing NU1103 errors. Adding --prerelease resolves the correct dev version. * Disable SQL Server connectivity test due to PE + deployment script issue (#14421) The Azure SQL Server deployment scripts use AzurePowerShellScript to connect to the SQL Server for role provisioning. When private endpoints disable public network access, the script container (running outside the VNet) cannot reach the server, causing DeploymentFailed errors. --------- Co-authored-by: Mitch Denny --- ...VnetKeyVaultConnectivityDeploymentTests.cs | 366 +++++++++++++++++ .../VnetKeyVaultInfraDeploymentTests.cs | 273 +++++++++++++ ...netSqlServerConnectivityDeploymentTests.cs | 368 +++++++++++++++++ .../VnetSqlServerInfraDeploymentTests.cs | 274 +++++++++++++ ...tStorageBlobConnectivityDeploymentTests.cs | 374 ++++++++++++++++++ .../VnetStorageBlobInfraDeploymentTests.cs | 275 +++++++++++++ 6 files changed, 1930 insertions(+) create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs new file mode 100644 index 00000000000..4971f8bf592 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs @@ -0,0 +1,366 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// L2+L3 connectivity verification test for Azure Key Vault with VNet and Private Endpoint. +/// Deploys a starter app with VNet + PE + Aspire Key Vault client, then curls the app to prove PE connectivity. +/// +public sealed class VnetKeyVaultConnectivityDeploymentTests(ITestOutputHelper output) +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); + + [Fact] + public async Task DeployStarterTemplateWithKeyVaultPrivateEndpoint() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployStarterTemplateWithKeyVaultPrivateEndpointCore(cancellationToken); + } + + private async Task DeployStarterTemplateWithKeyVaultPrivateEndpointCore(CancellationToken cancellationToken) + { + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithKeyVaultPrivateEndpoint)); + var startTime = DateTime.UtcNow; + var deploymentUrls = new Dictionary(); + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-kv-l23"); + var projectName = "VnetKvApp"; + + output.WriteLine($"Test: {nameof(DeployStarterTemplateWithKeyVaultPrivateEndpoint)}"); + output.WriteLine($"Project Name: {projectName}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find("Enter the output path:"); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find("Do you want to create a test project?"); + + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create starter project using aspire new + output.WriteLine("Step 3: Creating starter project..."); + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Type(projectName) + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 4: Navigate to project directory + sequenceBuilder + .Type($"cd {projectName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5a: Add Aspire.Hosting.Azure.AppContainers + output.WriteLine("Step 5a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5b: Add Aspire.Hosting.Azure.Network + output.WriteLine("Step 5b: Adding Azure Network hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Network") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5c: Add Aspire.Hosting.Azure.KeyVault + output.WriteLine("Step 5c: Adding Azure Key Vault hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.KeyVault") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 6: Add Aspire client package to the Web project + output.WriteLine("Step 6: Adding Key Vault client package to Web project..."); + sequenceBuilder + .Type($"dotnet add {projectName}.Web package Aspire.Azure.Security.KeyVault --prerelease") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(120)); + + // Step 7: Modify AppHost.cs to add VNet + PE + WithReference + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost"); + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + + output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + content = content.Replace( + "var builder = DistributedApplication.CreateBuilder(args);", + """ +var builder = DistributedApplication.CreateBuilder(args); + +#pragma warning disable ASPIREAZURE003 + +// VNet with delegated subnet for ACA and PE subnet +var vnet = builder.AddAzureVirtualNetwork("vnet"); +var acaSubnet = vnet.AddSubnet("aca-subnet", "10.0.0.0/23"); +var peSubnet = vnet.AddSubnet("pe-subnet", "10.0.2.0/24"); + +builder.AddAzureContainerAppEnvironment("env") + .WithDelegatedSubnet(acaSubnet); + +// Key Vault with Private Endpoint +var kv = builder.AddAzureKeyVault("kv"); +peSubnet.AddPrivateEndpoint(kv); + +#pragma warning restore ASPIREAZURE003 +"""); + + content = content.Replace( + ".WithExternalHttpEndpoints()", + ".WithExternalHttpEndpoints()\n .WithReference(kv)"); + + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified AppHost.cs with VNet + Key Vault PE + WithReference"); + output.WriteLine($"New content:\n{content}"); + }); + + // Step 8: Modify Web project Program.cs to register Key Vault client + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var webProgramPath = Path.Combine(projectDir, $"{projectName}.Web", "Program.cs"); + + output.WriteLine($"Looking for Web Program.cs at: {webProgramPath}"); + + var content = File.ReadAllText(webProgramPath); + + content = content.Replace( + "builder.AddServiceDefaults();", + """ +builder.AddServiceDefaults(); +builder.AddAzureKeyVaultClient("kv"); +"""); + + File.WriteAllText(webProgramPath, content); + + output.WriteLine($"Modified Web Program.cs to add Key Vault client registration"); + }); + + // Step 9: Navigate to AppHost project directory + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 10: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 11: Deploy to Azure + output.WriteLine("Step 11: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 12: Verify PE infrastructure + output.WriteLine("Step 12: Verifying PE infrastructure..."); + sequenceBuilder + .Type($"az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " + + $"az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 13: Verify deployed endpoints with retry + output.WriteLine("Step 13: Verifying deployed endpoints..."); + sequenceBuilder + .Type($"RG_NAME=\"{resourceGroupName}\" && " + + "urls=$(az containerapp list -g \"$RG_NAME\" --query \"[].properties.configuration.ingress.fqdn\" -o tsv 2>/dev/null | grep -v '\\.internal\\.') && " + + "if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " + + "failed=0 && " + + "for url in $urls; do " + + "echo \"Checking https://$url...\"; " + + "success=0; " + + "for i in $(seq 1 18); do " + + "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 10 2>/dev/null); " + + "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " + + "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " + + "done; " + + "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " + + "done && " + + "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 14: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployStarterTemplateWithKeyVaultPrivateEndpoint), + resourceGroupName, + deploymentUrls, + duration); + + output.WriteLine("✅ Test passed!"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployStarterTemplateWithKeyVaultPrivateEndpoint), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName, output); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + } + } + + private static void TriggerCleanupResourceGroup(string resourceGroupName, ITestOutputHelper output) + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs new file mode 100644 index 00000000000..6f85e18d287 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs @@ -0,0 +1,273 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// L1 infrastructure verification test for Azure Key Vault with VNet and Private Endpoint. +/// Deploys VNet + subnets + ACA delegation + Key Vault with PE, then verifies infrastructure via az CLI. +/// +public sealed class VnetKeyVaultInfraDeploymentTests(ITestOutputHelper output) +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); + + [Fact] + public async Task DeployVnetKeyVaultInfrastructure() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployVnetKeyVaultInfrastructureCore(cancellationToken); + } + + private async Task DeployVnetKeyVaultInfrastructureCore(CancellationToken cancellationToken) + { + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployVnetKeyVaultInfrastructure)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-kv-l1"); + + output.WriteLine($"Test: {nameof(DeployVnetKeyVaultInfrastructure)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + .Wait(TimeSpan.FromSeconds(5)) + .Enter() + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4a: Add Aspire.Hosting.Azure.AppContainers + output.WriteLine("Step 4a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4b: Add Aspire.Hosting.Azure.Network + output.WriteLine("Step 4b: Adding Azure Network hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Network") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4c: Add Aspire.Hosting.Azure.KeyVault + output.WriteLine("Step 4c: Adding Azure Key Vault hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.KeyVault") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add VNet + PE infrastructure + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +#pragma warning disable ASPIREAZURE003 + +// VNet with delegated subnet for ACA and PE subnet +var vnet = builder.AddAzureVirtualNetwork("vnet"); +var acaSubnet = vnet.AddSubnet("aca-subnet", "10.0.0.0/23"); +var peSubnet = vnet.AddSubnet("pe-subnet", "10.0.2.0/24"); + +_ = builder.AddAzureContainerAppEnvironment("env") + .WithDelegatedSubnet(acaSubnet); + +// Key Vault with Private Endpoint +var kv = builder.AddAzureKeyVault("kv"); +peSubnet.AddPrivateEndpoint(kv); + +#pragma warning restore ASPIREAZURE003 + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs with VNet + Key Vault PE infrastructure"); + output.WriteLine($"New content:\n{content}"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(25)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify VNet infrastructure + output.WriteLine("Step 8: Verifying VNet infrastructure..."); + sequenceBuilder + .Type($"az network vnet list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv | head -5 && " + + $"echo \"---PE---\" && az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " + + $"echo \"---DNS---\" && az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployVnetKeyVaultInfrastructure), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployVnetKeyVaultInfrastructure), + resourceGroupName, + ex.Message); + + throw; + } + finally + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs new file mode 100644 index 00000000000..9616dc53e25 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs @@ -0,0 +1,368 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// L2+L3 connectivity verification test for Azure SQL Server with VNet and Private Endpoint. +/// Deploys a starter app with VNet + PE + Aspire SQL client, then curls the app to prove PE connectivity. +/// +public sealed class VnetSqlServerConnectivityDeploymentTests(ITestOutputHelper output) +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); + + [Fact] + [ActiveIssue("https://github.com/dotnet/aspire/issues/14421")] + public async Task DeployStarterTemplateWithSqlServerPrivateEndpoint() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployStarterTemplateWithSqlServerPrivateEndpointCore(cancellationToken); + } + + private async Task DeployStarterTemplateWithSqlServerPrivateEndpointCore(CancellationToken cancellationToken) + { + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithSqlServerPrivateEndpoint)); + var startTime = DateTime.UtcNow; + var deploymentUrls = new Dictionary(); + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-sql-l23"); + var projectName = "VnetSqlApp"; + + output.WriteLine($"Test: {nameof(DeployStarterTemplateWithSqlServerPrivateEndpoint)}"); + output.WriteLine($"Project Name: {projectName}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find("Enter the output path:"); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find("Do you want to create a test project?"); + + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create starter project using aspire new + output.WriteLine("Step 3: Creating starter project..."); + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Type(projectName) + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 4: Navigate to project directory + sequenceBuilder + .Type($"cd {projectName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5a: Add Aspire.Hosting.Azure.AppContainers + output.WriteLine("Step 5a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5b: Add Aspire.Hosting.Azure.Network + output.WriteLine("Step 5b: Adding Azure Network hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Network") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5c: Add Aspire.Hosting.Azure.Sql + output.WriteLine("Step 5c: Adding Azure SQL hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Sql") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 6: Add Aspire client package to the Web project + output.WriteLine("Step 6: Adding SQL client package to Web project..."); + sequenceBuilder + .Type($"dotnet add {projectName}.Web package Aspire.Microsoft.Data.SqlClient --prerelease") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(120)); + + // Step 7: Modify AppHost.cs to add VNet + PE + WithReference + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost"); + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + + output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + content = content.Replace( + "var builder = DistributedApplication.CreateBuilder(args);", + """ +var builder = DistributedApplication.CreateBuilder(args); + +#pragma warning disable ASPIREAZURE003 + +// VNet with delegated subnet for ACA and PE subnet +var vnet = builder.AddAzureVirtualNetwork("vnet"); +var acaSubnet = vnet.AddSubnet("aca-subnet", "10.0.0.0/23"); +var peSubnet = vnet.AddSubnet("pe-subnet", "10.0.2.0/24"); + +builder.AddAzureContainerAppEnvironment("env") + .WithDelegatedSubnet(acaSubnet); + +// SQL Server with Private Endpoint +var sql = builder.AddAzureSqlServer("sql"); +var db = sql.AddDatabase("db"); +peSubnet.AddPrivateEndpoint(sql); + +#pragma warning restore ASPIREAZURE003 +"""); + + content = content.Replace( + ".WithExternalHttpEndpoints()", + ".WithExternalHttpEndpoints()\n .WithReference(db)"); + + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified AppHost.cs with VNet + SQL Server PE + WithReference"); + output.WriteLine($"New content:\n{content}"); + }); + + // Step 8: Modify Web project Program.cs to register SQL client + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var webProgramPath = Path.Combine(projectDir, $"{projectName}.Web", "Program.cs"); + + output.WriteLine($"Looking for Web Program.cs at: {webProgramPath}"); + + var content = File.ReadAllText(webProgramPath); + + content = content.Replace( + "builder.AddServiceDefaults();", + """ +builder.AddServiceDefaults(); +builder.AddSqlServerClient("db"); +"""); + + File.WriteAllText(webProgramPath, content); + + output.WriteLine($"Modified Web Program.cs to add SQL client registration"); + }); + + // Step 9: Navigate to AppHost project directory + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 10: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 11: Deploy to Azure + output.WriteLine("Step 11: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 12: Verify PE infrastructure + output.WriteLine("Step 12: Verifying PE infrastructure..."); + sequenceBuilder + .Type($"az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " + + $"az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 13: Verify deployed endpoints with retry + output.WriteLine("Step 13: Verifying deployed endpoints..."); + sequenceBuilder + .Type($"RG_NAME=\"{resourceGroupName}\" && " + + "urls=$(az containerapp list -g \"$RG_NAME\" --query \"[].properties.configuration.ingress.fqdn\" -o tsv 2>/dev/null | grep -v '\\.internal\\.') && " + + "if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " + + "failed=0 && " + + "for url in $urls; do " + + "echo \"Checking https://$url...\"; " + + "success=0; " + + "for i in $(seq 1 18); do " + + "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 10 2>/dev/null); " + + "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " + + "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " + + "done; " + + "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " + + "done && " + + "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 14: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployStarterTemplateWithSqlServerPrivateEndpoint), + resourceGroupName, + deploymentUrls, + duration); + + output.WriteLine("✅ Test passed!"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployStarterTemplateWithSqlServerPrivateEndpoint), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName, output); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + } + } + + private static void TriggerCleanupResourceGroup(string resourceGroupName, ITestOutputHelper output) + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs new file mode 100644 index 00000000000..9f15d2288e1 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs @@ -0,0 +1,274 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// L1 infrastructure verification test for Azure SQL Server with VNet and Private Endpoint. +/// Deploys VNet + subnets + ACA delegation + SQL Server with PE, then verifies infrastructure via az CLI. +/// +public sealed class VnetSqlServerInfraDeploymentTests(ITestOutputHelper output) +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); + + [Fact] + public async Task DeployVnetSqlServerInfrastructure() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployVnetSqlServerInfrastructureCore(cancellationToken); + } + + private async Task DeployVnetSqlServerInfrastructureCore(CancellationToken cancellationToken) + { + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployVnetSqlServerInfrastructure)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-sql-l1"); + + output.WriteLine($"Test: {nameof(DeployVnetSqlServerInfrastructure)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + .Wait(TimeSpan.FromSeconds(5)) + .Enter() + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4a: Add Aspire.Hosting.Azure.AppContainers + output.WriteLine("Step 4a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4b: Add Aspire.Hosting.Azure.Network + output.WriteLine("Step 4b: Adding Azure Network hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Network") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4c: Add Aspire.Hosting.Azure.Sql + output.WriteLine("Step 4c: Adding Azure SQL hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Sql") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add VNet + PE infrastructure + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +#pragma warning disable ASPIREAZURE003 + +// VNet with delegated subnet for ACA and PE subnet +var vnet = builder.AddAzureVirtualNetwork("vnet"); +var acaSubnet = vnet.AddSubnet("aca-subnet", "10.0.0.0/23"); +var peSubnet = vnet.AddSubnet("pe-subnet", "10.0.2.0/24"); + +_ = builder.AddAzureContainerAppEnvironment("env") + .WithDelegatedSubnet(acaSubnet); + +// SQL Server with Private Endpoint +var sql = builder.AddAzureSqlServer("sql"); +var db = sql.AddDatabase("db"); +peSubnet.AddPrivateEndpoint(sql); + +#pragma warning restore ASPIREAZURE003 + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs with VNet + SQL Server PE infrastructure"); + output.WriteLine($"New content:\n{content}"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(25)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify VNet infrastructure + output.WriteLine("Step 8: Verifying VNet infrastructure..."); + sequenceBuilder + .Type($"az network vnet list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv | head -5 && " + + $"echo \"---PE---\" && az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " + + $"echo \"---DNS---\" && az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployVnetSqlServerInfrastructure), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployVnetSqlServerInfrastructure), + resourceGroupName, + ex.Message); + + throw; + } + finally + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs new file mode 100644 index 00000000000..f06017ebb95 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs @@ -0,0 +1,374 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// L2+L3 connectivity verification test for Azure Storage Blob with VNet and Private Endpoint. +/// Deploys a starter app with VNet + PE + Aspire blob client, then curls the app to prove PE connectivity. +/// +public sealed class VnetStorageBlobConnectivityDeploymentTests(ITestOutputHelper output) +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); + + [Fact] + public async Task DeployStarterTemplateWithStorageBlobPrivateEndpoint() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployStarterTemplateWithStorageBlobPrivateEndpointCore(cancellationToken); + } + + private async Task DeployStarterTemplateWithStorageBlobPrivateEndpointCore(CancellationToken cancellationToken) + { + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithStorageBlobPrivateEndpoint)); + var startTime = DateTime.UtcNow; + var deploymentUrls = new Dictionary(); + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-blob-l23"); + var projectName = "VnetBlobApp"; + + output.WriteLine($"Test: {nameof(DeployStarterTemplateWithStorageBlobPrivateEndpoint)}"); + output.WriteLine($"Project Name: {projectName}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + // Pattern searchers for aspire new interactive prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find("Enter the output path:"); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find("Do you want to create a test project?"); + + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create starter project using aspire new + output.WriteLine("Step 3: Creating starter project..."); + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select Starter App + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Type(projectName) + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Accept default output path + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // No for localhost URLs + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .Enter() // No for Redis + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // No for test project + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 4: Navigate to project directory + output.WriteLine("Step 4: Navigating to project directory..."); + sequenceBuilder + .Type($"cd {projectName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5a: Add Aspire.Hosting.Azure.AppContainers package + output.WriteLine("Step 5a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5b: Add Aspire.Hosting.Azure.Network package + output.WriteLine("Step 5b: Adding Azure Network hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Network") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5c: Add Aspire.Hosting.Azure.Storage package + output.WriteLine("Step 5c: Adding Azure Storage hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Storage") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 6: Add Aspire client package to the Web project + output.WriteLine("Step 6: Adding blob client package to Web project..."); + sequenceBuilder + .Type($"dotnet add {projectName}.Web package Aspire.Azure.Storage.Blobs --prerelease") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(120)); + + // Step 7: Modify AppHost.cs to add VNet + PE + WithReference + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost"); + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + + output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + // Insert VNet + PE code after the builder creation + content = content.Replace( + "var builder = DistributedApplication.CreateBuilder(args);", + """ +var builder = DistributedApplication.CreateBuilder(args); + +#pragma warning disable ASPIREAZURE003 + +// VNet with delegated subnet for ACA and PE subnet for storage +var vnet = builder.AddAzureVirtualNetwork("vnet"); +var acaSubnet = vnet.AddSubnet("aca-subnet", "10.0.0.0/23"); +var peSubnet = vnet.AddSubnet("pe-subnet", "10.0.2.0/24"); + +builder.AddAzureContainerAppEnvironment("env") + .WithDelegatedSubnet(acaSubnet); + +// Storage with Private Endpoint +var storage = builder.AddAzureStorage("storage"); +var blobs = storage.AddBlobs("blobs"); +peSubnet.AddPrivateEndpoint(blobs); + +#pragma warning restore ASPIREAZURE003 +"""); + + // Add .WithReference(blobs) to the webfrontend chain + content = content.Replace( + ".WithExternalHttpEndpoints()", + ".WithExternalHttpEndpoints()\n .WithReference(blobs)"); + + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified AppHost.cs with VNet + Storage PE + WithReference"); + output.WriteLine($"New content:\n{content}"); + }); + + // Step 8: Modify Web project Program.cs to register blob client + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var webProgramPath = Path.Combine(projectDir, $"{projectName}.Web", "Program.cs"); + + output.WriteLine($"Looking for Web Program.cs at: {webProgramPath}"); + + var content = File.ReadAllText(webProgramPath); + + // Add blob client registration after AddServiceDefaults + content = content.Replace( + "builder.AddServiceDefaults();", + """ +builder.AddServiceDefaults(); +builder.AddAzureBlobServiceClient("blobs"); +"""); + + File.WriteAllText(webProgramPath, content); + + output.WriteLine($"Modified Web Program.cs to add blob client registration"); + }); + + // Step 9: Navigate to AppHost project directory + output.WriteLine("Step 9: Navigating to AppHost directory..."); + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 10: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 11: Deploy to Azure + output.WriteLine("Step 11: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 12: Verify PE infrastructure + output.WriteLine("Step 12: Verifying PE infrastructure..."); + sequenceBuilder + .Type($"az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " + + $"az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 13: Verify deployed endpoints with retry + output.WriteLine("Step 13: Verifying deployed endpoints..."); + sequenceBuilder + .Type($"RG_NAME=\"{resourceGroupName}\" && " + + "urls=$(az containerapp list -g \"$RG_NAME\" --query \"[].properties.configuration.ingress.fqdn\" -o tsv 2>/dev/null | grep -v '\\.internal\\.') && " + + "if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " + + "failed=0 && " + + "for url in $urls; do " + + "echo \"Checking https://$url...\"; " + + "success=0; " + + "for i in $(seq 1 18); do " + + "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 10 2>/dev/null); " + + "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " + + "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " + + "done; " + + "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " + + "done && " + + "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 14: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployStarterTemplateWithStorageBlobPrivateEndpoint), + resourceGroupName, + deploymentUrls, + duration); + + output.WriteLine("✅ Test passed!"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployStarterTemplateWithStorageBlobPrivateEndpoint), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName, output); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + } + } + + private static void TriggerCleanupResourceGroup(string resourceGroupName, ITestOutputHelper output) + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs new file mode 100644 index 00000000000..07346fde16d --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs @@ -0,0 +1,275 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// L1 infrastructure verification test for Azure Storage Blob with VNet and Private Endpoint. +/// Deploys VNet + subnets + ACA delegation + Storage with PE, then verifies infrastructure via az CLI. +/// +public sealed class VnetStorageBlobInfraDeploymentTests(ITestOutputHelper output) +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); + + [Fact] + public async Task DeployVnetStorageBlobInfrastructure() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployVnetStorageBlobInfrastructureCore(cancellationToken); + } + + private async Task DeployVnetStorageBlobInfrastructureCore(CancellationToken cancellationToken) + { + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployVnetStorageBlobInfrastructure)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-blob-l1"); + + output.WriteLine($"Test: {nameof(DeployVnetStorageBlobInfrastructure)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost using aspire init + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + sequenceBuilder.Type("aspire init") + .Enter() + .Wait(TimeSpan.FromSeconds(5)) + .Enter() + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4a: Add Aspire.Hosting.Azure.AppContainers + output.WriteLine("Step 4a: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4b: Add Aspire.Hosting.Azure.Network + output.WriteLine("Step 4b: Adding Azure Network hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Network") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 4c: Add Aspire.Hosting.Azure.Storage + output.WriteLine("Step 4c: Adding Azure Storage hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Storage") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add VNet + PE infrastructure + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + output.WriteLine($"Looking for apphost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +#pragma warning disable ASPIREAZURE003 + +// VNet with delegated subnet for ACA and PE subnet for storage +var vnet = builder.AddAzureVirtualNetwork("vnet"); +var acaSubnet = vnet.AddSubnet("aca-subnet", "10.0.0.0/23"); +var peSubnet = vnet.AddSubnet("pe-subnet", "10.0.2.0/24"); + +_ = builder.AddAzureContainerAppEnvironment("env") + .WithDelegatedSubnet(acaSubnet); + +// Storage with Private Endpoint +var storage = builder.AddAzureStorage("storage"); +var blobs = storage.AddBlobs("blobs"); +peSubnet.AddPrivateEndpoint(blobs); + +#pragma warning restore ASPIREAZURE003 + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs with VNet + Storage PE infrastructure"); + output.WriteLine($"New content:\n{content}"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy to Azure + output.WriteLine("Step 7: Starting Azure deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(25)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify VNet infrastructure + output.WriteLine("Step 8: Verifying VNet infrastructure..."); + sequenceBuilder + .Type($"az network vnet list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv | head -5 && " + + $"echo \"---PE---\" && az network private-endpoint list -g \"{resourceGroupName}\" --query \"[].{{name:name,state:provisioningState}}\" -o table && " + + $"echo \"---DNS---\" && az network private-dns zone list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 9: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployVnetStorageBlobInfrastructure), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployVnetStorageBlobInfrastructure), + resourceGroupName, + ex.Message); + + throw; + } + finally + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} From bc92f0620896c6b950719b272083dc6db22541b4 Mon Sep 17 00:00:00 2001 From: David Negstad <50252651+danegsta@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:47:37 -0800 Subject: [PATCH 076/256] Make Ubuntu runner not identify as WSL2 (#14440) * Try making dotnet not identify github as WSL2, ignore trust error * Skip on error * Remove continue-on-error since workaround was successful * Allow exit code 4 * Add comment explaining workaround --- .github/workflows/run-tests.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 6a4b210a99a..19627b4e58a 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -108,7 +108,17 @@ jobs: - name: Trust HTTPS development certificate (Linux) if: inputs.os == 'ubuntu-latest' - run: ${{ env.DOTNET_SCRIPT }} dev-certs https --trust + # Allow the task to succeed on partial trust. + # Remove this workaround once https://github.com/dotnet/aspnetcore/pull/65392 has shipped + run: | + EXIT_CODE=0 + ${{ env.DOTNET_SCRIPT }} dev-certs https --trust || EXIT_CODE=$? + if [ $EXIT_CODE -ne 0 ] && [ $EXIT_CODE -ne 4 ]; then + echo "dev-certs https --trust failed with exit code $EXIT_CODE" + exit $EXIT_CODE + fi + env: + WSL_INTEROP: "" - name: Verify Docker is running # nested docker containers not supported on windows From 38ac828f2b468a769946d941486550a55f90d0be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Tue, 10 Feb 2026 21:05:25 -0800 Subject: [PATCH 077/256] Add AspireExport coverage for Azure KeyVault hosting integration (#14439) * Add AspireExport attributes to Aspire.Hosting.Azure.KeyVault Add [AspireExport] to compatible methods: - addAzureKeyVault: Adds an Azure Key Vault resource - addSecret: Adds a secret from a parameter resource - addSecretFromExpression: Adds a secret from a reference expression - addSecretWithName: Adds a named secret from a parameter resource - addSecretWithNameFromExpression: Adds a named secret from a reference expression Add [AspireExportIgnore] to incompatible methods: - WithRoleAssignments: Uses KeyVaultBuiltInRole[] (Azure.Provisioning type) - GetSecret: Returns IAzureKeyVaultSecretReference (not ATS-mapped) - AddSecret(ParameterResource): Raw ParameterResource overloads (use IResourceBuilder instead) * Add AspireExport coverage for Azure KeyVault hosting integration - Export GetSecret and add string-based WithRoleAssignments overload for ATS polyglot support - Add [AspireExport] to IAzureKeyVaultSecretReference interface - Add Reason property to AspireExportIgnoreAttribute for documenting exclusions - Add [AspireExportIgnore(Reason=...)] with explanations to non-exportable overloads - String-based WithRoleAssignments is internal (uses FrozenDictionary for role name validation) - Add 7 unit tests for the new WithRoleAssignments string overload --- .../AzureKeyVaultResourceExtensions.cs | 56 +++++++++ .../IAzureKeyVaultSecretReference.cs | 1 + .../Ats/AspireExportIgnoreAttribute.cs | 8 ++ .../AzureKeyVaultTests.cs | 112 ++++++++++++++++++ 4 files changed, 177 insertions(+) diff --git a/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResourceExtensions.cs b/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResourceExtensions.cs index b6bdd58c277..eb9abf7e443 100644 --- a/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.KeyVault/AzureKeyVaultResourceExtensions.cs @@ -8,6 +8,7 @@ using Azure.Provisioning; using Azure.Provisioning.Expressions; using Azure.Provisioning.KeyVault; +using System.Collections.Frozen; using System.Text.RegularExpressions; namespace Aspire.Hosting; @@ -58,6 +59,7 @@ public static partial class AzureKeyVaultResourceExtensions /// /// /// + [AspireExport("addAzureKeyVault", Description = "Adds an Azure Key Vault resource")] public static IResourceBuilder AddAzureKeyVault(this IDistributedApplicationBuilder builder, [ResourceName] string name) { ArgumentNullException.ThrowIfNull(builder); @@ -166,6 +168,7 @@ public static IResourceBuilder AddAzureKeyVault(this IDis /// /// /// + [AspireExportIgnore(Reason = "KeyVaultBuiltInRole is an Azure.Provisioning type not compatible with ATS. Use the string-based overload instead.")] public static IResourceBuilder WithRoleAssignments( this IResourceBuilder builder, IResourceBuilder target, @@ -175,12 +178,42 @@ public static IResourceBuilder WithRoleAssignments( return builder.WithRoleAssignments(target, KeyVaultBuiltInRole.GetBuiltInRoleName, roles); } + /// + /// Assigns the specified roles to the given resource, granting it the necessary permissions + /// on the target Azure Key Vault resource. This replaces the default role assignments for the resource. + /// + /// The resource to which the specified roles will be assigned. + /// The target Azure Key Vault resource. + /// The built-in Key Vault role names to be assigned (e.g., "KeyVaultSecretsUser", "KeyVaultReader"). + /// The updated with the applied role assignments. + /// Thrown when a role name is not a valid Key Vault built-in role. + [AspireExport("withRoleAssignments", Description = "Assigns Key Vault roles to a resource")] + internal static IResourceBuilder WithRoleAssignments( + this IResourceBuilder builder, + IResourceBuilder target, + params string[] roles) + where T : IResource + { + var builtInRoles = new KeyVaultBuiltInRole[roles.Length]; + for (var i = 0; i < roles.Length; i++) + { + if (!s_keyVaultRolesByName.TryGetValue(roles[i], out var role)) + { + throw new ArgumentException($"'{roles[i]}' is not a valid Key Vault built-in role. Valid roles: {string.Join(", ", s_keyVaultRolesByName.Keys)}.", nameof(roles)); + } + builtInRoles[i] = role; + } + + return builder.WithRoleAssignments(target, builtInRoles); + } + /// /// Gets a secret reference for the specified secret name from the Azure Key Vault resource. /// /// The Azure Key Vault resource builder. /// The name of the secret. /// A reference to the secret. + [AspireExport("getSecret", Description = "Gets a secret reference from the Azure Key Vault")] public static IAzureKeyVaultSecretReference GetSecret(this IResourceBuilder builder, string secretName) { ArgumentNullException.ThrowIfNull(builder); @@ -195,6 +228,7 @@ public static IAzureKeyVaultSecretReference GetSecret(this IResourceBuilderThe name of the secret. Must follow Azure Key Vault naming rules. /// The parameter resource containing the secret value. /// A reference to the . + [AspireExport("addSecret", Description = "Adds a secret to the Azure Key Vault from a parameter resource")] public static IResourceBuilder AddSecret(this IResourceBuilder builder, string name, IResourceBuilder parameterResource) { ArgumentNullException.ThrowIfNull(builder); @@ -210,6 +244,7 @@ public static IResourceBuilder AddSecret(this IReso /// The name of the secret. Must follow Azure Key Vault naming rules. /// The parameter resource containing the secret value. /// A reference to the . + [AspireExportIgnore(Reason = "Raw ParameterResource overload; use the IResourceBuilder variant instead.")] public static IResourceBuilder AddSecret(this IResourceBuilder builder, string name, ParameterResource parameterResource) { ArgumentNullException.ThrowIfNull(builder); @@ -230,6 +265,7 @@ public static IResourceBuilder AddSecret(this IReso /// The name of the secret. Must follow Azure Key Vault naming rules. /// The reference expression containing the secret value. /// A reference to the . + [AspireExport("addSecretFromExpression", Description = "Adds a secret to the Azure Key Vault from a reference expression")] public static IResourceBuilder AddSecret(this IResourceBuilder builder, string name, ReferenceExpression value) { ArgumentNullException.ThrowIfNull(builder); @@ -251,6 +287,7 @@ public static IResourceBuilder AddSecret(this IReso /// The name of the secret. Must follow Azure Key Vault naming rules. /// The parameter resource containing the secret value. /// A reference to the . + [AspireExport("addSecretWithName", Description = "Adds a named secret to the Azure Key Vault from a parameter resource")] public static IResourceBuilder AddSecret(this IResourceBuilder builder, [ResourceName] string name, string secretName, IResourceBuilder parameterResource) { ArgumentNullException.ThrowIfNull(builder); @@ -267,6 +304,7 @@ public static IResourceBuilder AddSecret(this IReso /// The name of the secret. Must follow Azure Key Vault naming rules. /// The parameter resource containing the secret value. /// A reference to the . + [AspireExportIgnore(Reason = "Raw ParameterResource overload; use the IResourceBuilder variant instead.")] public static IResourceBuilder AddSecret(this IResourceBuilder builder, [ResourceName] string name, string secretName, ParameterResource parameterResource) { ArgumentNullException.ThrowIfNull(builder); @@ -288,6 +326,7 @@ public static IResourceBuilder AddSecret(this IReso /// The name of the secret. Must follow Azure Key Vault naming rules. /// The reference expression containing the secret value. /// A reference to the . + [AspireExport("addSecretWithNameFromExpression", Description = "Adds a named secret to the Azure Key Vault from a reference expression")] public static IResourceBuilder AddSecret(this IResourceBuilder builder, [ResourceName] string name, string secretName, ReferenceExpression value) { ArgumentNullException.ThrowIfNull(builder); @@ -301,6 +340,23 @@ public static IResourceBuilder AddSecret(this IReso return builder.ApplicationBuilder.AddResource(secret).ExcludeFromManifest(); } + private static readonly FrozenDictionary s_keyVaultRolesByName = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [nameof(KeyVaultBuiltInRole.KeyVaultAdministrator)] = KeyVaultBuiltInRole.KeyVaultAdministrator, + [nameof(KeyVaultBuiltInRole.KeyVaultCertificateUser)] = KeyVaultBuiltInRole.KeyVaultCertificateUser, + [nameof(KeyVaultBuiltInRole.KeyVaultCertificatesOfficer)] = KeyVaultBuiltInRole.KeyVaultCertificatesOfficer, + [nameof(KeyVaultBuiltInRole.KeyVaultContributor)] = KeyVaultBuiltInRole.KeyVaultContributor, + [nameof(KeyVaultBuiltInRole.KeyVaultCryptoOfficer)] = KeyVaultBuiltInRole.KeyVaultCryptoOfficer, + [nameof(KeyVaultBuiltInRole.KeyVaultCryptoServiceEncryptionUser)] = KeyVaultBuiltInRole.KeyVaultCryptoServiceEncryptionUser, + [nameof(KeyVaultBuiltInRole.KeyVaultCryptoServiceReleaseUser)] = KeyVaultBuiltInRole.KeyVaultCryptoServiceReleaseUser, + [nameof(KeyVaultBuiltInRole.KeyVaultCryptoUser)] = KeyVaultBuiltInRole.KeyVaultCryptoUser, + [nameof(KeyVaultBuiltInRole.KeyVaultDataAccessAdministrator)] = KeyVaultBuiltInRole.KeyVaultDataAccessAdministrator, + [nameof(KeyVaultBuiltInRole.KeyVaultReader)] = KeyVaultBuiltInRole.KeyVaultReader, + [nameof(KeyVaultBuiltInRole.KeyVaultSecretsOfficer)] = KeyVaultBuiltInRole.KeyVaultSecretsOfficer, + [nameof(KeyVaultBuiltInRole.KeyVaultSecretsUser)] = KeyVaultBuiltInRole.KeyVaultSecretsUser, + [nameof(KeyVaultBuiltInRole.ManagedHsmContributor)] = KeyVaultBuiltInRole.ManagedHsmContributor, + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + private static void ValidateSecretName(string secretName) { // Azure Key Vault secret names must be 1-127 characters long and contain only ASCII letters (a-z, A-Z), digits (0-9), and dashes (-) diff --git a/src/Aspire.Hosting.Azure/IAzureKeyVaultSecretReference.cs b/src/Aspire.Hosting.Azure/IAzureKeyVaultSecretReference.cs index 2ecde3f4f80..d3ab69e325f 100644 --- a/src/Aspire.Hosting.Azure/IAzureKeyVaultSecretReference.cs +++ b/src/Aspire.Hosting.Azure/IAzureKeyVaultSecretReference.cs @@ -8,6 +8,7 @@ namespace Aspire.Hosting.Azure; /// /// Represents a reference to a secret in an Azure Key Vault resource. /// +[AspireExport] public interface IAzureKeyVaultSecretReference : IValueProvider, IManifestExpressionProvider, IValueWithReferences { /// diff --git a/src/Aspire.Hosting/Ats/AspireExportIgnoreAttribute.cs b/src/Aspire.Hosting/Ats/AspireExportIgnoreAttribute.cs index 62a88920a8c..fc6c4662bb6 100644 --- a/src/Aspire.Hosting/Ats/AspireExportIgnoreAttribute.cs +++ b/src/Aspire.Hosting/Ats/AspireExportIgnoreAttribute.cs @@ -39,4 +39,12 @@ namespace Aspire.Hosting; [Experimental("ASPIREATS001")] public sealed class AspireExportIgnoreAttribute : Attribute { + /// + /// Gets or sets the reason why this member is excluded from ATS export. + /// + /// + /// Use this property to document why an API cannot be exported to polyglot app hosts, + /// distinguishing reviewed-but-incompatible members from those not yet reviewed. + /// + public string? Reason { get; set; } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureKeyVaultTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureKeyVaultTests.cs index de9d5445ed0..a4005ed4b1a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureKeyVaultTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureKeyVaultTests.cs @@ -497,4 +497,116 @@ public async Task ConnectionStringRedirectAnnotation_TakesPrecedenceOverEmulator // The redirect annotation should take precedence over emulator Assert.Equal("https://redirected-vault.vault.azure.net", connectionStringValue); } + + [Fact] + public void WithRoleAssignments_StringOverload_ValidRoles_DoesNotThrow() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var kv = builder.AddAzureKeyVault("myKeyVault"); + var container = builder.AddContainer("myContainer", "nginx"); + + var exception = Record.Exception(() => + container.WithRoleAssignments(kv, "KeyVaultSecretsUser", "KeyVaultReader")); + + Assert.Null(exception); + } + + [Fact] + public void WithRoleAssignments_StringOverload_SingleRole_DoesNotThrow() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var kv = builder.AddAzureKeyVault("myKeyVault"); + var container = builder.AddContainer("myContainer", "nginx"); + + var exception = Record.Exception(() => + container.WithRoleAssignments(kv, "KeyVaultAdministrator")); + + Assert.Null(exception); + } + + [Fact] + public void WithRoleAssignments_StringOverload_InvalidRole_ThrowsArgumentException() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var kv = builder.AddAzureKeyVault("myKeyVault"); + var container = builder.AddContainer("myContainer", "nginx"); + + var exception = Assert.Throws(() => + container.WithRoleAssignments(kv, "NotARealRole")); + + Assert.Contains("'NotARealRole' is not a valid Key Vault built-in role", exception.Message); + Assert.Contains("Valid roles:", exception.Message); + } + + [Fact] + public void WithRoleAssignments_StringOverload_MixedValidAndInvalidRoles_ThrowsOnInvalid() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var kv = builder.AddAzureKeyVault("myKeyVault"); + var container = builder.AddContainer("myContainer", "nginx"); + + var exception = Assert.Throws(() => + container.WithRoleAssignments(kv, "KeyVaultReader", "InvalidRole")); + + Assert.Contains("'InvalidRole'", exception.Message); + } + + [Fact] + public void WithRoleAssignments_StringOverload_CaseInsensitive() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var kv = builder.AddAzureKeyVault("myKeyVault"); + var container = builder.AddContainer("myContainer", "nginx"); + + var exception = Record.Exception(() => + container.WithRoleAssignments(kv, "keyvaultsecretsuser", "KEYVAULTREADER")); + + Assert.Null(exception); + } + + [Theory] + [InlineData("KeyVaultAdministrator")] + [InlineData("KeyVaultCertificateUser")] + [InlineData("KeyVaultCertificatesOfficer")] + [InlineData("KeyVaultContributor")] + [InlineData("KeyVaultCryptoOfficer")] + [InlineData("KeyVaultCryptoServiceEncryptionUser")] + [InlineData("KeyVaultCryptoServiceReleaseUser")] + [InlineData("KeyVaultCryptoUser")] + [InlineData("KeyVaultDataAccessAdministrator")] + [InlineData("KeyVaultReader")] + [InlineData("KeyVaultSecretsOfficer")] + [InlineData("KeyVaultSecretsUser")] + [InlineData("ManagedHsmContributor")] + public void WithRoleAssignments_StringOverload_AllBuiltInRoles_AreAccepted(string roleName) + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var kv = builder.AddAzureKeyVault("myKeyVault"); + var container = builder.AddContainer("myContainer", "nginx"); + + var exception = Record.Exception(() => + container.WithRoleAssignments(kv, roleName)); + + Assert.Null(exception); + } + + [Fact] + public void WithRoleAssignments_StringOverload_EmptyRoles_DoesNotThrow() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var kv = builder.AddAzureKeyVault("myKeyVault"); + var container = builder.AddContainer("myContainer", "nginx"); + + var exception = Record.Exception(() => + container.WithRoleAssignments(kv, Array.Empty())); + + Assert.Null(exception); + } } From 7882b4868b1dbf95f313ad909d160516e78338d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Tue, 10 Feb 2026 21:06:23 -0800 Subject: [PATCH 078/256] Add AspireExport attributes to Aspire.Hosting.RabbitMQ (#14433) * Add AspireExport attributes to RabbitMQ hosting extension methods * Add TypeScript validation apphost for RabbitMQ exports --- .../ValidationAppHost/.aspire/settings.json | 9 + .../ValidationAppHost/apphost.run.json | 13 + .../ValidationAppHost/apphost.ts | 15 + .../ValidationAppHost/package-lock.json | 961 ++++++++++++++++++ .../ValidationAppHost/package.json | 19 + .../ValidationAppHost/tsconfig.json | 15 + .../RabbitMQBuilderExtensions.cs | 5 + 7 files changed, 1037 insertions(+) create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/apphost.run.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/apphost.ts create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/package-lock.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/package.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/tsconfig.json diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json new file mode 100644 index 00000000000..90e901beee5 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json @@ -0,0 +1,9 @@ +{ + "appHostPath": "../apphost.ts", + "language": "typescript/nodejs", + "channel": "local", + "sdkVersion": "13.2.0-preview.1.26081.1", + "packages": { + "Aspire.Hosting.RabbitMQ": "13.2.0-preview.1.26081.1" + } +} \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/apphost.run.json b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/apphost.run.json new file mode 100644 index 00000000000..054885b7982 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/apphost.run.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "https": { + "applicationUrl": "https://localhost:18076;http://localhost:48822", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:56384", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:64883" + } + } + } +} \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/apphost.ts b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/apphost.ts new file mode 100644 index 00000000000..16e66ed17d5 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/apphost.ts @@ -0,0 +1,15 @@ +import { createBuilder, ContainerLifetime } from './.modules/aspire.js'; + +const builder = await createBuilder(); + +const rabbitmq = await builder.addRabbitMQ("messaging"); +await rabbitmq.withDataVolume(); +await rabbitmq.withManagementPlugin(); + +const rabbitmq2 = await builder + .addRabbitMQ("messaging2") + .withLifetime(ContainerLifetime.Persistent) + .withDataVolume() + .withManagementPluginWithPort({ port: 15673 }); + +await builder.build().run(); \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/package-lock.json b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/package-lock.json new file mode 100644 index 00000000000..f1821e859ca --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/package-lock.json @@ -0,0 +1,961 @@ +{ + "name": "validationapphost", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "validationapphost", + "version": "1.0.0", + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "nodemon": "^3.1.11", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", + "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + } + } +} diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/package.json b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/package.json new file mode 100644 index 00000000000..be16934198a --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/package.json @@ -0,0 +1,19 @@ +{ + "name": "validationapphost", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "aspire run", + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "nodemon": "^3.1.11", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } +} \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/tsconfig.json b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/tsconfig.json new file mode 100644 index 00000000000..edf7302cc25 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": ["apphost.ts", ".modules/**/*.ts"], + "exclude": ["node_modules"] +} \ No newline at end of file diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQBuilderExtensions.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQBuilderExtensions.cs index 4175f8cee7f..92bfa231531 100644 --- a/src/Aspire.Hosting.RabbitMQ/RabbitMQBuilderExtensions.cs +++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQBuilderExtensions.cs @@ -26,6 +26,7 @@ public static class RabbitMQBuilderExtensions /// The parameter used to provide the password for the RabbitMQ resource. If a random password will be generated. /// The host port that the underlying container is bound to when running locally. /// A reference to the . + [AspireExport("addRabbitMQ", Description = "Adds a RabbitMQ container resource")] public static IResourceBuilder AddRabbitMQ(this IDistributedApplicationBuilder builder, [ResourceName] string name, IResourceBuilder? userName = null, @@ -92,6 +93,7 @@ static Task CreateConnection(string connectionString) /// The name of the volume. Defaults to an auto-generated name based on the application and resource names. /// A flag that indicates if this is a read-only volume. /// The . + [AspireExport("withDataVolume", Description = "Adds a data volume to the RabbitMQ container")] public static IResourceBuilder WithDataVolume(this IResourceBuilder builder, string? name = null, bool isReadOnly = false) { ArgumentNullException.ThrowIfNull(builder); @@ -107,6 +109,7 @@ public static IResourceBuilder WithDataVolume(this IReso /// The source directory on the host to mount into the container. /// A flag that indicates if this is a read-only mount. /// The . + [AspireExport("withDataBindMount", Description = "Adds a data bind mount to the RabbitMQ container")] public static IResourceBuilder WithDataBindMount(this IResourceBuilder builder, string source, bool isReadOnly = false) { ArgumentNullException.ThrowIfNull(builder); @@ -127,6 +130,7 @@ public static IResourceBuilder WithDataBindMount(this IR /// The resource builder. /// The . /// Thrown when the current container image and tag do not match the defaults for . + [AspireExport("withManagementPlugin", Description = "Enables the RabbitMQ management plugin")] public static IResourceBuilder WithManagementPlugin(this IResourceBuilder builder) { ArgumentNullException.ThrowIfNull(builder); @@ -147,6 +151,7 @@ public static IResourceBuilder WithManagementPlugin(this /// /// /// + [AspireExport("withManagementPluginWithPort", Description = "Enables the RabbitMQ management plugin with a specific port")] public static IResourceBuilder WithManagementPlugin(this IResourceBuilder builder, int? port) { ArgumentNullException.ThrowIfNull(builder); From c49f16bd4d20d5de786c5d9a30880e8d7850c16a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Tue, 10 Feb 2026 21:39:16 -0800 Subject: [PATCH 079/256] Gate experimental polyglot languages behind per-language feature flags (#14428) Add per-language experimental feature flags for Python, Go, Java, and Rust. When polyglotSupportEnabled is true, only C# and TypeScript are available by default. Experimental languages require an additional flag: - experimentalPolyglot:python - experimentalPolyglot:go - experimentalPolyglot:java - experimentalPolyglot:rust Changes: - Add IsExperimental property to LanguageInfo record - Add 4 new feature flags to KnownFeatures - Filter experimental languages in DefaultLanguageDiscovery based on flags - Update CI Dockerfiles to enable per-language flags - Update extension JSON schemas with new flag definitions - Update --language option description in NewCommand/InitCommand - Add tests for experimental language filtering --- .../polyglot-validation/Dockerfile.go | 3 + .../polyglot-validation/Dockerfile.java | 3 + .../polyglot-validation/Dockerfile.python | 3 + .../polyglot-validation/Dockerfile.rust | 3 + .../aspire-global-settings.schema.json | 71 +++++++++++ extension/schemas/aspire-settings.schema.json | 71 +++++++++++ src/Aspire.Cli/Commands/InitCommand.cs | 2 +- src/Aspire.Cli/Commands/NewCommand.cs | 2 +- src/Aspire.Cli/KnownFeatures.cs | 24 ++++ .../Projects/DefaultLanguageDiscovery.cs | 71 ++++++++--- src/Aspire.Cli/Projects/ILanguageDiscovery.cs | 4 +- .../Projects/DefaultLanguageDiscoveryTests.cs | 115 ++++++++++++++++-- 12 files changed, 340 insertions(+), 32 deletions(-) diff --git a/.github/workflows/polyglot-validation/Dockerfile.go b/.github/workflows/polyglot-validation/Dockerfile.go index f860ce7e37d..019261a7edc 100644 --- a/.github/workflows/polyglot-validation/Dockerfile.go +++ b/.github/workflows/polyglot-validation/Dockerfile.go @@ -37,5 +37,8 @@ ENTRYPOINT ["/bin/bash", "-c", "\ set -e && \ /scripts/setup-local-cli.sh && \ aspire config set features:polyglotSupportEnabled true --global && \ + aspire config set features:experimentalPolyglot:go true --global && \ + echo '' && \ + echo '=== Running validation ===' && \ /scripts/test-go.sh \ "] diff --git a/.github/workflows/polyglot-validation/Dockerfile.java b/.github/workflows/polyglot-validation/Dockerfile.java index ed787b8d988..89199e49883 100644 --- a/.github/workflows/polyglot-validation/Dockerfile.java +++ b/.github/workflows/polyglot-validation/Dockerfile.java @@ -37,5 +37,8 @@ set -e && \ /scripts/setup-local-cli.sh && \ aspire config set features:polyglotSupportEnabled true --global && \ + aspire config set features:experimentalPolyglot:java true --global && \ + echo '' && \ + echo '=== Running validation ===' && \ /scripts/test-java.sh \ "] diff --git a/.github/workflows/polyglot-validation/Dockerfile.python b/.github/workflows/polyglot-validation/Dockerfile.python index 76dd862f031..4dc2365ded3 100644 --- a/.github/workflows/polyglot-validation/Dockerfile.python +++ b/.github/workflows/polyglot-validation/Dockerfile.python @@ -41,5 +41,8 @@ ENTRYPOINT ["/bin/bash", "-c", "\ set -e && \ /scripts/setup-local-cli.sh && \ aspire config set features:polyglotSupportEnabled true --global && \ + aspire config set features:experimentalPolyglot:python true --global && \ + echo '' && \ + echo '=== Running validation ===' && \ /scripts/test-python.sh \ "] diff --git a/.github/workflows/polyglot-validation/Dockerfile.rust b/.github/workflows/polyglot-validation/Dockerfile.rust index 8d39bbcd7f5..ba3a876fd7b 100644 --- a/.github/workflows/polyglot-validation/Dockerfile.rust +++ b/.github/workflows/polyglot-validation/Dockerfile.rust @@ -37,5 +37,8 @@ ENTRYPOINT ["/bin/bash", "-c", "\ set -e && \ /scripts/setup-local-cli.sh && \ aspire config set features:polyglotSupportEnabled true --global && \ + aspire config set features:experimentalPolyglot:rust true --global && \ + echo '' && \ + echo '=== Running validation ===' && \ /scripts/test-rust.sh \ "] diff --git a/extension/schemas/aspire-global-settings.schema.json b/extension/schemas/aspire-global-settings.schema.json index 2bfa780e5bc..bf16ec26e56 100644 --- a/extension/schemas/aspire-global-settings.schema.json +++ b/extension/schemas/aspire-global-settings.schema.json @@ -125,6 +125,77 @@ "description": "Enable or disable support for non-.NET (polyglot) languages and runtimes in Aspire applications", "default": false }, + "experimentalPolyglot": { + "description": "Per-language feature flags for experimental polyglot languages (requires polyglotSupportEnabled).", + "type": "object", + "properties": { + "go": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Go language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + }, + "java": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Java language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + }, + "python": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Python language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + }, + "rust": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Rust language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + } + }, + "additionalProperties": false + }, "runningInstanceDetectionEnabled": { "anyOf": [ { diff --git a/extension/schemas/aspire-settings.schema.json b/extension/schemas/aspire-settings.schema.json index 9f96e1a18dc..391cf7adb02 100644 --- a/extension/schemas/aspire-settings.schema.json +++ b/extension/schemas/aspire-settings.schema.json @@ -129,6 +129,77 @@ "description": "Enable or disable support for non-.NET (polyglot) languages and runtimes in Aspire applications", "default": false }, + "experimentalPolyglot": { + "description": "Per-language feature flags for experimental polyglot languages (requires polyglotSupportEnabled).", + "type": "object", + "properties": { + "go": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Go language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + }, + "java": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Java language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + }, + "python": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Python language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + }, + "rust": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Rust language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + } + }, + "additionalProperties": false + }, "runningInstanceDetectionEnabled": { "anyOf": [ { diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index e2589cecd11..db860435fd0 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -120,7 +120,7 @@ public InitCommand( { _languageOption = new Option("--language", "-l") { - Description = "The programming language for the AppHost (csharp, typescript, python)" + Description = "The programming language for the AppHost (csharp, typescript)" }; Options.Add(_languageOption); } diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index caace48b9b9..1ce14e647b7 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -120,7 +120,7 @@ public NewCommand( { _languageOption = new Option("--language", "-l") { - Description = "The programming language for the AppHost (csharp, typescript, python)" + Description = "The programming language for the AppHost (csharp, typescript)" }; Options.Add(_languageOption); } diff --git a/src/Aspire.Cli/KnownFeatures.cs b/src/Aspire.Cli/KnownFeatures.cs index 48b5ba0ced6..97e0fb1d558 100644 --- a/src/Aspire.Cli/KnownFeatures.cs +++ b/src/Aspire.Cli/KnownFeatures.cs @@ -25,6 +25,10 @@ internal static class KnownFeatures public static string DefaultWatchEnabled => "defaultWatchEnabled"; public static string ShowAllTemplates => "showAllTemplates"; public static string PolyglotSupportEnabled => "polyglotSupportEnabled"; + public static string ExperimentalPolyglotRust => "experimentalPolyglot:rust"; + public static string ExperimentalPolyglotJava => "experimentalPolyglot:java"; + public static string ExperimentalPolyglotGo => "experimentalPolyglot:go"; + public static string ExperimentalPolyglotPython => "experimentalPolyglot:python"; public static string DotNetSdkInstallationEnabled => "dotnetSdkInstallationEnabled"; public static string RunningInstanceDetectionEnabled => "runningInstanceDetectionEnabled"; @@ -80,6 +84,26 @@ internal static class KnownFeatures "Enable or disable support for non-.NET (polyglot) languages and runtimes in Aspire applications", DefaultValue: false), + [ExperimentalPolyglotRust] = new( + ExperimentalPolyglotRust, + "Enable or disable experimental Rust language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + DefaultValue: false), + + [ExperimentalPolyglotJava] = new( + ExperimentalPolyglotJava, + "Enable or disable experimental Java language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + DefaultValue: false), + + [ExperimentalPolyglotGo] = new( + ExperimentalPolyglotGo, + "Enable or disable experimental Go language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + DefaultValue: false), + + [ExperimentalPolyglotPython] = new( + ExperimentalPolyglotPython, + "Enable or disable experimental Python language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + DefaultValue: false), + [DotNetSdkInstallationEnabled] = new( DotNetSdkInstallationEnabled, "Enable or disable automatic .NET SDK installation when a required SDK version is missing", diff --git a/src/Aspire.Cli/Projects/DefaultLanguageDiscovery.cs b/src/Aspire.Cli/Projects/DefaultLanguageDiscovery.cs index d9473782505..4341400b61d 100644 --- a/src/Aspire.Cli/Projects/DefaultLanguageDiscovery.cs +++ b/src/Aspire.Cli/Projects/DefaultLanguageDiscovery.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 Aspire.Cli.Configuration; + namespace Aspire.Cli.Projects; /// @@ -9,12 +11,9 @@ namespace Aspire.Cli.Projects; /// /// /// This implementation provides a static list of supported languages. -/// Future implementations could discover languages from: -/// - Configuration files (~/.aspire/languages.json) -/// - NuGet search (Aspire.Hosting.Language.* packages) -/// - Remote service endpoints +/// Experimental languages (Go, Java, Rust) are filtered based on per-language feature flags. /// -internal sealed class DefaultLanguageDiscovery : ILanguageDiscovery +internal sealed class DefaultLanguageDiscovery(IFeatures features) : ILanguageDiscovery { private static readonly LanguageInfo[] s_allLanguages = [ @@ -38,34 +37,46 @@ internal sealed class DefaultLanguageDiscovery : ILanguageDiscovery PackageName: "Aspire.Hosting.CodeGeneration.Python", DetectionPatterns: ["apphost.py"], CodeGenerator: "Python", - AppHostFileName: "apphost.py"), + AppHostFileName: "apphost.py", + IsExperimental: true), new LanguageInfo( LanguageId: new LanguageId(KnownLanguageId.Go), DisplayName: KnownLanguageId.GoDisplayName, PackageName: "Aspire.Hosting.CodeGeneration.Go", DetectionPatterns: ["apphost.go"], CodeGenerator: "Go", - AppHostFileName: "apphost.go"), + AppHostFileName: "apphost.go", + IsExperimental: true), new LanguageInfo( LanguageId: new LanguageId(KnownLanguageId.Java), DisplayName: KnownLanguageId.JavaDisplayName, PackageName: "Aspire.Hosting.CodeGeneration.Java", DetectionPatterns: ["AppHost.java"], CodeGenerator: "Java", - AppHostFileName: "AppHost.java"), + AppHostFileName: "AppHost.java", + IsExperimental: true), new LanguageInfo( LanguageId: new LanguageId(KnownLanguageId.Rust), DisplayName: KnownLanguageId.RustDisplayName, PackageName: "Aspire.Hosting.CodeGeneration.Rust", DetectionPatterns: ["apphost.rs"], CodeGenerator: "Rust", - AppHostFileName: "apphost.rs"), + AppHostFileName: "apphost.rs", + IsExperimental: true), ]; + private static readonly Dictionary s_experimentalFeatureFlags = new(StringComparer.OrdinalIgnoreCase) + { + [KnownLanguageId.Python] = KnownFeatures.ExperimentalPolyglotPython, + [KnownLanguageId.Go] = KnownFeatures.ExperimentalPolyglotGo, + [KnownLanguageId.Java] = KnownFeatures.ExperimentalPolyglotJava, + [KnownLanguageId.Rust] = KnownFeatures.ExperimentalPolyglotRust, + }; + /// public Task> GetAvailableLanguagesAsync(CancellationToken cancellationToken = default) { - return Task.FromResult>(s_allLanguages); + return Task.FromResult(s_allLanguages.Where(IsLanguageEnabled)); } /// @@ -80,7 +91,7 @@ public Task> GetAvailableLanguagesAsync(CancellationTo /// public Task DetectLanguageAsync(DirectoryInfo directory, CancellationToken cancellationToken = default) { - foreach (var language in s_allLanguages) + foreach (var language in s_allLanguages.Where(IsLanguageEnabled)) { foreach (var pattern in language.DetectionPatterns) { @@ -102,25 +113,49 @@ public Task> GetAvailableLanguagesAsync(CancellationTo var match = s_allLanguages.FirstOrDefault(l => string.Equals(l.LanguageId.Value, languageId.Value, StringComparison.OrdinalIgnoreCase)); - if (match is not null) - { - return match; - } - // Try alias match (e.g., "typescript" -> "typescript/nodejs") - return languageId.Value switch + match ??= languageId.Value switch { KnownLanguageId.TypeScriptAlias => s_allLanguages.FirstOrDefault(l => string.Equals(l.LanguageId.Value, KnownLanguageId.TypeScript, StringComparison.OrdinalIgnoreCase)), _ => null }; + + if (match is not null && !IsLanguageEnabled(match)) + { + return null; + } + + return match; } /// public LanguageInfo? GetLanguageByFile(FileInfo file) { - return s_allLanguages.FirstOrDefault(l => + var match = s_allLanguages.FirstOrDefault(l => l.DetectionPatterns.Any(p => MatchesPattern(file.Name, p))); + + if (match is not null && !IsLanguageEnabled(match)) + { + return null; + } + + return match; + } + + private bool IsLanguageEnabled(LanguageInfo language) + { + if (!language.IsExperimental) + { + return true; + } + + if (s_experimentalFeatureFlags.TryGetValue(language.LanguageId.Value, out var featureFlag)) + { + return features.IsFeatureEnabled(featureFlag, false); + } + + return true; } private static bool MatchesPattern(string fileName, string pattern) diff --git a/src/Aspire.Cli/Projects/ILanguageDiscovery.cs b/src/Aspire.Cli/Projects/ILanguageDiscovery.cs index 94f4487a14b..a1ba5a8cc65 100644 --- a/src/Aspire.Cli/Projects/ILanguageDiscovery.cs +++ b/src/Aspire.Cli/Projects/ILanguageDiscovery.cs @@ -36,13 +36,15 @@ internal readonly record struct LanguageId(string Value) /// File patterns used to detect this language (e.g., ["apphost.ts"]). /// The code generator name to use for this language (e.g., "TypeScript"). Must match ICodeGenerator.Language. /// The default filename for the AppHost entry point (e.g., "apphost.ts"). +/// Whether this language is experimental and requires an additional per-language feature flag to be enabled. internal sealed record LanguageInfo( LanguageId LanguageId, string DisplayName, string PackageName, string[] DetectionPatterns, string CodeGenerator, - string? AppHostFileName = null); + string? AppHostFileName = null, + bool IsExperimental = false); /// /// Interface for discovering available languages. diff --git a/tests/Aspire.Cli.Tests/Projects/DefaultLanguageDiscoveryTests.cs b/tests/Aspire.Cli.Tests/Projects/DefaultLanguageDiscoveryTests.cs index 20d0b654d7e..e68c7614fae 100644 --- a/tests/Aspire.Cli.Tests/Projects/DefaultLanguageDiscoveryTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/DefaultLanguageDiscoveryTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.InternalTesting; +using Aspire.Cli.Configuration; using Aspire.Cli.Projects; namespace Aspire.Cli.Tests.Projects; @@ -11,7 +12,7 @@ public class DefaultLanguageDiscoveryTests [Fact] public async Task GetAvailableLanguagesAsync_ReturnsCSharpLanguage() { - var discovery = new DefaultLanguageDiscovery(); + var discovery = new DefaultLanguageDiscovery(new TestFeatures()); var languages = await discovery.GetAvailableLanguagesAsync().DefaultTimeout(); @@ -27,7 +28,7 @@ public async Task GetAvailableLanguagesAsync_ReturnsCSharpLanguage() [InlineData("apphost.cs")] public async Task GetAvailableLanguagesAsync_CSharpLanguageHasExpectedDetectionPatterns(string expectedPattern) { - var discovery = new DefaultLanguageDiscovery(); + var discovery = new DefaultLanguageDiscovery(new TestFeatures()); var languages = await discovery.GetAvailableLanguagesAsync().DefaultTimeout(); @@ -38,7 +39,7 @@ public async Task GetAvailableLanguagesAsync_CSharpLanguageHasExpectedDetectionP [Fact] public async Task GetAvailableLanguagesAsync_ReturnsTypeScriptLanguage() { - var discovery = new DefaultLanguageDiscovery(); + var discovery = new DefaultLanguageDiscovery(new TestFeatures()); var languages = await discovery.GetAvailableLanguagesAsync().DefaultTimeout(); @@ -51,7 +52,9 @@ public async Task GetAvailableLanguagesAsync_ReturnsTypeScriptLanguage() [Fact] public async Task GetAvailableLanguagesAsync_ReturnsPythonLanguage() { - var discovery = new DefaultLanguageDiscovery(); + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.ExperimentalPolyglotPython, true); + var discovery = new DefaultLanguageDiscovery(features); var languages = await discovery.GetAvailableLanguagesAsync().DefaultTimeout(); @@ -61,6 +64,35 @@ public async Task GetAvailableLanguagesAsync_ReturnsPythonLanguage() Assert.Contains("apphost.py", python.DetectionPatterns); } + [Fact] + public async Task GetAvailableLanguagesAsync_ExcludesExperimentalLanguagesByDefault() + { + var discovery = new DefaultLanguageDiscovery(new TestFeatures()); + + var languages = (await discovery.GetAvailableLanguagesAsync().DefaultTimeout()).ToList(); + + Assert.Null(languages.FirstOrDefault(l => l.LanguageId.Value == KnownLanguageId.Python)); + Assert.Null(languages.FirstOrDefault(l => l.LanguageId.Value == KnownLanguageId.Go)); + Assert.Null(languages.FirstOrDefault(l => l.LanguageId.Value == KnownLanguageId.Java)); + Assert.Null(languages.FirstOrDefault(l => l.LanguageId.Value == KnownLanguageId.Rust)); + } + + [Theory] + [InlineData(KnownLanguageId.Python, "experimentalPolyglot:python")] + [InlineData(KnownLanguageId.Go, "experimentalPolyglot:go")] + [InlineData(KnownLanguageId.Java, "experimentalPolyglot:java")] + [InlineData(KnownLanguageId.Rust, "experimentalPolyglot:rust")] + public async Task GetAvailableLanguagesAsync_IncludesExperimentalLanguageWhenFlagEnabled(string languageId, string featureFlag) + { + var features = new TestFeatures(); + features.SetFeature(featureFlag, true); + var discovery = new DefaultLanguageDiscovery(features); + + var languages = (await discovery.GetAvailableLanguagesAsync().DefaultTimeout()).ToList(); + + Assert.NotNull(languages.FirstOrDefault(l => l.LanguageId.Value == languageId)); + } + [Theory] [InlineData("test.csproj", KnownLanguageId.CSharp)] [InlineData("Test.csproj", KnownLanguageId.CSharp)] @@ -71,11 +103,9 @@ public async Task GetAvailableLanguagesAsync_ReturnsPythonLanguage() [InlineData("APPHOST.CS", KnownLanguageId.CSharp)] [InlineData("apphost.ts", "typescript/nodejs")] [InlineData("AppHost.ts", "typescript/nodejs")] - [InlineData("apphost.py", KnownLanguageId.Python)] - [InlineData("AppHost.py", KnownLanguageId.Python)] public void GetLanguageByFile_ReturnsCorrectLanguage(string fileName, string expectedLanguageId) { - var discovery = new DefaultLanguageDiscovery(); + var discovery = new DefaultLanguageDiscovery(new TestFeatures()); var file = new FileInfo(Path.Combine(Path.GetTempPath(), fileName)); var language = discovery.GetLanguageByFile(file); @@ -90,7 +120,7 @@ public void GetLanguageByFile_ReturnsCorrectLanguage(string fileName, string exp [InlineData("random.js")] public void GetLanguageByFile_ReturnsNullForUnknownFiles(string fileName) { - var discovery = new DefaultLanguageDiscovery(); + var discovery = new DefaultLanguageDiscovery(new TestFeatures()); var file = new FileInfo(Path.Combine(Path.GetTempPath(), fileName)); var language = discovery.GetLanguageByFile(file); @@ -98,13 +128,37 @@ public void GetLanguageByFile_ReturnsNullForUnknownFiles(string fileName) Assert.Null(language); } + [Fact] + public void GetLanguageByFile_ReturnsNullForExperimentalLanguageWhenFlagDisabled() + { + var discovery = new DefaultLanguageDiscovery(new TestFeatures()); + var file = new FileInfo(Path.Combine(Path.GetTempPath(), "apphost.go")); + + var language = discovery.GetLanguageByFile(file); + + Assert.Null(language); + } + + [Fact] + public void GetLanguageByFile_ReturnsExperimentalLanguageWhenFlagEnabled() + { + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.ExperimentalPolyglotGo, true); + var discovery = new DefaultLanguageDiscovery(features); + var file = new FileInfo(Path.Combine(Path.GetTempPath(), "apphost.go")); + + var language = discovery.GetLanguageByFile(file); + + Assert.NotNull(language); + Assert.Equal(KnownLanguageId.Go, language.LanguageId.Value); + } + [Theory] [InlineData(KnownLanguageId.CSharp)] [InlineData("typescript/nodejs")] - [InlineData(KnownLanguageId.Python)] public void GetLanguageById_ReturnsCorrectLanguage(string languageId) { - var discovery = new DefaultLanguageDiscovery(); + var discovery = new DefaultLanguageDiscovery(new TestFeatures()); var language = discovery.GetLanguageById(new LanguageId(languageId)); @@ -115,10 +169,49 @@ public void GetLanguageById_ReturnsCorrectLanguage(string languageId) [Fact] public void GetLanguageById_ReturnsNullForUnknownLanguage() { - var discovery = new DefaultLanguageDiscovery(); + var discovery = new DefaultLanguageDiscovery(new TestFeatures()); var language = discovery.GetLanguageById(new LanguageId("unknown")); Assert.Null(language); } + + [Fact] + public void GetLanguageById_ReturnsNullForExperimentalLanguageWhenFlagDisabled() + { + var discovery = new DefaultLanguageDiscovery(new TestFeatures()); + + var language = discovery.GetLanguageById(new LanguageId(KnownLanguageId.Rust)); + + Assert.Null(language); + } + + [Fact] + public void GetLanguageById_ReturnsExperimentalLanguageWhenFlagEnabled() + { + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.ExperimentalPolyglotRust, true); + var discovery = new DefaultLanguageDiscovery(features); + + var language = discovery.GetLanguageById(new LanguageId(KnownLanguageId.Rust)); + + Assert.NotNull(language); + Assert.Equal(KnownLanguageId.Rust, language.LanguageId.Value); + } + + private sealed class TestFeatures : IFeatures + { + private readonly Dictionary _features = new(); + + public TestFeatures SetFeature(string featureName, bool value) + { + _features[featureName] = value; + return this; + } + + public bool IsFeatureEnabled(string featureName, bool defaultValue = false) + { + return _features.TryGetValue(featureName, out var value) ? value : defaultValue; + } + } } From c1a9b766d87df4a000809b8695f0744b5811df21 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Tue, 10 Feb 2026 21:41:00 -0800 Subject: [PATCH 080/256] Add --no-build and --no-restore flags to aspire run command (#14401) These flags work like their equivalents on `dotnet run`: - `--no-build`: Skip building the project before running (implies --no-restore) - `--no-restore`: Skip restoring packages before running This also includes: - Passing --no-restore to dotnet build when building occurs - CLI validation error when --no-build is used with watch mode enabled (since dotnet watch doesn't support --no-build) Co-authored-by: Claude Opus 4.6 --- src/Aspire.Cli/Commands/ExecCommand.cs | 1 + .../Commands/PipelineCommandBase.cs | 10 +- src/Aspire.Cli/Commands/RunCommand.cs | 22 +- src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 15 +- .../Projects/AppHostProjectContext.cs | 5 + .../Projects/DotNetAppHostProject.cs | 61 +++-- .../DotNetBasedAppHostServerProject.cs | 2 +- src/Aspire.Cli/Projects/IAppHostProject.cs | 5 + .../PublishCommandStrings.Designer.cs | 11 +- .../Resources/PublishCommandStrings.resx | 3 + .../Resources/RunCommandStrings.Designer.cs | 12 + .../Resources/RunCommandStrings.resx | 6 + .../xlf/PublishCommandStrings.cs.xlf | 5 + .../xlf/PublishCommandStrings.de.xlf | 5 + .../xlf/PublishCommandStrings.es.xlf | 5 + .../xlf/PublishCommandStrings.fr.xlf | 5 + .../xlf/PublishCommandStrings.it.xlf | 5 + .../xlf/PublishCommandStrings.ja.xlf | 5 + .../xlf/PublishCommandStrings.ko.xlf | 5 + .../xlf/PublishCommandStrings.pl.xlf | 5 + .../xlf/PublishCommandStrings.pt-BR.xlf | 5 + .../xlf/PublishCommandStrings.ru.xlf | 5 + .../xlf/PublishCommandStrings.tr.xlf | 5 + .../xlf/PublishCommandStrings.zh-Hans.xlf | 5 + .../xlf/PublishCommandStrings.zh-Hant.xlf | 5 + .../Resources/xlf/RunCommandStrings.cs.xlf | 10 + .../Resources/xlf/RunCommandStrings.de.xlf | 10 + .../Resources/xlf/RunCommandStrings.es.xlf | 10 + .../Resources/xlf/RunCommandStrings.fr.xlf | 10 + .../Resources/xlf/RunCommandStrings.it.xlf | 10 + .../Resources/xlf/RunCommandStrings.ja.xlf | 10 + .../Resources/xlf/RunCommandStrings.ko.xlf | 10 + .../Resources/xlf/RunCommandStrings.pl.xlf | 10 + .../Resources/xlf/RunCommandStrings.pt-BR.xlf | 10 + .../Resources/xlf/RunCommandStrings.ru.xlf | 10 + .../Resources/xlf/RunCommandStrings.tr.xlf | 10 + .../xlf/RunCommandStrings.zh-Hans.xlf | 10 + .../xlf/RunCommandStrings.zh-Hant.xlf | 10 + src/Aspire.Cli/Utils/AppHostHelper.cs | 3 +- .../Commands/DeployCommandTests.cs | 18 +- .../Commands/DoCommandTests.cs | 16 +- .../Commands/ExecCommandTests.cs | 2 +- ...PublishCommandPromptingIntegrationTests.cs | 4 +- .../Commands/PublishCommandTests.cs | 10 +- .../Commands/RunCommandTests.cs | 162 ++++++++++-- .../DotNet/DotNetCliRunnerTests.cs | 231 +++++++++++++++++- .../Templating/DotNetTemplateFactoryTests.cs | 4 +- .../TestServices/TestDotNetCliRunner.cs | 12 +- 48 files changed, 714 insertions(+), 96 deletions(-) diff --git a/src/Aspire.Cli/Commands/ExecCommand.cs b/src/Aspire.Cli/Commands/ExecCommand.cs index 225266239b8..6dfab4368be 100644 --- a/src/Aspire.Cli/Commands/ExecCommand.cs +++ b/src/Aspire.Cli/Commands/ExecCommand.cs @@ -182,6 +182,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell projectFile: effectiveAppHostProjectFile, watch: false, noBuild: false, + noRestore: false, args: args, env: env, backchannelCompletionSource: backchannelCompletionSource, diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index a15cd763c62..1cb2587d6ea 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -55,6 +55,11 @@ internal abstract class PipelineCommandBase : BaseCommand Description = "The environment to use for the operation. The default is 'Production'." }; + protected static readonly Option s_noBuildOption = new("--no-build") + { + Description = PublishCommandStrings.NoBuildArgumentDescription + }; + protected abstract string OperationCompletedPrefix { get; } protected abstract string OperationFailedPrefix { get; } @@ -88,6 +93,7 @@ protected PipelineCommandBase(string name, string description, IDotNetCliRunner Options.Add(s_logLevelOption); Options.Add(s_environmentOption); Options.Add(s_includeExceptionDetailsOption); + Options.Add(s_noBuildOption); // In the publish and deploy commands we forward all unrecognized tokens // through to the underlying tooling when we launch the app host. @@ -103,6 +109,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { var debugMode = parseResult.GetValue(RootCommand.DebugOption); var waitForDebugger = parseResult.GetValue(RootCommand.WaitForDebuggerOption); + var noBuild = parseResult.GetValue(s_noBuildOption); Task? pendingRun = null; PublishContext? publishContext = null; @@ -156,7 +163,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell Arguments = GetRunArguments(fullyQualifiedOutputPath, unmatchedTokens, parseResult), BackchannelCompletionSource = backchannelCompletionSource, WorkingDirectory = ExecutionContext.WorkingDirectory, - Debug = debugMode + Debug = debugMode, + NoBuild = noBuild }; pendingRun = project.PublishAsync(publishContext, cancellationToken); diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index e09d37da8f9..e8f202b82d1 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -82,6 +82,10 @@ internal sealed class RunCommand : BaseCommand { Description = RunCommandStrings.IsolatedArgumentDescription }; + private static readonly Option s_noBuildOption = new("--no-build") + { + Description = RunCommandStrings.NoBuildArgumentDescription + }; private readonly Option? _startDebugSessionOption; public RunCommand( @@ -121,6 +125,7 @@ public RunCommand( Options.Add(s_detachOption); Options.Add(s_formatOption); Options.Add(s_isolatedOption); + Options.Add(s_noBuildOption); if (ExtensionHelper.IsExtensionHost(InteractionService, out _, out _)) { @@ -140,6 +145,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var detach = parseResult.GetValue(s_detachOption); var format = parseResult.GetValue(s_formatOption); var isolated = parseResult.GetValue(s_isolatedOption); + var noBuild = parseResult.GetValue(s_noBuildOption); var isExtensionHost = ExtensionHelper.IsExtensionHost(InteractionService, out _, out _); var startDebugSession = false; if (isExtensionHost) @@ -158,6 +164,15 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.InvalidCommand; } + // Validate that --no-build is not used when watch mode would be enabled + // Watch mode is enabled when DefaultWatchEnabled feature is true, or when running under extension host (not in debug session) + var watchModeEnabled = _features.IsFeatureEnabled(KnownFeatures.DefaultWatchEnabled, defaultValue: false) || (isExtensionHost && !startDebugSession); + if (noBuild && watchModeEnabled) + { + InteractionService.DisplayError(RunCommandStrings.NoBuildNotSupportedWithWatchMode); + return ExitCodeConstants.InvalidCommand; + } + // Handle detached mode - spawn child process and exit if (detach) { @@ -220,7 +235,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell AppHostFile = effectiveAppHostFile, Watch = false, Debug = parseResult.GetValue(RootCommand.DebugOption), - NoBuild = false, + NoBuild = noBuild, + NoRestore = noBuild, // --no-build implies --no-restore WaitForDebugger = parseResult.GetValue(RootCommand.WaitForDebuggerOption), Isolated = isolated, StartDebugSession = startDebugSession, @@ -661,6 +677,10 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? { args.Add("--isolated"); } + if (parseResult.GetValue(s_noBuildOption)) + { + args.Add("--no-build"); + } // Pass through any unmatched tokens (but not --detach since child shouldn't detach again) foreach (var token in parseResult.UnmatchedTokens) diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index 13b7a1ce49f..0f481a7917d 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -28,10 +28,10 @@ internal interface IDotNetCliRunner { Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); + Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool noRestore, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task NewProjectAsync(string templateName, string name, string outputPath, string[] extraArgs, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); - Task BuildAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); + Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken); @@ -382,7 +382,7 @@ private async Task StartBackchannelAsync(IDotNetCliExecution? execution, string } } - public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool noRestore, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); @@ -396,6 +396,7 @@ public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, var isSingleFile = projectFile.Extension.Equals(".cs", StringComparison.OrdinalIgnoreCase); var watchOrRunCommand = watch ? "watch" : "run"; var noBuildSwitch = noBuild ? "--no-build" : string.Empty; + var noRestoreSwitch = noRestore && !noBuild ? "--no-restore" : string.Empty; // --no-build implies --no-restore var noProfileSwitch = options.NoLaunchProfile ? "--no-launch-profile" : string.Empty; // Add --non-interactive flag when using watch to prevent interactive prompts during automation var nonInteractiveSwitch = watch ? "--non-interactive" : string.Empty; @@ -404,7 +405,7 @@ public async Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] cliArgs = isSingleFile switch { - false => [watchOrRunCommand, nonInteractiveSwitch, verboseSwitch, noBuildSwitch, noProfileSwitch, "--project", projectFile.FullName, "--", .. args], + false => [watchOrRunCommand, nonInteractiveSwitch, verboseSwitch, noBuildSwitch, noRestoreSwitch, noProfileSwitch, "--project", projectFile.FullName, "--", .. args], true => ["run", noProfileSwitch, "--file", projectFile.FullName, "--", .. args] }; @@ -697,11 +698,13 @@ public async Task NewProjectAsync(string templateName, string name, string cancellationToken: cancellationToken); } - public async Task BuildAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public async Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(); - string[] cliArgs = ["build", projectFilePath.FullName]; + var noRestoreSwitch = noRestore ? "--no-restore" : string.Empty; + string[] cliArgs = ["build", noRestoreSwitch, projectFilePath.FullName]; + cliArgs = [.. cliArgs.Where(arg => !string.IsNullOrWhiteSpace(arg))]; // Always inject DOTNET_CLI_USE_MSBUILD_SERVER for apphost builds var env = new Dictionary diff --git a/src/Aspire.Cli/Projects/AppHostProjectContext.cs b/src/Aspire.Cli/Projects/AppHostProjectContext.cs index 2fba071ef90..d37af5174f6 100644 --- a/src/Aspire.Cli/Projects/AppHostProjectContext.cs +++ b/src/Aspire.Cli/Projects/AppHostProjectContext.cs @@ -32,6 +32,11 @@ internal sealed class AppHostProjectContext /// public bool NoBuild { get; init; } + /// + /// Gets whether to skip restoring packages before running. + /// + public bool NoRestore { get; init; } + /// /// Gets whether to wait for a debugger to attach. /// diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index f47686fa1a3..7ee32718103 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -239,7 +239,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken try { - if (!watch) + if (!watch && !context.NoBuild) { // Build in CLI if either not running under extension host, or the extension reports 'build-dotnet-using-cli' capability. var extensionHasBuildCapability = extensionBackchannel is not null && await extensionBackchannel.HasCapabilityAsync(KnownCapabilities.BuildDotnetUsingCli, cancellationToken); @@ -252,7 +252,7 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken StandardErrorCallback = buildOutputCollector.AppendError, }; - var buildExitCode = await AppHostHelper.BuildAppHostAsync(_runner, _interactionService, effectiveAppHostFile, buildOptions, context.WorkingDirectory, cancellationToken); + var buildExitCode = await AppHostHelper.BuildAppHostAsync(_runner, _interactionService, effectiveAppHostFile, context.NoRestore, buildOptions, context.WorkingDirectory, cancellationToken); if (buildExitCode != 0) { @@ -314,10 +314,14 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken // Start the apphost - the runner will signal the backchannel when ready try { + // noBuild: true if either watch mode is off (we already built above) or --no-build was passed + // noRestore: only relevant when noBuild is false (since --no-build implies --no-restore) + var noBuild = !watch || context.NoBuild; return await _runner.RunAsync( effectiveAppHostFile, watch, - !watch, + noBuild, + context.NoRestore, context.UnmatchedTokens, env, backchannelCompletionSource, @@ -386,30 +390,34 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca throw exception; } - // Build the apphost - var buildOutputCollector = new OutputCollector(_fileLoggerProvider, "Build"); - var buildOptions = new DotNetCliRunnerInvocationOptions + // Build the apphost (unless --no-build is specified) + if (!context.NoBuild) { - StandardOutputCallback = buildOutputCollector.AppendOutput, - StandardErrorCallback = buildOutputCollector.AppendError, - }; - - var buildExitCode = await AppHostHelper.BuildAppHostAsync( - _runner, - _interactionService, - effectiveAppHostFile, - buildOptions, - context.WorkingDirectory, - cancellationToken); - - if (buildExitCode != 0) - { - // Set OutputCollector so PipelineCommandBase can display errors - context.OutputCollector = buildOutputCollector; - // Signal the backchannel completion source so the caller doesn't wait forever - context.BackchannelCompletionSource?.TrySetException( - new InvalidOperationException("The app host build failed.")); - return ExitCodeConstants.FailedToBuildArtifacts; + var buildOutputCollector = new OutputCollector(_fileLoggerProvider, "Build"); + var buildOptions = new DotNetCliRunnerInvocationOptions + { + StandardOutputCallback = buildOutputCollector.AppendOutput, + StandardErrorCallback = buildOutputCollector.AppendError, + }; + + var buildExitCode = await AppHostHelper.BuildAppHostAsync( + _runner, + _interactionService, + effectiveAppHostFile, + noRestore: false, + buildOptions, + context.WorkingDirectory, + cancellationToken); + + if (buildExitCode != 0) + { + // Set OutputCollector so PipelineCommandBase can display errors + context.OutputCollector = buildOutputCollector; + // Signal the backchannel completion source so the caller doesn't wait forever + context.BackchannelCompletionSource?.TrySetException( + new InvalidOperationException("The app host build failed.")); + return ExitCodeConstants.FailedToBuildArtifacts; + } } } @@ -434,6 +442,7 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca effectiveAppHostFile, watch: false, noBuild: true, + noRestore: false, context.Arguments, env, context.BackchannelCompletionSource, diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 3d32b0d4e95..47b8dcbc09e 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -425,7 +425,7 @@ await NuGetConfigMerger.CreateOrUpdateAsync( StandardErrorCallback = outputCollector.AppendError }; - var exitCode = await _dotNetCliRunner.BuildAsync(projectFile, options, cancellationToken); + var exitCode = await _dotNetCliRunner.BuildAsync(projectFile, noRestore: false, options, cancellationToken); return (exitCode == 0, outputCollector); } diff --git a/src/Aspire.Cli/Projects/IAppHostProject.cs b/src/Aspire.Cli/Projects/IAppHostProject.cs index a49916006d9..5a747b65b4a 100644 --- a/src/Aspire.Cli/Projects/IAppHostProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostProject.cs @@ -120,6 +120,11 @@ internal sealed class PublishContext /// Gets whether debug logging is enabled. /// public bool Debug { get; init; } + + /// + /// Gets whether to skip building before running. + /// + public bool NoBuild { get; init; } } /// diff --git a/src/Aspire.Cli/Resources/PublishCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/PublishCommandStrings.Designer.cs index ea6b806d380..20a423db950 100644 --- a/src/Aspire.Cli/Resources/PublishCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/PublishCommandStrings.Designer.cs @@ -86,7 +86,16 @@ public static string InputPromptLoading { return ResourceManager.GetString("InputPromptLoading", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Do not build or restore the project before running.. + /// + public static string NoBuildArgumentDescription { + get { + return ResourceManager.GetString("NoBuildArgumentDescription", resourceCulture); + } + } + /// /// Looks up a localized string similar to PUBLISHING COMPLETED. /// diff --git a/src/Aspire.Cli/Resources/PublishCommandStrings.resx b/src/Aspire.Cli/Resources/PublishCommandStrings.resx index 07b69b9b049..56757121332 100644 --- a/src/Aspire.Cli/Resources/PublishCommandStrings.resx +++ b/src/Aspire.Cli/Resources/PublishCommandStrings.resx @@ -141,4 +141,7 @@ Loading... + + Do not build or restore the project before running. + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs index d0a953c7850..5a58b653555 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs @@ -296,5 +296,17 @@ public static string IsolatedModeRunningInstanceWarning { return ResourceManager.GetString("IsolatedModeRunningInstanceWarning", resourceCulture); } } + + public static string NoBuildArgumentDescription { + get { + return ResourceManager.GetString("NoBuildArgumentDescription", resourceCulture); + } + } + + public static string NoBuildNotSupportedWithWatchMode { + get { + return ResourceManager.GetString("NoBuildNotSupportedWithWatchMode", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.resx b/src/Aspire.Cli/Resources/RunCommandStrings.resx index 10f852c8d69..3b5ca858c10 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.resx +++ b/src/Aspire.Cli/Resources/RunCommandStrings.resx @@ -247,4 +247,10 @@ A running instance of this AppHost was found and will be stopped. To run multiple isolated instances simultaneously, run from different directories such as git worktree directories. + + Do not build or restore the project before running. + + + The --no-build option cannot be used when watch mode is enabled. + diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.cs.xlf index 9c1ff238553..00ca6129328 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.cs.xlf @@ -17,6 +17,11 @@ Probíhá načítání... + + Do not build or restore the project before running. + Do not build or restore the project before running. + + PUBLISHING COMPLETED PUBLIKOVÁNÍ DOKONČENO diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.de.xlf index b685b072bfc..6a20e6f7d7e 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.de.xlf @@ -17,6 +17,11 @@ Wird geladen... + + Do not build or restore the project before running. + Do not build or restore the project before running. + + PUBLISHING COMPLETED VERÖFFENTLICHUNG ABGESCHLOSSEN diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.es.xlf index a0a44a758cc..3f5f63fa488 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.es.xlf @@ -17,6 +17,11 @@ Cargando... + + Do not build or restore the project before running. + Do not build or restore the project before running. + + PUBLISHING COMPLETED PUBLICACIÓN COMPLETADA diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.fr.xlf index ad4ce7d5a9b..4835622a816 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.fr.xlf @@ -17,6 +17,11 @@ Chargement en cours… Merci de patienter. + + Do not build or restore the project before running. + Do not build or restore the project before running. + + PUBLISHING COMPLETED PUBLICATION EFFECTUÉE diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.it.xlf index 00593b88edf..b32fc0d550d 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.it.xlf @@ -17,6 +17,11 @@ Caricamento in corso... + + Do not build or restore the project before running. + Do not build or restore the project before running. + + PUBLISHING COMPLETED PUBBLICAZIONE COMPLETATA diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ja.xlf index f7a42d25e90..696d80a2bc0 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ja.xlf @@ -17,6 +17,11 @@ 読み込んでいます... + + Do not build or restore the project before running. + Do not build or restore the project before running. + + PUBLISHING COMPLETED 発行が完了しました diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ko.xlf index 84c73105644..68bc80ace63 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ko.xlf @@ -17,6 +17,11 @@ 로드 중... + + Do not build or restore the project before running. + Do not build or restore the project before running. + + PUBLISHING COMPLETED 게시 완료 diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pl.xlf index b6f5ed5a5b1..8aaff03bbe7 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pl.xlf @@ -17,6 +17,11 @@ Trwa ładowanie... + + Do not build or restore the project before running. + Do not build or restore the project before running. + + PUBLISHING COMPLETED UKOŃCZONO PUBLIKOWANIE diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pt-BR.xlf index e916d9d3cb4..bece3c5fb38 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pt-BR.xlf @@ -17,6 +17,11 @@ Carregando... + + Do not build or restore the project before running. + Do not build or restore the project before running. + + PUBLISHING COMPLETED PUBLICAÇÃO CONCLUÍDA diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ru.xlf index 74bb3aea4b4..2df80db04cb 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ru.xlf @@ -17,6 +17,11 @@ Загрузка… + + Do not build or restore the project before running. + Do not build or restore the project before running. + + PUBLISHING COMPLETED ПУБЛИКАЦИЯ ЗАВЕРШЕНА diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.tr.xlf index 457149650f9..266e4b02e1a 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.tr.xlf @@ -17,6 +17,11 @@ Yükleniyor... + + Do not build or restore the project before running. + Do not build or restore the project before running. + + PUBLISHING COMPLETED YAYIMLAMA TAMAMLANDI diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hans.xlf index d2465731b90..984381ed93e 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hans.xlf @@ -17,6 +17,11 @@ 正在加载... + + Do not build or restore the project before running. + Do not build or restore the project before running. + + PUBLISHING COMPLETED 已完成发布 diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hant.xlf index ae580c41ae1..b3bd4e87174 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hant.xlf @@ -17,6 +17,11 @@ 正在載入... + + Do not build or restore the project before running. + Do not build or restore the project before running. + + PUBLISHING COMPLETED 發佈完成 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf index 0f0cec9a525..454f6dc920c 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf @@ -132,6 +132,16 @@ Protokoly + + Do not build or restore the project before running. + Do not build or restore the project before running. + + + + The --no-build option cannot be used when watch mode is enabled. + The --no-build option cannot be used when watch mode is enabled. + + Press [bold]CTRL+C[/] to stop the apphost and exit. Stisknutím kláves [bold]Ctrl+C[/] zastavíte hostitele aplikací a provedete ukončení. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf index b871e5ddc75..37a94547020 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf @@ -132,6 +132,16 @@ Protokolle + + Do not build or restore the project before running. + Do not build or restore the project before running. + + + + The --no-build option cannot be used when watch mode is enabled. + The --no-build option cannot be used when watch mode is enabled. + + Press [bold]CTRL+C[/] to stop the apphost and exit. Drücken Sie [bold]STRG+C[/], um den App-Host zu beenden und zu verlassen. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf index 5311149418b..22f5ddcb6dd 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf @@ -132,6 +132,16 @@ Registros + + Do not build or restore the project before running. + Do not build or restore the project before running. + + + + The --no-build option cannot be used when watch mode is enabled. + The --no-build option cannot be used when watch mode is enabled. + + Press [bold]CTRL+C[/] to stop the apphost and exit. Presione [bold]CTRL+C[/] para detener el apphost y salir. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf index 35f1597ed11..adaaed0befc 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf @@ -132,6 +132,16 @@ Journaux + + Do not build or restore the project before running. + Do not build or restore the project before running. + + + + The --no-build option cannot be used when watch mode is enabled. + The --no-build option cannot be used when watch mode is enabled. + + Press [bold]CTRL+C[/] to stop the apphost and exit. Appuyez sur [bold]CTRL+C[/] pour arrêter l’apphost et quitter. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf index 8ad9e2f6fc0..df0aadbf184 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf @@ -132,6 +132,16 @@ Log + + Do not build or restore the project before running. + Do not build or restore the project before running. + + + + The --no-build option cannot be used when watch mode is enabled. + The --no-build option cannot be used when watch mode is enabled. + + Press [bold]CTRL+C[/] to stop the apphost and exit. Premere [bold]CTRL+C[/] per arrestare l'apphost e uscire. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf index 456e326c43e..79e19c80b02 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf @@ -132,6 +132,16 @@ ログ + + Do not build or restore the project before running. + Do not build or restore the project before running. + + + + The --no-build option cannot be used when watch mode is enabled. + The --no-build option cannot be used when watch mode is enabled. + + Press [bold]CTRL+C[/] to stop the apphost and exit. [bold]Ctrl+C[/] キーを押して AppHost を停止して終了します。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf index 2b77fb08792..f88d2ab250b 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf @@ -132,6 +132,16 @@ 로그 + + Do not build or restore the project before running. + Do not build or restore the project before running. + + + + The --no-build option cannot be used when watch mode is enabled. + The --no-build option cannot be used when watch mode is enabled. + + Press [bold]CTRL+C[/] to stop the apphost and exit. 앱 호스트를 중지하고 종료하려면 [bold]CTRL+C[/]를 누르세요. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf index 32e5ee5335e..3d299fe03aa 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf @@ -132,6 +132,16 @@ Dzienniki + + Do not build or restore the project before running. + Do not build or restore the project before running. + + + + The --no-build option cannot be used when watch mode is enabled. + The --no-build option cannot be used when watch mode is enabled. + + Press [bold]CTRL+C[/] to stop the apphost and exit. Naciśnij klawisze [bold]CTRL+C[/], aby zatrzymać hosta aplikacji i zakończyć. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf index 51e822915e0..68c69dbc585 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf @@ -132,6 +132,16 @@ Logs + + Do not build or restore the project before running. + Do not build or restore the project before running. + + + + The --no-build option cannot be used when watch mode is enabled. + The --no-build option cannot be used when watch mode is enabled. + + Press [bold]CTRL+C[/] to stop the apphost and exit. Pressione [bold]CTRL+C[/] para interromper o apphost e sair. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf index 308dfdc0a1c..f92d6c1056b 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf @@ -132,6 +132,16 @@ Журналы + + Do not build or restore the project before running. + Do not build or restore the project before running. + + + + The --no-build option cannot be used when watch mode is enabled. + The --no-build option cannot be used when watch mode is enabled. + + Press [bold]CTRL+C[/] to stop the apphost and exit. Нажмите клавиши [bold]CTRL+C[/], чтобы остановить хост приложений и выйти. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf index 52fbacb96f3..f1802c2992a 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf @@ -132,6 +132,16 @@ Günlükler + + Do not build or restore the project before running. + Do not build or restore the project before running. + + + + The --no-build option cannot be used when watch mode is enabled. + The --no-build option cannot be used when watch mode is enabled. + + Press [bold]CTRL+C[/] to stop the apphost and exit. Apphost'u durdurmak ve çıkmak için [bold]CTRL+C[/] tuşlarına basın. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf index 6aa572623b6..b23f5447f08 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf @@ -132,6 +132,16 @@ 日志 + + Do not build or restore the project before running. + Do not build or restore the project before running. + + + + The --no-build option cannot be used when watch mode is enabled. + The --no-build option cannot be used when watch mode is enabled. + + Press [bold]CTRL+C[/] to stop the apphost and exit. 按 [bold]Ctrl+C[/] 以停止应用主机并退出。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf index 097f37c223a..f165b8200a3 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf @@ -132,6 +132,16 @@ 記錄 + + Do not build or restore the project before running. + Do not build or restore the project before running. + + + + The --no-build option cannot be used when watch mode is enabled. + The --no-build option cannot be used when watch mode is enabled. + + Press [bold]CTRL+C[/] to stop the apphost and exit. 按 [bold]CTRL+C[/] 停止 apphost 並結束。 diff --git a/src/Aspire.Cli/Utils/AppHostHelper.cs b/src/Aspire.Cli/Utils/AppHostHelper.cs index 26cb04098d8..15c52614290 100644 --- a/src/Aspire.Cli/Utils/AppHostHelper.cs +++ b/src/Aspire.Cli/Utils/AppHostHelper.cs @@ -65,13 +65,14 @@ internal static class AppHostHelper return appHostInformationResult; } - internal static async Task BuildAppHostAsync(IDotNetCliRunner runner, IInteractionService interactionService, FileInfo projectFile, DotNetCliRunnerInvocationOptions options, DirectoryInfo workingDirectory, CancellationToken cancellationToken) + internal static async Task BuildAppHostAsync(IDotNetCliRunner runner, IInteractionService interactionService, FileInfo projectFile, bool noRestore, DotNetCliRunnerInvocationOptions options, DirectoryInfo workingDirectory, CancellationToken cancellationToken) { var relativePath = Path.GetRelativePath(workingDirectory.FullName, projectFile.FullName); return await interactionService.ShowStatusAsync( $":hammer_and_wrench: {InteractionServiceStrings.BuildingAppHost} {relativePath}", () => runner.BuildAsync( projectFile, + noRestore, options, cancellationToken)); } diff --git a/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs index 2283ba66494..fee7f2ccda8 100644 --- a/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs @@ -111,7 +111,7 @@ public async Task DeployCommandFailsWhenAppHostBuildFails() { var runner = new TestDotNetCliRunner { - BuildAsyncCallback = (projectFile, options, cancellationToken) => + BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => { return 1; // Simulate a build failure } @@ -146,7 +146,7 @@ public async Task DeployCommandSucceedsWithoutOutputPath() var runner = new TestDotNetCliRunner { // Simulate a successful build - BuildAsyncCallback = (projectFile, options, cancellationToken) => 0, + BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0, // Simulate a successful app host information retrieval GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => @@ -155,7 +155,7 @@ public async Task DeployCommandSucceedsWithoutOutputPath() }, // Simulate apphost running successfully and establishing a backchannel - RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, cancellationToken) => + RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => { Assert.True(options.NoLaunchProfile); @@ -214,7 +214,7 @@ public async Task DeployCommandSucceedsEndToEnd() var runner = new TestDotNetCliRunner { // Simulate a successful build - BuildAsyncCallback = (projectFile, options, cancellationToken) => 0, + BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0, // Simulate a successful app host information retrieval GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => @@ -223,7 +223,7 @@ public async Task DeployCommandSucceedsEndToEnd() }, // Simulate apphost running successfully and establishing a backchannel - RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, cancellationToken) => + RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => { Assert.True(options.NoLaunchProfile); @@ -287,7 +287,7 @@ public async Task DeployCommandIncludesDeployFlagInArguments() var runner = new TestDotNetCliRunner { // Simulate a successful build - BuildAsyncCallback = (projectFile, options, cancellationToken) => 0, + BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0, // Simulate a successful app host information retrieval GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => @@ -296,7 +296,7 @@ public async Task DeployCommandIncludesDeployFlagInArguments() }, // Simulate apphost running and verify --step deploy flag is passed - RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, cancellationToken) => + RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => { Assert.Contains("--operation", args); Assert.Contains("publish", args); @@ -355,7 +355,7 @@ public async Task DeployCommandReturnsNonZeroExitCodeWhenDeploymentFails() var runner = new TestDotNetCliRunner { // Simulate a successful build - BuildAsyncCallback = (projectFile, options, cancellationToken) => 0, + BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0, // Simulate a successful app host information retrieval GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => @@ -364,7 +364,7 @@ public async Task DeployCommandReturnsNonZeroExitCodeWhenDeploymentFails() }, // Simulate apphost running but deployment fails - RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, cancellationToken) => + RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => { var deployModeCompleted = new TaskCompletionSource(); var backchannel = new TestAppHostBackchannel diff --git a/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs index 0049e802c40..5e9496925b5 100644 --- a/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DoCommandTests.cs @@ -42,7 +42,7 @@ public async Task DoCommandWithStepArgumentSucceeds() var runner = new TestDotNetCliRunner { // Simulate a successful build - BuildAsyncCallback = (projectFile, options, cancellationToken) => 0, + BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0, // Simulate a successful app host information retrieval GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => @@ -51,7 +51,7 @@ public async Task DoCommandWithStepArgumentSucceeds() }, // Simulate apphost running successfully and establishing a backchannel - RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, cancellationToken) => + RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => { Assert.True(options.NoLaunchProfile); @@ -99,14 +99,14 @@ public async Task DoCommandWithDeployStepSucceeds() { var runner = new TestDotNetCliRunner { - BuildAsyncCallback = (projectFile, options, cancellationToken) => 0, + BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0, GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => { return (0, true, VersionHelper.GetDefaultTemplateVersion()); }, - RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, cancellationToken) => + RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => { // Verify that --step deploy is passed Assert.Contains("--step", args); @@ -152,14 +152,14 @@ public async Task DoCommandWithPublishStepSucceeds() { var runner = new TestDotNetCliRunner { - BuildAsyncCallback = (projectFile, options, cancellationToken) => 0, + BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0, GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => { return (0, true, VersionHelper.GetDefaultTemplateVersion()); }, - RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, cancellationToken) => + RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => { // Verify that --step publish is passed Assert.Contains("--step", args); @@ -205,14 +205,14 @@ public async Task DoCommandPassesOutputPathWhenSpecified() { var runner = new TestDotNetCliRunner { - BuildAsyncCallback = (projectFile, options, cancellationToken) => 0, + BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0, GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => { return (0, true, VersionHelper.GetDefaultTemplateVersion()); }, - RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, cancellationToken) => + RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => { // Verify output path is included Assert.Contains("--output-path", args); diff --git a/tests/Aspire.Cli.Tests/Commands/ExecCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ExecCommandTests.cs index 3a07e800bed..25570f002a9 100644 --- a/tests/Aspire.Cli.Tests/Commands/ExecCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ExecCommandTests.cs @@ -149,7 +149,7 @@ public async Task ExecCommand_ExecutesSuccessfully() options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner { - RunAsyncCallback = (projectFile, watch, noBuild, args, env, backchannelCompletionSource, runnerOptions, cancellationToken) => + RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, runnerOptions, cancellationToken) => { var backchannel = new TestAppHostBackchannel(); backchannelCompletionSource?.SetResult(backchannel); diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 0d07a8c2aa7..9931df588db 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -574,7 +574,7 @@ private static TestDotNetCliRunner CreateTestRunnerWithPromptBackchannel(TestPro var runner = new TestDotNetCliRunner(); // Simulate successful build - runner.BuildAsyncCallback = (projectFile, options, cancellationToken) => 0; + runner.BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0; // Simulate compatible app host runner.GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => @@ -583,7 +583,7 @@ private static TestDotNetCliRunner CreateTestRunnerWithPromptBackchannel(TestPro }; // Simulate successful app host run with the prompt backchannel - runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, cancellationToken) => + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => { backchannelCompletionSource?.SetResult(promptBackchannel); await promptBackchannel.WaitForCompletion().DefaultTimeout(); diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandTests.cs index 9f6e8bc1690..a1cf70beea0 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandTests.cs @@ -99,7 +99,7 @@ public async Task PublishCommandFailsWhenAppHostBuildFails() options.DotNetCliRunnerFactory = (sp) => { var runner = new TestDotNetCliRunner(); - runner.BuildAsyncCallback = (projectFile, options, cancellationToken) => + runner.BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => { return 1; // Simulate a build failure }; @@ -132,10 +132,10 @@ public async Task PublishCommandFailsWhenAppHostCrashesBeforeBackchannelEstablis var runner = new TestDotNetCliRunner(); // Simulate a successful build - runner.BuildAsyncCallback = (projectFile, options, cancellationToken) => 0; + runner.BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0; // Simulate apphost starting but crashing before backchannel is established - runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, cancellationToken) => + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => { // Simulate a delay to mimic apphost starting await Task.Delay(100, cancellationToken); @@ -175,7 +175,7 @@ public async Task PublishCommandSucceedsEndToEnd() var runner = new TestDotNetCliRunner(); // Simulate a successful build - runner.BuildAsyncCallback = (projectFile, options, cancellationToken) => 0; + runner.BuildAsyncCallback = (projectFile, noRestore, options, cancellationToken) => 0; // Simulate a successful app host information retrieval runner.GetAppHostInformationAsyncCallback = (projectFile, options, cancellationToken) => @@ -184,7 +184,7 @@ public async Task PublishCommandSucceedsEndToEnd() }; // Simulate apphost running successfully and establishing a backchannel - runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, cancellationToken) => + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) => { Assert.True(options.NoLaunchProfile); diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index a567ffc8b33..2a84fbbe614 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -204,13 +204,13 @@ public async Task RunCommand_CompletesSuccessfully() { var runner = new TestDotNetCliRunner(); // Fake the build command to always succeed. - runner.BuildAsyncCallback = (projectFile, options, ct) => 0; + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0; // Fake apphost information to return a compatable app host. runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); // public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, CancellationToken cancellationToken) - runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, ct) => + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => { // Make a backchannel and return it, but don't return from the run call until the backchannel var backchannel = sp.GetRequiredService(); @@ -266,10 +266,10 @@ public async Task RunCommand_WithNoResources_CompletesSuccessfully() var runnerFactory = (IServiceProvider sp) => { var runner = new TestDotNetCliRunner(); - runner.BuildAsyncCallback = (projectFile, options, ct) => 0; + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0; runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); - runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, ct) => + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => { var backchannel = sp.GetRequiredService(); backchannelCompletionSource!.SetResult(backchannel); @@ -329,13 +329,13 @@ public async Task RunCommand_WhenDashboardFailsToStart_ReturnsNonZeroExitCodeWit { var runner = new TestDotNetCliRunner(); // Fake the build command to always succeed. - runner.BuildAsyncCallback = (projectFile, options, ct) => 0; + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0; // Fake apphost information to return a compatible app host. runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); // Configure the runner to establish a backchannel but simulate dashboard failure - runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, ct) => + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => { // Set up the backchannel var backchannel = sp.GetRequiredService(); @@ -389,7 +389,7 @@ public async Task AppHostHelper_BuildAppHostAsync_IncludesRelativePathInStatusMe }; var testRunner = new TestDotNetCliRunner(); - testRunner.BuildAsyncCallback = (projectFile, options, ct) => 0; + testRunner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0; using var workspace = TemporaryWorkspace.Create(outputHelper); var appHostDirectoryPath = Path.Combine(workspace.WorkspaceRoot.FullName, "src", "MyApp.AppHost"); @@ -399,7 +399,7 @@ public async Task AppHostHelper_BuildAppHostAsync_IncludesRelativePathInStatusMe File.WriteAllText(appHostProjectFile.FullName, ""); var options = new DotNetCliRunnerInvocationOptions(); - await AppHostHelper.BuildAppHostAsync(testRunner, testInteractionService, appHostProjectFile, options, workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout(); + await AppHostHelper.BuildAppHostAsync(testRunner, testInteractionService, appHostProjectFile, noRestore: false, options, workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout(); } [Fact] @@ -427,13 +427,13 @@ public async Task RunCommand_SkipsBuild_WhenExtensionDevKitCapabilityIsAvailable var runnerFactory = (IServiceProvider sp) => { var runner = new TestDotNetCliRunner(); - runner.BuildAsyncCallback = (projectFile, options, ct) => + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => { buildCalled = true; return 0; }; runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); - runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, ct) => + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => { var backchannel = sp.GetRequiredService(); backchannelCompletionSource!.SetResult(backchannel); @@ -497,13 +497,13 @@ public async Task RunCommand_SkipsBuild_WhenRunningInExtension_AndNoBuildInCliCa var runnerFactory = (IServiceProvider sp) => { var runner = new TestDotNetCliRunner(); - runner.BuildAsyncCallback = (projectFile, options, ct) => + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => { buildCalled = true; return 0; }; runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); - runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, ct) => + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => { var backchannel = sp.GetRequiredService(); backchannelCompletionSource!.SetResult(backchannel); @@ -567,13 +567,13 @@ public async Task RunCommand_Builds_WhenExtensionHasBuildDotnetUsingCliCapabilit var runnerFactory = (IServiceProvider sp) => { var runner = new TestDotNetCliRunner(); - runner.BuildAsyncCallback = (projectFile, options, ct) => { + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => { buildCalled = true; buildCalledTcs.TrySetResult(); return 0; }; runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); - runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, ct) => { + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => { var backchannel = sp.GetRequiredService(); backchannelCompletionSource!.SetResult(backchannel); await Task.Delay(Timeout.InfiniteTimeSpan, ct); @@ -626,12 +626,12 @@ public async Task RunCommand_WhenSingleFileAppHostAndDefaultWatchEnabled_DoesNot { var runner = new TestDotNetCliRunner(); // Fake the build command to always succeed. - runner.BuildAsyncCallback = (projectFile, options, ct) => 0; + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0; // Fake apphost information to return a compatible app host. runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); - runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, ct) => + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => { watchModeUsed = watch; // Make a backchannel and return it @@ -683,12 +683,12 @@ public async Task RunCommand_WhenDefaultWatchEnabledFeatureFlagIsTrue_UsesWatchM { var runner = new TestDotNetCliRunner(); // Fake the build command to always succeed. - runner.BuildAsyncCallback = (projectFile, options, ct) => 0; + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0; // Fake apphost information to return a compatible app host. runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); - runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, ct) => + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => { watchModeUsed = watch; // Make a backchannel and return it @@ -742,12 +742,12 @@ public async Task RunCommand_WhenDefaultWatchEnabledFeatureFlagIsFalse_DoesNotUs { var runner = new TestDotNetCliRunner(); // Fake the build command to always succeed. - runner.BuildAsyncCallback = (projectFile, options, ct) => 0; + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0; // Fake apphost information to return a compatible app host. runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); - runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, ct) => + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => { watchModeUsed = watch; // Make a backchannel and return it @@ -801,12 +801,12 @@ public async Task RunCommand_WhenDefaultWatchEnabledFeatureFlagNotSet_DefaultsTo { var runner = new TestDotNetCliRunner(); // Fake the build command to always succeed. - runner.BuildAsyncCallback = (projectFile, options, ct) => 0; + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0; // Fake apphost information to return a compatible app host. runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); - runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, ct) => + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => { watchModeUsed = watch; // Make a backchannel and return it @@ -890,6 +890,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrue_IncludesNonInteractiv projectFile: projectFile, watch: true, // This should add --non-interactive noBuild: false, + noRestore: false, args: ["--operation", "inspect"], env: new Dictionary(), null, @@ -935,6 +936,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsFalse_DoesNotIncludeNonInt projectFile: projectFile, watch: false, // This should NOT add --non-interactive noBuild: false, + noRestore: false, args: ["--operation", "inspect"], env: new Dictionary(), null, @@ -984,6 +986,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrueAndDebugIsTrue_Include projectFile: projectFile, watch: true, // This should add --verbose when debug is true noBuild: false, + noRestore: false, args: ["--operation", "inspect"], env: new Dictionary(), null, @@ -1028,6 +1031,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrueAndDebugIsFalse_DoesNo projectFile: projectFile, watch: true, // This should NOT add --verbose when debug is false noBuild: false, + noRestore: false, args: ["--operation", "inspect"], env: new Dictionary(), null, @@ -1073,6 +1077,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsFalseAndDebugIsTrue_DoesNo projectFile: projectFile, watch: false, // This should NOT add --verbose because it's not in watch mode noBuild: false, + noRestore: false, args: ["--operation", "inspect"], env: new Dictionary(), null, @@ -1118,6 +1123,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrue_SetsSuppressLaunchBro projectFile: projectFile, watch: true, // This should set DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER=true noBuild: false, + noRestore: false, args: ["--operation", "inspect"], env: new Dictionary(), null, @@ -1164,6 +1170,7 @@ public async Task DotNetCliRunner_RunAsync_WhenWatchIsFalse_DoesNotSetSuppressLa projectFile: projectFile, watch: false, // This should NOT set DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER noBuild: false, + noRestore: false, args: ["--operation", "inspect"], env: new Dictionary(), null, @@ -1203,6 +1210,67 @@ public void RunCommand_RunningInstanceDetectionFeatureFlag_DefaultsToFalse() Assert.True(isEnabled, "Running instance detection should be enabled by default"); } + [Fact] + public async Task RunCommand_WithNoBuildOption_SkipsBuildAndPassesNoBuildAndNoRestoreToRunner() + { + var buildCalled = false; + var noBuildPassedToRunner = false; + var noRestorePassedToRunner = false; + + var backchannelFactory = (IServiceProvider sp) => + { + var backchannel = new TestAppHostBackchannel(); + backchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync; + return backchannel; + }; + + var runnerFactory = (IServiceProvider sp) => + { + var runner = new TestDotNetCliRunner(); + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => + { + buildCalled = true; + return 0; + }; + runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); + + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => + { + noBuildPassedToRunner = noBuild; + noRestorePassedToRunner = noRestore; + var backchannel = sp.GetRequiredService(); + backchannelCompletionSource!.SetResult(backchannel); + await Task.Delay(100, ct); + return 0; + }; + + return runner; + }; + + var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator(); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ProjectLocatorFactory = projectLocatorFactory; + options.AppHostBackchannelFactory = backchannelFactory; + options.DotNetCliRunnerFactory = runnerFactory; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("run --no-build"); + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromSeconds(2)); + + var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout(); + + Assert.False(buildCalled, "Build should be skipped when --no-build is specified"); + Assert.True(noBuildPassedToRunner, "noBuild=true should be passed to the runner when --no-build is specified"); + Assert.True(noRestorePassedToRunner, "noRestore=true should be passed to the runner when --no-build is specified (--no-build implies --no-restore)"); + } + [Fact] public async Task RunCommand_WithIsolatedOption_SetsRandomizePortsAndIsolatesUserSecrets() { @@ -1227,7 +1295,7 @@ public async Task RunCommand_WithIsolatedOption_SetsRandomizePortsAndIsolatesUse var runnerFactory = (IServiceProvider sp) => { var runner = new TestDotNetCliRunner(); - runner.BuildAsyncCallback = (projectFile, options, ct) => 0; + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0; runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); // Return UserSecretsId when GetProjectItemsAndPropertiesAsync is called @@ -1238,7 +1306,7 @@ public async Task RunCommand_WithIsolatedOption_SetsRandomizePortsAndIsolatesUse return (0, doc); }; - runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, ct) => + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => { // Capture environment variables tcs.SetResult(env?.ToDictionary() ?? []); @@ -1301,4 +1369,50 @@ public async Task RunCommand_WithIsolatedOption_SetsRandomizePortsAndIsolatesUse } } } + + [Fact] + public async Task RunCommand_WithNoBuildAndWatchModeEnabled_ReturnsInvalidCommandError() + { + // This test verifies that when --no-build is specified and watch mode is enabled + // (via feature flag), the CLI returns an error because this combination is not supported + // (dotnet watch doesn't support --no-build) + + var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator(); + + // Create a features factory that enables DefaultWatchEnabled + var featuresFactory = (IServiceProvider sp) => new TestFeatures() + .SetFeature(KnownFeatures.DefaultWatchEnabled, true); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ProjectLocatorFactory = projectLocatorFactory; + options.FeatureFlagsFactory = featuresFactory; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("run --no-build"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + // Should return InvalidCommand error because --no-build is not supported with watch mode enabled + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + private sealed class TestFeatures : IFeatures + { + private readonly Dictionary _features = new(); + + public TestFeatures SetFeature(string featureName, bool value) + { + _features[featureName] = value; + return this; + } + + public bool IsFeatureEnabled(string featureName, bool defaultValue = false) + { + return _features.TryGetValue(featureName, out var value) ? value : defaultValue; + } + } } diff --git a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs index 997dc6ab6ae..cbe916564e8 100644 --- a/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/DotNet/DotNetCliRunnerTests.cs @@ -55,6 +55,7 @@ public async Task DotNetCliCorrectlyAppliesNoLaunchProfileArgumentWhenSpecifiedI projectFile: projectFile, watch: false, noBuild: false, + noRestore: false, args: ["--operation", "inspect"], env: new Dictionary(), null, @@ -88,7 +89,7 @@ public async Task BuildAsyncAlwaysInjectsDotnetCliUseMsBuildServerEnvironmentVar }, 0); - var exitCode = await runner.BuildAsync(projectFile, options, CancellationToken.None).DefaultTimeout(); + var exitCode = await runner.BuildAsync(projectFile, noRestore: false, options, CancellationToken.None).DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -127,7 +128,65 @@ public async Task BuildAsyncUsesConfigurationValueForDotnetCliUseMsBuildServer() }, 0); - var exitCode = await runner.BuildAsync(projectFile, options, CancellationToken.None).DefaultTimeout(); + var exitCode = await runner.BuildAsync(projectFile, noRestore: false, options, CancellationToken.None).DefaultTimeout(); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task BuildAsyncIncludesNoRestoreFlagWhenNoRestoreIsTrue() + { + 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 services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var options = new DotNetCliRunnerInvocationOptions(); + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var runner = DotNetCliRunnerTestHelper.Create( + provider, + executionContext, + (args, env, _, _) => + { + // Verify that --no-restore is included when noRestore is true + Assert.Contains("build", args); + Assert.Contains("--no-restore", args); + }, + 0); + + var exitCode = await runner.BuildAsync(projectFile, noRestore: true, options, CancellationToken.None).DefaultTimeout(); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task BuildAsyncDoesNotIncludeNoRestoreFlagWhenNoRestoreIsFalse() + { + 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 services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var options = new DotNetCliRunnerInvocationOptions(); + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var runner = DotNetCliRunnerTestHelper.Create( + provider, + executionContext, + (args, env, _, _) => + { + // Verify that --no-restore is NOT included when noRestore is false + Assert.Contains("build", args); + Assert.DoesNotContain("--no-restore", args); + }, + 0); + + var exitCode = await runner.BuildAsync(projectFile, noRestore: false, options, CancellationToken.None).DefaultTimeout(); Assert.Equal(0, exitCode); } @@ -160,6 +219,7 @@ public async Task RunAsyncInjectsDotnetCliUseMsBuildServerWhenNoBuildIsFalse() projectFile: projectFile, watch: false, noBuild: false, // This should inject the environment variable + noRestore: false, args: ["--operation", "inspect"], env: new Dictionary(), null, @@ -200,6 +260,7 @@ public async Task RunAsyncDoesNotInjectDotnetCliUseMsBuildServerWhenNoBuildIsTru projectFile: projectFile, watch: false, noBuild: true, // This should NOT inject the environment variable + noRestore: false, args: ["--operation", "inspect"], env: new Dictionary(), null, @@ -245,6 +306,7 @@ public async Task RunAsyncPreservesExistingEnvironmentVariables() projectFile: projectFile, watch: false, noBuild: false, + noRestore: false, args: ["--operation", "inspect"], env: existingEnv, null, @@ -316,6 +378,7 @@ public async Task RunAsyncSetsVersionCheckDisabledWhenUpdateNotificationsFeature projectFile: projectFile, watch: false, noBuild: false, + noRestore: false, args: ["--operation", "inspect"], env: new Dictionary(), null, @@ -357,6 +420,7 @@ public async Task RunAsyncDoesNotSetVersionCheckDisabledWhenUpdateNotificationsF projectFile: projectFile, watch: false, noBuild: false, + noRestore: false, args: ["--operation", "inspect"], env: new Dictionary(), null, @@ -403,6 +467,7 @@ public async Task RunAsyncDoesNotOverrideUserProvidedVersionCheckDisabledValue() projectFile: projectFile, watch: false, noBuild: false, + noRestore: false, args: ["--operation", "inspect"], env: userEnv, null, @@ -449,6 +514,7 @@ public async Task RunAsyncLaunchesAppHostInExtensionHostIfConnected() projectFile: projectFile, watch: false, noBuild: false, + noRestore: false, args: [], env: null, backchannelCompletionSource: new TaskCompletionSource(), @@ -751,6 +817,7 @@ public async Task RunAsyncAppliesNoLaunchProfileForSingleFileAppHost() projectFile: appHostFile, watch: false, noBuild: false, + noRestore: false, args: [], env: new Dictionary(), null, @@ -795,6 +862,7 @@ public async Task RunAsyncDoesNotIncludeNoLaunchProfileForSingleFileAppHostWhenN projectFile: appHostFile, watch: false, noBuild: false, + noRestore: false, args: [], env: new Dictionary(), null, @@ -839,6 +907,7 @@ public async Task RunAsyncFiltersOutEmptyAndWhitespaceArguments() projectFile: projectFile, watch: true, // This will generate empty strings for verboseSwitch when Debug=false noBuild: false, + noRestore: false, args: [], env: new Dictionary(), null, @@ -889,6 +958,7 @@ public async Task RunAsyncFiltersOutEmptyArgumentsForSingleFileAppHost() projectFile: appHostFile, watch: false, noBuild: false, + noRestore: false, args: [], env: new Dictionary(), null, @@ -937,6 +1007,7 @@ public async Task RunAsyncIncludesAllNonEmptyFlagsWhenEnabled() projectFile: projectFile, watch: true, noBuild: false, + noRestore: false, args: [], env: new Dictionary(), null, @@ -984,6 +1055,7 @@ public async Task RunAsyncCorrectlyHandlesWatchWithoutDebug() projectFile: projectFile, watch: true, noBuild: false, + noRestore: false, args: [], env: new Dictionary(), null, @@ -1207,4 +1279,159 @@ public void TryParsePackageVersionFromStdout_ParsesCorrectly(string stdout, bool Assert.Equal(expectedResult, result); Assert.Equal(expectedVersion, version); } + + [Fact] + public async Task RunAsyncIncludesNoBuildFlagWhenNoBuildIsTrue() + { + 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 services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var options = new DotNetCliRunnerInvocationOptions(); + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var runner = DotNetCliRunnerTestHelper.Create( + provider, + executionContext, + (args, env, _, _) => + { + // Verify that --no-build is included when noBuild is true + Assert.Contains("run", args); + Assert.Contains("--no-build", args); + }, + 0); + + var exitCode = await runner.RunAsync( + projectFile: projectFile, + watch: false, + noBuild: true, // This should add --no-build + noRestore: false, + args: ["--operation", "inspect"], + env: new Dictionary(), + null, + options, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task RunAsyncDoesNotIncludeNoBuildFlagWhenNoBuildIsFalse() + { + 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 services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var options = new DotNetCliRunnerInvocationOptions(); + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var runner = DotNetCliRunnerTestHelper.Create( + provider, + executionContext, + (args, env, _, _) => + { + // Verify that --no-build is NOT included when noBuild is false + Assert.Contains("run", args); + Assert.DoesNotContain("--no-build", args); + }, + 0); + + var exitCode = await runner.RunAsync( + projectFile: projectFile, + watch: false, + noBuild: false, // This should NOT add --no-build + noRestore: false, + args: ["--operation", "inspect"], + env: new Dictionary(), + null, + options, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task RunAsyncIncludesNoRestoreFlagWhenNoRestoreIsTrueAndNoBuildIsFalse() + { + 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 services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var options = new DotNetCliRunnerInvocationOptions(); + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var runner = DotNetCliRunnerTestHelper.Create( + provider, + executionContext, + (args, env, _, _) => + { + // Verify that --no-restore is included when noRestore is true and noBuild is false + Assert.Contains("run", args); + Assert.Contains("--no-restore", args); + Assert.DoesNotContain("--no-build", args); + }, + 0); + + var exitCode = await runner.RunAsync( + projectFile: projectFile, + watch: false, + noBuild: false, + noRestore: true, // This should add --no-restore + args: ["--operation", "inspect"], + env: new Dictionary(), + null, + options, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task RunAsyncDoesNotIncludeNoRestoreFlagWhenNoBuildIsTrue() + { + // --no-build implies --no-restore, so we should not include --no-restore when --no-build is specified + 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 services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var options = new DotNetCliRunnerInvocationOptions(); + + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var runner = DotNetCliRunnerTestHelper.Create( + provider, + executionContext, + (args, env, _, _) => + { + // Verify that --no-restore is NOT included when noBuild is true (because --no-build implies --no-restore) + Assert.Contains("run", args); + Assert.Contains("--no-build", args); + Assert.DoesNotContain("--no-restore", args); + }, + 0); + + var exitCode = await runner.RunAsync( + projectFile: projectFile, + watch: false, + noBuild: true, // --no-build implies --no-restore + noRestore: true, // This should be ignored because noBuild is true + args: ["--operation", "inspect"], + env: new Dictionary(), + null, + options, + CancellationToken.None).DefaultTimeout(); + + Assert.Equal(0, exitCode); + } } diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 383cf49560c..bbd51fec810 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -454,7 +454,7 @@ private sealed class TestDotNetCliRunner : IDotNetCliRunner public Task NewProjectAsync(string templateName, string projectName, string outputPath, string[] extraArgs, DotNetCliRunnerInvocationOptions? options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task BuildAsync(FileInfo projectFile, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task BuildAsync(FileInfo projectFile, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task AddPackageAsync(FileInfo projectFile, string packageName, string version, string? packageSourceUrl, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) @@ -478,7 +478,7 @@ public Task AddProjectReferenceAsync(FileInfo projectFile, FileInfo referen public Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool noRestore, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs index 30b87c6ee52..94506b94fd8 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestDotNetCliRunner.cs @@ -13,13 +13,13 @@ internal sealed class TestDotNetCliRunner : IDotNetCliRunner { public Func? AddPackageAsyncCallback { get; set; } public Func? AddProjectToSolutionAsyncCallback { get; set; } - public Func? BuildAsyncCallback { get; set; } + public Func? BuildAsyncCallback { get; set; } public Func? GetAppHostInformationAsyncCallback { get; set; } public Func? GetNuGetConfigPathsAsyncCallback { get; set; } public Func? GetProjectItemsAndPropertiesAsyncCallback { get; set; } public Func? InstallTemplateAsyncCallback { get; set; } public Func? NewProjectAsyncCallback { get; set; } - public Func?, TaskCompletionSource?, DotNetCliRunnerInvocationOptions, CancellationToken, Task>? RunAsyncCallback { get; set; } + public Func?, TaskCompletionSource?, DotNetCliRunnerInvocationOptions, CancellationToken, Task>? RunAsyncCallback { get; set; } public Func? SearchPackagesAsyncCallback { get; set; } public Func Projects)>? GetSolutionProjectsAsyncCallback { get; set; } public Func? AddProjectReferenceAsyncCallback { get; set; } @@ -38,10 +38,10 @@ public Task AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo proje : Task.FromResult(0); // If not overridden, just return success. } - public Task BuildAsync(FileInfo projectFilePath, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task BuildAsync(FileInfo projectFilePath, bool noRestore, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { return BuildAsyncCallback != null - ? Task.FromResult(BuildAsyncCallback(projectFilePath, options, cancellationToken)) + ? Task.FromResult(BuildAsyncCallback(projectFilePath, noRestore, options, cancellationToken)) : throw new NotImplementedException(); } @@ -91,10 +91,10 @@ public Task NewProjectAsync(string templateName, string name, string output : Task.FromResult(0); // If not overridden, just return success. } - public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) + public Task RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool noRestore, string[] args, IDictionary? env, TaskCompletionSource? backchannelCompletionSource, DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { return RunAsyncCallback != null - ? RunAsyncCallback(projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, cancellationToken) + ? RunAsyncCallback(projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, cancellationToken) : throw new NotImplementedException(); } From 2b2d45f16ed273864d3021288103a1b621a413e8 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 11 Feb 2026 21:17:34 +1100 Subject: [PATCH 081/256] Fall back to code-insiders when code is not found in start-code scripts (#14399) * Fall back to code-insiders when code is not found in start-code scripts * Address PR feedback: validate explicit VSCODE_CMD and suppress where.exe stderr --------- Co-authored-by: Mitch Denny --- start-code.cmd | 32 +++++++++++++++++++++++++++++--- start-code.sh | 25 +++++++++++++++++++++---- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/start-code.cmd b/start-code.cmd index a05617f8b01..dadbaa906c0 100644 --- a/start-code.cmd +++ b/start-code.cmd @@ -1,12 +1,38 @@ @ECHO OFF -SETLOCAL +SETLOCAL EnableDelayedExpansion :: This command launches a Visual Studio Code with environment variables required to use a local version of the .NET Core SDK. :: Set VSCODE_CMD environment variable to use a different VS Code variant (e.g., code-insiders). -IF ["%VSCODE_CMD%"] == [""] SET VSCODE_CMD=code +IF NOT ["%VSCODE_CMD%"] == [""] GOTO find_vscode -FOR /f "delims=" %%a IN ('where.exe %VSCODE_CMD%') DO @SET vscode=%%a& GOTO break +:: Check which VS Code variants are available +SET _has_code= +SET _has_insiders= +FOR /f "delims=" %%a IN ('where.exe code 2^>nul') DO @SET _has_code=1 +FOR /f "delims=" %%a IN ('where.exe code-insiders 2^>nul') DO @SET _has_insiders=1 + +IF DEFINED _has_code IF DEFINED _has_insiders ( + echo Both 'code' and 'code-insiders' are installed. + echo 1^) code + echo 2^) code-insiders + SET /P _choice="Select [1]: " + IF NOT DEFINED _choice SET _choice=1 + IF "!_choice!"=="1" SET VSCODE_CMD=code& GOTO find_vscode + IF "!_choice!"=="2" SET VSCODE_CMD=code-insiders& GOTO find_vscode + echo [ERROR] Invalid selection. + exit /b 1 +) + +IF DEFINED _has_code SET VSCODE_CMD=code& GOTO find_vscode +IF DEFINED _has_insiders SET VSCODE_CMD=code-insiders& GOTO find_vscode + +echo [ERROR] Neither 'code' nor 'code-insiders' is installed or can't be found. +echo. +exit /b 1 + +:find_vscode +FOR /f "delims=" %%a IN ('where.exe %VSCODE_CMD% 2^>nul') DO @SET vscode=%%a& GOTO break :break IF ["%vscode%"] == [""] ( diff --git a/start-code.sh b/start-code.sh index 15fb930f934..d7cd3628d58 100755 --- a/start-code.sh +++ b/start-code.sh @@ -27,10 +27,27 @@ then set -- '.'; fi -VSCODE_CMD="${VSCODE_CMD:-code}" - -if ! command -v "$VSCODE_CMD" &> /dev/null; then - echo "[ERROR] $VSCODE_CMD is not installed or can't be found." +if [ -n "${VSCODE_CMD:-}" ]; then + if ! command -v "$VSCODE_CMD" &> /dev/null; then + echo "[ERROR] The specified VS Code command '$VSCODE_CMD' is not installed or cannot be found in PATH." + exit 1 + fi +elif command -v code &> /dev/null && command -v code-insiders &> /dev/null; then + echo "Both 'code' and 'code-insiders' are installed." + echo " 1) code" + echo " 2) code-insiders" + read -rp "Select [1]: " choice + case "${choice:-1}" in + 1) VSCODE_CMD="code" ;; + 2) VSCODE_CMD="code-insiders" ;; + *) echo "[ERROR] Invalid selection."; exit 1 ;; + esac +elif command -v code &> /dev/null; then + VSCODE_CMD="code" +elif command -v code-insiders &> /dev/null; then + VSCODE_CMD="code-insiders" +else + echo "[ERROR] Neither 'code' nor 'code-insiders' is installed or can be found." exit 1 fi From bdd98153219031e4bad22876b1b0e81ec0d63c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Wed, 11 Feb 2026 08:31:08 -0800 Subject: [PATCH 082/256] Add 'aspire wait' CLI command (#14419) * Add 'aspire wait' command for blocking until a resource reaches a target status Adds a new CLI command 'aspire wait ' that connects to a running AppHost and blocks until the specified resource reaches the desired status or a timeout is exceeded. Options: --status Target status (default: healthy) --timeout Maximum wait time (default: 120) --project Path to AppHost project file Exit codes: 0 - Resource reached desired status 17 - Timeout exceeded 18 - Resource entered terminal failure state 7 - Failed to find/connect to AppHost * Add CLI E2E test for wait command * Fix code review issues: health check race condition, resource-not-found detection, case-insensitive status, TimeProvider usage - Check HealthReports to distinguish 'no health checks' from 'pending health checks' - Validate resource exists via GetResourceSnapshotsAsync before entering wait loop - Normalize --status input to lowercase for case-insensitive matching - Use TimeProvider.GetTimestamp/GetElapsedTime instead of Stopwatch - Add 7 new functional tests covering all fixed scenarios * Fix resource-not-found: use streaming detection instead of upfront snapshot check The upfront GetResourceSnapshotsAsync check fails when the AppHost has just started and resources haven't been reported to the backchannel yet. Instead, track whether the resource appears in the watch stream and report not-found only when the stream completes without ever seeing the target resource. * Fix resource matching: compare against both Name and DisplayName, use --status up in E2E test - Match user-provided resource name against both snapshot.Name (ResourceId like 'webfrontend-abc123') and snapshot.DisplayName ('webfrontend') - Switch E2E test to --status up for reliability (avoids health check dependencies) * Move implementation to apphost * Fix json serialization * Fix WaitForRunningAsync doesn't use StopOnResourceUnavailable --- .../AppHostAuxiliaryBackchannel.cs | 37 +++ .../BackchannelJsonSerializerContext.cs | 2 + .../IAppHostAuxiliaryBackchannel.cs | 14 + src/Aspire.Cli/Commands/RootCommand.cs | 2 + src/Aspire.Cli/Commands/WaitCommand.cs | 178 +++++++++++ src/Aspire.Cli/ExitCodeConstants.cs | 2 + src/Aspire.Cli/Program.cs | 1 + src/Aspire.Cli/README.md | 39 +++ .../Resources/WaitCommandStrings.Designer.cs | 144 +++++++++ .../Resources/WaitCommandStrings.resx | 168 ++++++++++ .../Resources/xlf/WaitCommandStrings.cs.xlf | 87 +++++ .../Resources/xlf/WaitCommandStrings.de.xlf | 87 +++++ .../Resources/xlf/WaitCommandStrings.es.xlf | 87 +++++ .../Resources/xlf/WaitCommandStrings.fr.xlf | 87 +++++ .../Resources/xlf/WaitCommandStrings.it.xlf | 87 +++++ .../Resources/xlf/WaitCommandStrings.ja.xlf | 87 +++++ .../Resources/xlf/WaitCommandStrings.ko.xlf | 87 +++++ .../Resources/xlf/WaitCommandStrings.pl.xlf | 87 +++++ .../xlf/WaitCommandStrings.pt-BR.xlf | 87 +++++ .../Resources/xlf/WaitCommandStrings.ru.xlf | 87 +++++ .../Resources/xlf/WaitCommandStrings.tr.xlf | 87 +++++ .../xlf/WaitCommandStrings.zh-Hans.xlf | 87 +++++ .../xlf/WaitCommandStrings.zh-Hant.xlf | 87 +++++ .../AuxiliaryBackchannelRpcTarget.cs | 89 ++++++ .../Backchannel/BackchannelDataTypes.cs | 61 ++++ .../WaitCommandTests.cs | 133 ++++++++ .../Commands/WaitCommandTests.cs | 299 ++++++++++++++++++ .../TestAppHostAuxiliaryBackchannel.cs | 14 + tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 1 + 29 files changed, 2315 insertions(+) create mode 100644 src/Aspire.Cli/Commands/WaitCommand.cs create mode 100644 src/Aspire.Cli/Resources/WaitCommandStrings.Designer.cs create mode 100644 src/Aspire.Cli/Resources/WaitCommandStrings.resx create mode 100644 src/Aspire.Cli/Resources/xlf/WaitCommandStrings.cs.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/WaitCommandStrings.de.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/WaitCommandStrings.es.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/WaitCommandStrings.fr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/WaitCommandStrings.it.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ja.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ko.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pl.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pt-BR.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ru.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/WaitCommandStrings.tr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hans.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hant.xlf create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs create mode 100644 tests/Aspire.Cli.Tests/Commands/WaitCommandTests.cs diff --git a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs index d850db55c41..02c0cab6a27 100644 --- a/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs @@ -733,6 +733,43 @@ public async Task ExecuteResourceCommandAsync( return response; } + /// + public async Task WaitForResourceAsync( + string resourceName, + string status, + int timeoutSeconds, + CancellationToken cancellationToken = default) + { + if (!SupportsV2) + { + return new WaitForResourceResponse + { + Success = false, + ErrorMessage = "Wait command is not supported by the AppHost version. Update the AppHost to use this command." + }; + } + + var rpc = EnsureConnected(); + + _logger?.LogDebug("Waiting for resource '{ResourceName}' to reach status '{Status}' with timeout {Timeout}s", resourceName, status, timeoutSeconds); + + var request = new WaitForResourceRequest + { + ResourceName = resourceName, + Status = status, + TimeoutSeconds = timeoutSeconds + }; + + var response = await rpc.InvokeWithCancellationAsync( + "WaitForResourceAsync", + [request], + cancellationToken).ConfigureAwait(false); + + _logger?.LogDebug("Wait for resource '{ResourceName}' completed: success={Success}, state={State}", resourceName, response.Success, response.State); + + return response; + } + #endregion /// diff --git a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs index 682cbc656f9..815878ca712 100644 --- a/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs +++ b/src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs @@ -75,6 +75,8 @@ namespace Aspire.Cli.Backchannel; [JsonSerializable(typeof(StopAppHostResponse))] [JsonSerializable(typeof(ExecuteResourceCommandRequest))] [JsonSerializable(typeof(ExecuteResourceCommandResponse))] +[JsonSerializable(typeof(WaitForResourceRequest))] +[JsonSerializable(typeof(WaitForResourceResponse))] internal partial class BackchannelJsonSerializerContext : JsonSerializerContext { [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Using the Json source generator.")] diff --git a/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs b/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs index f7d91f26b6d..6c3d8448b9c 100644 --- a/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs @@ -119,4 +119,18 @@ Task ExecuteResourceCommandAsync( string resourceName, string commandName, CancellationToken cancellationToken = default); + + /// + /// Waits for a resource to reach a target status on the AppHost side. + /// + /// The name of the resource. + /// The target status ("up", "healthy", "down"). + /// The timeout in seconds. + /// Cancellation token. + /// The result of the wait operation. + Task WaitForResourceAsync( + string resourceName, + string status, + int timeoutSeconds, + CancellationToken cancellationToken = default); } diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 2246ed8a872..db956046a9a 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -110,6 +110,7 @@ public RootCommand( StopCommand stopCommand, StartCommand startCommand, RestartCommand restartCommand, + WaitCommand waitCommand, ResourceCommand commandCommand, PsCommand psCommand, ResourcesCommand resourcesCommand, @@ -189,6 +190,7 @@ public RootCommand( Subcommands.Add(stopCommand); Subcommands.Add(startCommand); Subcommands.Add(restartCommand); + Subcommands.Add(waitCommand); Subcommands.Add(commandCommand); Subcommands.Add(psCommand); Subcommands.Add(resourcesCommand); diff --git a/src/Aspire.Cli/Commands/WaitCommand.cs b/src/Aspire.Cli/Commands/WaitCommand.cs new file mode 100644 index 00000000000..ca89929a294 --- /dev/null +++ b/src/Aspire.Cli/Commands/WaitCommand.cs @@ -0,0 +1,178 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Globalization; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Resources; +using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Commands; + +internal sealed class WaitCommand : BaseCommand +{ + private readonly IInteractionService _interactionService; + private readonly AppHostConnectionResolver _connectionResolver; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + + private static readonly Argument s_resourceArgument = new("resource") + { + Description = WaitCommandStrings.ResourceArgumentDescription + }; + + private static readonly Option s_statusOption = new("--status") + { + Description = WaitCommandStrings.StatusOptionDescription, + DefaultValueFactory = _ => "healthy" + }; + + private static readonly Option s_timeoutOption = new("--timeout") + { + Description = WaitCommandStrings.TimeoutOptionDescription, + DefaultValueFactory = _ => 120 + }; + + private static readonly Option s_projectOption = new("--project") + { + Description = WaitCommandStrings.ProjectOptionDescription + }; + + public WaitCommand( + IInteractionService interactionService, + IAuxiliaryBackchannelMonitor backchannelMonitor, + IFeatures features, + ICliUpdateNotifier updateNotifier, + CliExecutionContext executionContext, + ILogger logger, + AspireCliTelemetry telemetry, + TimeProvider? timeProvider = null) + : base("wait", WaitCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) + { + _interactionService = interactionService; + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); + _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; + + Arguments.Add(s_resourceArgument); + Options.Add(s_statusOption); + Options.Add(s_timeoutOption); + Options.Add(s_projectOption); + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + using var activity = Telemetry.StartDiagnosticActivity(Name); + + var resourceName = parseResult.GetValue(s_resourceArgument)!; + var status = parseResult.GetValue(s_statusOption)!.ToLowerInvariant(); + var timeoutSeconds = parseResult.GetValue(s_timeoutOption); + var passedAppHostProjectFile = parseResult.GetValue(s_projectOption); + + // Validate status value + if (!IsValidStatus(status)) + { + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, WaitCommandStrings.InvalidStatusValue, status)); + return ExitCodeConstants.InvalidCommand; + } + + // Validate timeout + if (timeoutSeconds <= 0) + { + _interactionService.DisplayError(WaitCommandStrings.TimeoutMustBePositive); + return ExitCodeConstants.InvalidCommand; + } + + // Resolve connection to a running AppHost + var result = await _connectionResolver.ResolveConnectionAsync( + passedAppHostProjectFile, + WaitCommandStrings.ScanningForRunningAppHosts, + WaitCommandStrings.SelectAppHost, + WaitCommandStrings.NoInScopeAppHostsShowingAll, + WaitCommandStrings.NoRunningAppHostsFound, + cancellationToken); + + if (!result.Success) + { + _interactionService.DisplayError(result.ErrorMessage ?? WaitCommandStrings.NoRunningAppHostsFound); + return ExitCodeConstants.FailedToFindProject; + } + + var connection = result.Connection!; + + return await WaitForResourceAsync(connection, resourceName, status, timeoutSeconds, cancellationToken); + } + + private async Task WaitForResourceAsync( + IAppHostAuxiliaryBackchannel connection, + string resourceName, + string status, + int timeoutSeconds, + CancellationToken cancellationToken) + { + var statusLabel = GetStatusLabel(status); + + _logger.LogDebug("Waiting for resource '{ResourceName}' to reach status '{Status}' with timeout {Timeout}s", resourceName, status, timeoutSeconds); + + var startTimestamp = _timeProvider.GetTimestamp(); + + var exitCode = await _interactionService.ShowStatusAsync( + string.Format(CultureInfo.CurrentCulture, WaitCommandStrings.WaitingForResource, resourceName, statusLabel), + async () => + { + var response = await connection.WaitForResourceAsync(resourceName, status, timeoutSeconds, cancellationToken).ConfigureAwait(false); + + if (response.Success) + { + return ExitCodeConstants.Success; + } + + if (response.ResourceNotFound) + { + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, WaitCommandStrings.ResourceNotFound, resourceName)); + return ExitCodeConstants.WaitResourceFailed; + } + + if (response.TimedOut) + { + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, WaitCommandStrings.WaitTimedOut, resourceName, statusLabel, timeoutSeconds)); + return ExitCodeConstants.WaitTimeout; + } + + // Resource entered a failed state + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, WaitCommandStrings.ResourceEnteredFailedState, resourceName, response.State ?? response.ErrorMessage)); + return ExitCodeConstants.WaitResourceFailed; + }); + + // Reset cursor position after spinner + _interactionService.DisplayPlainText(""); + + if (exitCode == ExitCodeConstants.Success) + { + var elapsed = _timeProvider.GetElapsedTime(startTimestamp); + _interactionService.DisplaySuccess(string.Format(CultureInfo.CurrentCulture, WaitCommandStrings.ResourceReachedTargetStatus, resourceName, statusLabel, elapsed.TotalSeconds)); + } + + return exitCode; + } + + private static bool IsValidStatus(string status) + { + return status is "healthy" or "up" or "down"; + } + + private static string GetStatusLabel(string status) + { + return status switch + { + "up" => "up (running)", + "healthy" => "healthy", + "down" => "down", + _ => status + }; + } +} diff --git a/src/Aspire.Cli/ExitCodeConstants.cs b/src/Aspire.Cli/ExitCodeConstants.cs index fed6c27ac66..1b9fcc72e5b 100644 --- a/src/Aspire.Cli/ExitCodeConstants.cs +++ b/src/Aspire.Cli/ExitCodeConstants.cs @@ -22,4 +22,6 @@ internal static class ExitCodeConstants public const int CentralPackageManagementNotSupported = 14; public const int SingleFileAppHostNotSupported = 15; public const int FailedToExecuteResourceCommand = 16; + public const int WaitTimeout = 17; + public const int WaitResourceFailed = 18; } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index f58c684790f..e7e1e91cde5 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -342,6 +342,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/README.md b/src/Aspire.Cli/README.md index 2b6edc35744..88a26557290 100644 --- a/src/Aspire.Cli/README.md +++ b/src/Aspire.Cli/README.md @@ -126,6 +126,45 @@ Arguments passed to the application that is being run. **Description:** Runs the Aspire app host and executes a command against a specified resource. Use either `--resource` for an existing resource or `--start-resource` to start a resource and then execute the command. +### wait + +Wait for a resource to reach a target status. + +```cli +aspire wait [options] +``` + +**Arguments:** +- `` - The name of the resource to wait for + +**Options:** +- `--status` - The target status to wait for: `healthy`, `up`, `down` (default: `healthy`) +- `--timeout` - Maximum time to wait in seconds (default: 120) +- `--project` - The path to the Aspire AppHost project file + +**Description:** +Blocks until the specified resource reaches the desired status or the timeout is exceeded. Useful in CI/CD pipelines and scripts after starting an AppHost with `aspire run --detach`. + +**Status values:** +- `healthy` (default) - Resource is running and all health checks pass (or no health checks registered) +- `up` - Resource is running, regardless of health check status +- `down` - Resource has exited, finished, or failed to start + +**Exit codes:** +- `0` - Resource reached the desired status +- `17` - Timeout exceeded +- `18` - Resource entered a terminal failure state while waiting for healthy/up +- `7` - Failed to find or connect to AppHost + +**Example:** +```cli +# Start an AppHost in the background, then wait for a resource +aspire run --detach +aspire wait webapi +aspire wait redis --status up --timeout 60 +aspire wait worker --status down +``` + ### update Update integrations in the Aspire project. (Preview) diff --git a/src/Aspire.Cli/Resources/WaitCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/WaitCommandStrings.Designer.cs new file mode 100644 index 00000000000..30352787146 --- /dev/null +++ b/src/Aspire.Cli/Resources/WaitCommandStrings.Designer.cs @@ -0,0 +1,144 @@ +//------------------------------------------------------------------------------ +// +// 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.Cli.Resources { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class WaitCommandStrings { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal WaitCommandStrings() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + public static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Aspire.Cli.Resources.WaitCommandStrings", typeof(WaitCommandStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + public static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + public static string Description { + get { + return ResourceManager.GetString("Description", resourceCulture); + } + } + + public static string ResourceArgumentDescription { + get { + return ResourceManager.GetString("ResourceArgumentDescription", resourceCulture); + } + } + + public static string StatusOptionDescription { + get { + return ResourceManager.GetString("StatusOptionDescription", resourceCulture); + } + } + + public static string TimeoutOptionDescription { + get { + return ResourceManager.GetString("TimeoutOptionDescription", resourceCulture); + } + } + + public static string ProjectOptionDescription { + get { + return ResourceManager.GetString("ProjectOptionDescription", resourceCulture); + } + } + + public static string ScanningForRunningAppHosts { + get { + return ResourceManager.GetString("ScanningForRunningAppHosts", resourceCulture); + } + } + + public static string SelectAppHost { + get { + return ResourceManager.GetString("SelectAppHost", resourceCulture); + } + } + + public static string NoInScopeAppHostsShowingAll { + get { + return ResourceManager.GetString("NoInScopeAppHostsShowingAll", resourceCulture); + } + } + + public static string NoRunningAppHostsFound { + get { + return ResourceManager.GetString("NoRunningAppHostsFound", resourceCulture); + } + } + + public static string WaitingForResource { + get { + return ResourceManager.GetString("WaitingForResource", resourceCulture); + } + } + + public static string ResourceReachedTargetStatus { + get { + return ResourceManager.GetString("ResourceReachedTargetStatus", resourceCulture); + } + } + + public static string WaitTimedOut { + get { + return ResourceManager.GetString("WaitTimedOut", resourceCulture); + } + } + + public static string ResourceNotFound { + get { + return ResourceManager.GetString("ResourceNotFound", resourceCulture); + } + } + + public static string ResourceEnteredFailedState { + get { + return ResourceManager.GetString("ResourceEnteredFailedState", resourceCulture); + } + } + + public static string InvalidStatusValue { + get { + return ResourceManager.GetString("InvalidStatusValue", resourceCulture); + } + } + + public static string TimeoutMustBePositive { + get { + return ResourceManager.GetString("TimeoutMustBePositive", resourceCulture); + } + } + } +} diff --git a/src/Aspire.Cli/Resources/WaitCommandStrings.resx b/src/Aspire.Cli/Resources/WaitCommandStrings.resx new file mode 100644 index 00000000000..a93ab28f02b --- /dev/null +++ b/src/Aspire.Cli/Resources/WaitCommandStrings.resx @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Wait for a resource to reach a target status. + + + The name of the resource to wait for. + + + The target status to wait for (healthy, up, down). Defaults to healthy. + + + Maximum time to wait in seconds. Defaults to 120. + + + The path to the Aspire AppHost project file. + + + Scanning for running AppHosts... + + + Select an AppHost: + + + No running AppHosts found in the current directory. Showing all running AppHosts: + + + No running AppHosts found. + + + Waiting for resource '{0}' to be {1}... + + + Resource '{0}' is {1}. ({2:F1}s) + + + Timed out waiting for resource '{0}' to be {1} after {2}s. + + + Resource '{0}' was not found. + + + Resource '{0}' entered a failed state: {1}. + + + Invalid status value '{0}'. Valid values are: healthy, up, down. + + + Timeout must be a positive number of seconds. + + diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.cs.xlf new file mode 100644 index 00000000000..24dfa963182 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.cs.xlf @@ -0,0 +1,87 @@ + + + + + + Wait for a resource to reach a target status. + Wait for a resource to reach a target status. + + + + Invalid status value '{0}'. Valid values are: healthy, up, down. + Invalid status value '{0}'. Valid values are: healthy, up, down. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + The name of the resource to wait for. + The name of the resource to wait for. + + + + Resource '{0}' entered a failed state: {1}. + Resource '{0}' entered a failed state: {1}. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + Resource '{0}' is {1}. ({2:F1}s) + Resource '{0}' is {1}. ({2:F1}s) + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + The target status to wait for (healthy, up, down). Defaults to healthy. + The target status to wait for (healthy, up, down). Defaults to healthy. + + + + Timeout must be a positive number of seconds. + Timeout must be a positive number of seconds. + + + + Maximum time to wait in seconds. Defaults to 120. + Maximum time to wait in seconds. Defaults to 120. + + + + Timed out waiting for resource '{0}' to be {1} after {2}s. + Timed out waiting for resource '{0}' to be {1} after {2}s. + + + + Waiting for resource '{0}' to be {1}... + Waiting for resource '{0}' to be {1}... + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.de.xlf new file mode 100644 index 00000000000..fa566fd6d81 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.de.xlf @@ -0,0 +1,87 @@ + + + + + + Wait for a resource to reach a target status. + Wait for a resource to reach a target status. + + + + Invalid status value '{0}'. Valid values are: healthy, up, down. + Invalid status value '{0}'. Valid values are: healthy, up, down. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + The name of the resource to wait for. + The name of the resource to wait for. + + + + Resource '{0}' entered a failed state: {1}. + Resource '{0}' entered a failed state: {1}. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + Resource '{0}' is {1}. ({2:F1}s) + Resource '{0}' is {1}. ({2:F1}s) + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + The target status to wait for (healthy, up, down). Defaults to healthy. + The target status to wait for (healthy, up, down). Defaults to healthy. + + + + Timeout must be a positive number of seconds. + Timeout must be a positive number of seconds. + + + + Maximum time to wait in seconds. Defaults to 120. + Maximum time to wait in seconds. Defaults to 120. + + + + Timed out waiting for resource '{0}' to be {1} after {2}s. + Timed out waiting for resource '{0}' to be {1} after {2}s. + + + + Waiting for resource '{0}' to be {1}... + Waiting for resource '{0}' to be {1}... + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.es.xlf new file mode 100644 index 00000000000..a8e64cf4f14 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.es.xlf @@ -0,0 +1,87 @@ + + + + + + Wait for a resource to reach a target status. + Wait for a resource to reach a target status. + + + + Invalid status value '{0}'. Valid values are: healthy, up, down. + Invalid status value '{0}'. Valid values are: healthy, up, down. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + The name of the resource to wait for. + The name of the resource to wait for. + + + + Resource '{0}' entered a failed state: {1}. + Resource '{0}' entered a failed state: {1}. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + Resource '{0}' is {1}. ({2:F1}s) + Resource '{0}' is {1}. ({2:F1}s) + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + The target status to wait for (healthy, up, down). Defaults to healthy. + The target status to wait for (healthy, up, down). Defaults to healthy. + + + + Timeout must be a positive number of seconds. + Timeout must be a positive number of seconds. + + + + Maximum time to wait in seconds. Defaults to 120. + Maximum time to wait in seconds. Defaults to 120. + + + + Timed out waiting for resource '{0}' to be {1} after {2}s. + Timed out waiting for resource '{0}' to be {1} after {2}s. + + + + Waiting for resource '{0}' to be {1}... + Waiting for resource '{0}' to be {1}... + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.fr.xlf new file mode 100644 index 00000000000..7bd17b7aca2 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.fr.xlf @@ -0,0 +1,87 @@ + + + + + + Wait for a resource to reach a target status. + Wait for a resource to reach a target status. + + + + Invalid status value '{0}'. Valid values are: healthy, up, down. + Invalid status value '{0}'. Valid values are: healthy, up, down. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + The name of the resource to wait for. + The name of the resource to wait for. + + + + Resource '{0}' entered a failed state: {1}. + Resource '{0}' entered a failed state: {1}. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + Resource '{0}' is {1}. ({2:F1}s) + Resource '{0}' is {1}. ({2:F1}s) + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + The target status to wait for (healthy, up, down). Defaults to healthy. + The target status to wait for (healthy, up, down). Defaults to healthy. + + + + Timeout must be a positive number of seconds. + Timeout must be a positive number of seconds. + + + + Maximum time to wait in seconds. Defaults to 120. + Maximum time to wait in seconds. Defaults to 120. + + + + Timed out waiting for resource '{0}' to be {1} after {2}s. + Timed out waiting for resource '{0}' to be {1} after {2}s. + + + + Waiting for resource '{0}' to be {1}... + Waiting for resource '{0}' to be {1}... + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.it.xlf new file mode 100644 index 00000000000..a0e330107e2 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.it.xlf @@ -0,0 +1,87 @@ + + + + + + Wait for a resource to reach a target status. + Wait for a resource to reach a target status. + + + + Invalid status value '{0}'. Valid values are: healthy, up, down. + Invalid status value '{0}'. Valid values are: healthy, up, down. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + The name of the resource to wait for. + The name of the resource to wait for. + + + + Resource '{0}' entered a failed state: {1}. + Resource '{0}' entered a failed state: {1}. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + Resource '{0}' is {1}. ({2:F1}s) + Resource '{0}' is {1}. ({2:F1}s) + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + The target status to wait for (healthy, up, down). Defaults to healthy. + The target status to wait for (healthy, up, down). Defaults to healthy. + + + + Timeout must be a positive number of seconds. + Timeout must be a positive number of seconds. + + + + Maximum time to wait in seconds. Defaults to 120. + Maximum time to wait in seconds. Defaults to 120. + + + + Timed out waiting for resource '{0}' to be {1} after {2}s. + Timed out waiting for resource '{0}' to be {1} after {2}s. + + + + Waiting for resource '{0}' to be {1}... + Waiting for resource '{0}' to be {1}... + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ja.xlf new file mode 100644 index 00000000000..8688c957d7c --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ja.xlf @@ -0,0 +1,87 @@ + + + + + + Wait for a resource to reach a target status. + Wait for a resource to reach a target status. + + + + Invalid status value '{0}'. Valid values are: healthy, up, down. + Invalid status value '{0}'. Valid values are: healthy, up, down. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + The name of the resource to wait for. + The name of the resource to wait for. + + + + Resource '{0}' entered a failed state: {1}. + Resource '{0}' entered a failed state: {1}. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + Resource '{0}' is {1}. ({2:F1}s) + Resource '{0}' is {1}. ({2:F1}s) + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + The target status to wait for (healthy, up, down). Defaults to healthy. + The target status to wait for (healthy, up, down). Defaults to healthy. + + + + Timeout must be a positive number of seconds. + Timeout must be a positive number of seconds. + + + + Maximum time to wait in seconds. Defaults to 120. + Maximum time to wait in seconds. Defaults to 120. + + + + Timed out waiting for resource '{0}' to be {1} after {2}s. + Timed out waiting for resource '{0}' to be {1} after {2}s. + + + + Waiting for resource '{0}' to be {1}... + Waiting for resource '{0}' to be {1}... + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ko.xlf new file mode 100644 index 00000000000..c1297400695 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ko.xlf @@ -0,0 +1,87 @@ + + + + + + Wait for a resource to reach a target status. + Wait for a resource to reach a target status. + + + + Invalid status value '{0}'. Valid values are: healthy, up, down. + Invalid status value '{0}'. Valid values are: healthy, up, down. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + The name of the resource to wait for. + The name of the resource to wait for. + + + + Resource '{0}' entered a failed state: {1}. + Resource '{0}' entered a failed state: {1}. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + Resource '{0}' is {1}. ({2:F1}s) + Resource '{0}' is {1}. ({2:F1}s) + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + The target status to wait for (healthy, up, down). Defaults to healthy. + The target status to wait for (healthy, up, down). Defaults to healthy. + + + + Timeout must be a positive number of seconds. + Timeout must be a positive number of seconds. + + + + Maximum time to wait in seconds. Defaults to 120. + Maximum time to wait in seconds. Defaults to 120. + + + + Timed out waiting for resource '{0}' to be {1} after {2}s. + Timed out waiting for resource '{0}' to be {1} after {2}s. + + + + Waiting for resource '{0}' to be {1}... + Waiting for resource '{0}' to be {1}... + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pl.xlf new file mode 100644 index 00000000000..b2e7f34aa9d --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pl.xlf @@ -0,0 +1,87 @@ + + + + + + Wait for a resource to reach a target status. + Wait for a resource to reach a target status. + + + + Invalid status value '{0}'. Valid values are: healthy, up, down. + Invalid status value '{0}'. Valid values are: healthy, up, down. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + The name of the resource to wait for. + The name of the resource to wait for. + + + + Resource '{0}' entered a failed state: {1}. + Resource '{0}' entered a failed state: {1}. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + Resource '{0}' is {1}. ({2:F1}s) + Resource '{0}' is {1}. ({2:F1}s) + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + The target status to wait for (healthy, up, down). Defaults to healthy. + The target status to wait for (healthy, up, down). Defaults to healthy. + + + + Timeout must be a positive number of seconds. + Timeout must be a positive number of seconds. + + + + Maximum time to wait in seconds. Defaults to 120. + Maximum time to wait in seconds. Defaults to 120. + + + + Timed out waiting for resource '{0}' to be {1} after {2}s. + Timed out waiting for resource '{0}' to be {1} after {2}s. + + + + Waiting for resource '{0}' to be {1}... + Waiting for resource '{0}' to be {1}... + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pt-BR.xlf new file mode 100644 index 00000000000..521e49348f7 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pt-BR.xlf @@ -0,0 +1,87 @@ + + + + + + Wait for a resource to reach a target status. + Wait for a resource to reach a target status. + + + + Invalid status value '{0}'. Valid values are: healthy, up, down. + Invalid status value '{0}'. Valid values are: healthy, up, down. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + The name of the resource to wait for. + The name of the resource to wait for. + + + + Resource '{0}' entered a failed state: {1}. + Resource '{0}' entered a failed state: {1}. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + Resource '{0}' is {1}. ({2:F1}s) + Resource '{0}' is {1}. ({2:F1}s) + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + The target status to wait for (healthy, up, down). Defaults to healthy. + The target status to wait for (healthy, up, down). Defaults to healthy. + + + + Timeout must be a positive number of seconds. + Timeout must be a positive number of seconds. + + + + Maximum time to wait in seconds. Defaults to 120. + Maximum time to wait in seconds. Defaults to 120. + + + + Timed out waiting for resource '{0}' to be {1} after {2}s. + Timed out waiting for resource '{0}' to be {1} after {2}s. + + + + Waiting for resource '{0}' to be {1}... + Waiting for resource '{0}' to be {1}... + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ru.xlf new file mode 100644 index 00000000000..13c35c770fc --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ru.xlf @@ -0,0 +1,87 @@ + + + + + + Wait for a resource to reach a target status. + Wait for a resource to reach a target status. + + + + Invalid status value '{0}'. Valid values are: healthy, up, down. + Invalid status value '{0}'. Valid values are: healthy, up, down. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + The name of the resource to wait for. + The name of the resource to wait for. + + + + Resource '{0}' entered a failed state: {1}. + Resource '{0}' entered a failed state: {1}. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + Resource '{0}' is {1}. ({2:F1}s) + Resource '{0}' is {1}. ({2:F1}s) + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + The target status to wait for (healthy, up, down). Defaults to healthy. + The target status to wait for (healthy, up, down). Defaults to healthy. + + + + Timeout must be a positive number of seconds. + Timeout must be a positive number of seconds. + + + + Maximum time to wait in seconds. Defaults to 120. + Maximum time to wait in seconds. Defaults to 120. + + + + Timed out waiting for resource '{0}' to be {1} after {2}s. + Timed out waiting for resource '{0}' to be {1} after {2}s. + + + + Waiting for resource '{0}' to be {1}... + Waiting for resource '{0}' to be {1}... + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.tr.xlf new file mode 100644 index 00000000000..8d3bf57318d --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.tr.xlf @@ -0,0 +1,87 @@ + + + + + + Wait for a resource to reach a target status. + Wait for a resource to reach a target status. + + + + Invalid status value '{0}'. Valid values are: healthy, up, down. + Invalid status value '{0}'. Valid values are: healthy, up, down. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + The name of the resource to wait for. + The name of the resource to wait for. + + + + Resource '{0}' entered a failed state: {1}. + Resource '{0}' entered a failed state: {1}. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + Resource '{0}' is {1}. ({2:F1}s) + Resource '{0}' is {1}. ({2:F1}s) + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + The target status to wait for (healthy, up, down). Defaults to healthy. + The target status to wait for (healthy, up, down). Defaults to healthy. + + + + Timeout must be a positive number of seconds. + Timeout must be a positive number of seconds. + + + + Maximum time to wait in seconds. Defaults to 120. + Maximum time to wait in seconds. Defaults to 120. + + + + Timed out waiting for resource '{0}' to be {1} after {2}s. + Timed out waiting for resource '{0}' to be {1} after {2}s. + + + + Waiting for resource '{0}' to be {1}... + Waiting for resource '{0}' to be {1}... + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hans.xlf new file mode 100644 index 00000000000..4478e63df2d --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hans.xlf @@ -0,0 +1,87 @@ + + + + + + Wait for a resource to reach a target status. + Wait for a resource to reach a target status. + + + + Invalid status value '{0}'. Valid values are: healthy, up, down. + Invalid status value '{0}'. Valid values are: healthy, up, down. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + The name of the resource to wait for. + The name of the resource to wait for. + + + + Resource '{0}' entered a failed state: {1}. + Resource '{0}' entered a failed state: {1}. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + Resource '{0}' is {1}. ({2:F1}s) + Resource '{0}' is {1}. ({2:F1}s) + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + The target status to wait for (healthy, up, down). Defaults to healthy. + The target status to wait for (healthy, up, down). Defaults to healthy. + + + + Timeout must be a positive number of seconds. + Timeout must be a positive number of seconds. + + + + Maximum time to wait in seconds. Defaults to 120. + Maximum time to wait in seconds. Defaults to 120. + + + + Timed out waiting for resource '{0}' to be {1} after {2}s. + Timed out waiting for resource '{0}' to be {1} after {2}s. + + + + Waiting for resource '{0}' to be {1}... + Waiting for resource '{0}' to be {1}... + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hant.xlf new file mode 100644 index 00000000000..a3d15fa2d57 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hant.xlf @@ -0,0 +1,87 @@ + + + + + + Wait for a resource to reach a target status. + Wait for a resource to reach a target status. + + + + Invalid status value '{0}'. Valid values are: healthy, up, down. + Invalid status value '{0}'. Valid values are: healthy, up, down. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + No running AppHosts found. + No running AppHosts found. + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + The name of the resource to wait for. + The name of the resource to wait for. + + + + Resource '{0}' entered a failed state: {1}. + Resource '{0}' entered a failed state: {1}. + + + + Resource '{0}' was not found. + Resource '{0}' was not found. + + + + Resource '{0}' is {1}. ({2:F1}s) + Resource '{0}' is {1}. ({2:F1}s) + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost: + Select an AppHost: + + + + The target status to wait for (healthy, up, down). Defaults to healthy. + The target status to wait for (healthy, up, down). Defaults to healthy. + + + + Timeout must be a positive number of seconds. + Timeout must be a positive number of seconds. + + + + Maximum time to wait in seconds. Defaults to 120. + Maximum time to wait in seconds. Defaults to 120. + + + + Timed out waiting for resource '{0}' to be {1} after {2}s. + Timed out waiting for resource '{0}' to be {1} after {2}s. + + + + Waiting for resource '{0}' to be {1}... + Waiting for resource '{0}' to be {1}... + + + + + \ No newline at end of file diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 9bee5ce6325..9596720b7e6 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -226,6 +226,95 @@ public async Task ExecuteResourceCommandAsync(Ex }; } + /// + /// Waits for a resource to reach a target status. + /// + public async Task WaitForResourceAsync(WaitForResourceRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var appModel = serviceProvider.GetService(); + if (appModel is not null && !appModel.Resources.Any(r => StringComparers.ResourceName.Equals(r.Name, request.ResourceName))) + { + return new WaitForResourceResponse + { + Success = false, + ResourceNotFound = true, + ErrorMessage = $"Resource '{request.ResourceName}' was not found." + }; + } + + var notificationService = serviceProvider.GetRequiredService(); + + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(request.TimeoutSeconds)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + try + { + return request.Status switch + { + "healthy" => await WaitForHealthyAsync(notificationService, request.ResourceName, linkedCts.Token).ConfigureAwait(false), + "up" => await WaitForRunningAsync(notificationService, request.ResourceName, linkedCts.Token).ConfigureAwait(false), + "down" => await WaitForTerminalAsync(notificationService, request.ResourceName, linkedCts.Token).ConfigureAwait(false), + _ => new WaitForResourceResponse { Success = false, ErrorMessage = $"Unknown status: {request.Status}" } + }; + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) + { + return new WaitForResourceResponse { Success = false, TimedOut = true, ErrorMessage = $"Timed out waiting for resource '{request.ResourceName}'." }; + } + catch (DistributedApplicationException ex) + { + return new WaitForResourceResponse { Success = false, ErrorMessage = ex.Message }; + } + } + + private static async Task WaitForHealthyAsync(ResourceNotificationService notificationService, string resourceName, CancellationToken cancellationToken) + { + var resourceEvent = await notificationService.WaitForResourceHealthyAsync(resourceName, WaitBehavior.StopOnResourceUnavailable, cancellationToken).ConfigureAwait(false); + + return new WaitForResourceResponse + { + Success = true, + State = resourceEvent.Snapshot.State?.Text, + HealthStatus = resourceEvent.Snapshot.HealthStatus?.ToString() + }; + } + + private static async Task WaitForRunningAsync(ResourceNotificationService notificationService, string resourceName, CancellationToken cancellationToken) + { + var resourceEvent = await notificationService.WaitForResourceAsync( + resourceName, + re => re.Snapshot.State?.Text == KnownResourceStates.Running || KnownResourceStates.TerminalStates.Contains(re.Snapshot.State?.Text) || re.Snapshot.ExitCode is not null, + cancellationToken).ConfigureAwait(false); + + var state = resourceEvent.Snapshot.State?.Text; + var isRunning = state == KnownResourceStates.Running; + + return new WaitForResourceResponse + { + Success = isRunning, + State = state, + HealthStatus = resourceEvent.Snapshot.HealthStatus?.ToString(), + ErrorMessage = isRunning ? null : $"Resource '{resourceName}' failed to reach 'Running' state. Current state: {state ?? "Unknown"}." + }; + } + + private static async Task WaitForTerminalAsync(ResourceNotificationService notificationService, string resourceName, CancellationToken cancellationToken) + { + var resourceEvent = await notificationService.WaitForResourceAsync( + resourceName, + re => KnownResourceStates.TerminalStates.Contains(re.Snapshot.State?.Text) || re.Snapshot.ExitCode is not null, + cancellationToken).ConfigureAwait(false); + + return new WaitForResourceResponse + { + Success = true, + State = resourceEvent.Snapshot.State?.Text, + HealthStatus = resourceEvent.Snapshot.HealthStatus?.ToString() + }; + } + #endregion #region V1 API Methods (Legacy - Keep for backward compatibility) diff --git a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs index 00e5c76733b..eb84f2a5778 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs @@ -299,6 +299,67 @@ internal sealed class ExecuteResourceCommandResponse #endregion +#region Wait For Resource + +/// +/// Request to wait for a resource to reach a target status. +/// +internal sealed class WaitForResourceRequest +{ + /// + /// Gets the name of the resource to wait for. + /// + public required string ResourceName { get; init; } + + /// + /// Gets the target status to wait for (e.g., "up", "healthy", "down"). + /// + public required string Status { get; init; } + + /// + /// Gets the timeout in seconds. + /// + public int TimeoutSeconds { get; init; } = 120; +} + +/// +/// Response from waiting for a resource. +/// +internal sealed class WaitForResourceResponse +{ + /// + /// Gets whether the resource reached the target status. + /// + public required bool Success { get; init; } + + /// + /// Gets the current state of the resource. + /// + public string? State { get; init; } + + /// + /// Gets the current health status of the resource. + /// + public string? HealthStatus { get; init; } + + /// + /// Gets whether the resource was not found. + /// + public bool ResourceNotFound { get; init; } + + /// + /// Gets whether the wait timed out. + /// + public bool TimedOut { get; init; } + + /// + /// Gets the error message if the wait failed. + /// + public string? ErrorMessage { get; init; } +} + +#endregion + /// /// Represents the state of a resource reported via RPC. /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs new file mode 100644 index 00000000000..e0fab0a036b --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs @@ -0,0 +1,133 @@ +// 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; +using Aspire.Cli.Tests.Utils; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end tests for Aspire CLI wait command. +/// Each test class runs as a separate CI job for parallelization. +/// +public sealed class WaitCommandTests(ITestOutputHelper output) +{ + [Fact] + public async Task CreateStartWaitAndStopAspireProject() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateStartWaitAndStopAspireProject)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // Pattern searchers for aspire new prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find($"Enter the output path: (./AspireWaitApp): "); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find($"Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find($"Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find($"Do you want to create a test project?"); + + var waitForProjectCreatedSuccessfullyMessage = new CellPatternSearcher() + .Find("Project created successfully."); + + // Pattern searchers for start/wait/stop commands + var waitForAppHostStartedSuccessfully = new CellPatternSearcher() + .Find("AppHost started successfully."); + + var waitForResourceUp = new CellPatternSearcher() + .Find("is up (running)."); + + var waitForAppHostStoppedSuccessfully = new CellPatternSearcher() + .Find("AppHost stopped successfully."); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Create a new project using aspire new + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() // select first template (Starter App) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Type("AspireWaitApp") + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter); + + // Navigate to the AppHost directory + sequenceBuilder.Type("cd AspireWaitApp/AspireWaitApp.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Start the AppHost in the background using aspire run --detach + sequenceBuilder.Type("aspire run --detach") + .Enter() + .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Wait for the webfrontend resource to be up (running) + sequenceBuilder.Type("aspire wait webfrontend --status up --timeout 120") + .Enter() + .WaitUntil(s => waitForResourceUp.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Stop the AppHost using aspire stop + sequenceBuilder.Type("aspire stop") + .Enter() + .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1)) + .WaitForSuccessPrompt(counter); + + // Exit the shell + sequenceBuilder.Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/WaitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/WaitCommandTests.cs new file mode 100644 index 00000000000..abb486832de --- /dev/null +++ b/tests/Aspire.Cli.Tests/Commands/WaitCommandTests.cs @@ -0,0 +1,299 @@ +// 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.Backchannel; +using Aspire.Cli.Commands; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Cli.Tests.Commands; + +public class WaitCommandTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task WaitCommand_Help_Works() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("wait --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task WaitCommand_RequiresResourceArgument() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("wait"); + + // Missing required argument should fail + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.NotEqual(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task WaitCommand_AcceptsResourceArgument() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("wait myresource --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task WaitCommand_AcceptsProjectOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("wait myresource --project /path/to/project.csproj --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task WaitCommand_AcceptsStatusOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("wait myresource --status up --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task WaitCommand_AcceptsTimeoutOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("wait myresource --timeout 60 --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Theory] + [InlineData("healthy")] + [InlineData("up")] + [InlineData("down")] + public async Task WaitCommand_AcceptsAllStatusValues(string status) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"wait myresource --status {status} --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Theory] + [InlineData("Healthy")] + [InlineData("UP")] + [InlineData("Down")] + public async Task WaitCommand_StatusIsCaseInsensitive(string status) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse($"wait myresource --status {status} --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task WaitCommand_ResourceNotFound_ReturnsFailure() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var backchannel = new TestAppHostAuxiliaryBackchannel + { + WaitForResourceResult = new WaitForResourceResponse + { + Success = false, + ResourceNotFound = true, + ErrorMessage = "Resource 'nonexistent' was not found." + } + }; + var monitor = new TestAuxiliaryBackchannelMonitor(); + monitor.AddConnection("hash", "/tmp/test.sock", backchannel); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("wait nonexistent --timeout 5"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.WaitResourceFailed, exitCode); + } + + [Fact] + public async Task WaitCommand_ResourceRunning_WaitForUp_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var backchannel = new TestAppHostAuxiliaryBackchannel + { + WaitForResourceResult = new WaitForResourceResponse { Success = true, State = "Running" } + }; + var monitor = new TestAuxiliaryBackchannelMonitor(); + monitor.AddConnection("hash", "/tmp/test.sock", backchannel); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("wait myapp --status up --timeout 5"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task WaitCommand_ResourceHealthy_WaitForHealthy_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var backchannel = new TestAppHostAuxiliaryBackchannel + { + WaitForResourceResult = new WaitForResourceResponse { Success = true, State = "Running", HealthStatus = "Healthy" } + }; + var monitor = new TestAuxiliaryBackchannelMonitor(); + monitor.AddConnection("hash", "/tmp/test.sock", backchannel); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("wait mydb --status healthy --timeout 5"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task WaitCommand_Timeout_ReturnsTimeoutExitCode() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var backchannel = new TestAppHostAuxiliaryBackchannel + { + WaitForResourceResult = new WaitForResourceResponse + { + Success = false, + TimedOut = true, + ErrorMessage = "Timed out waiting for resource 'mydb'." + } + }; + var monitor = new TestAuxiliaryBackchannelMonitor(); + monitor.AddConnection("hash", "/tmp/test.sock", backchannel); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("wait mydb --status healthy --timeout 2"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.WaitTimeout, exitCode); + } + + [Fact] + public async Task WaitCommand_ResourceExited_WaitForDown_ReturnsSuccess() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var backchannel = new TestAppHostAuxiliaryBackchannel + { + WaitForResourceResult = new WaitForResourceResponse { Success = true, State = "Exited" } + }; + var monitor = new TestAuxiliaryBackchannelMonitor(); + monitor.AddConnection("hash", "/tmp/test.sock", backchannel); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("wait worker --status down --timeout 5"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task WaitCommand_ResourceFailedToStart_WaitForUp_ReturnsFailure() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var backchannel = new TestAppHostAuxiliaryBackchannel + { + WaitForResourceResult = new WaitForResourceResponse + { + Success = false, + State = "FailedToStart", + ErrorMessage = "Resource 'myapp' failed to start." + } + }; + var monitor = new TestAuxiliaryBackchannelMonitor(); + monitor.AddConnection("hash", "/tmp/test.sock", backchannel); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("wait myapp --status up --timeout 5"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.WaitResourceFailed, exitCode); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs index 149ea2c6e2a..31c7ea06dd8 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs @@ -99,6 +99,20 @@ public Task ExecuteResourceCommandAsync( return Task.FromResult(ExecuteResourceCommandResult); } + /// + /// Gets or sets the result to return from WaitForResourceAsync. + /// + public WaitForResourceResponse WaitForResourceResult { get; set; } = new WaitForResourceResponse { Success = true, State = "Running" }; + + public Task WaitForResourceAsync( + string resourceName, + string status, + int timeoutSeconds, + CancellationToken cancellationToken = default) + { + return Task.FromResult(WaitForResourceResult); + } + public Task CallResourceMcpToolAsync( string resourceName, string toolName, diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index cde2b95b41e..5ef18fe61ad 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -191,6 +191,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); From 45ff89ebce95e35534fe2a9ab59442620557a016 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 11 Feb 2026 12:21:58 -0500 Subject: [PATCH 083/256] Add VS Code launch.json with Aspire debugger configs for all playground apps (#14347) --- playground/AspireEventHub/.vscode/launch.json | 11 +++++++++++ playground/AspireWithJavaScript/.vscode/launch.json | 11 +++++++++++ playground/AspireWithMaui/.vscode/launch.json | 11 +++++++++++ playground/AspireWithNode/.vscode/launch.json | 11 +++++++++++ playground/AzureAIFoundryEndToEnd/.vscode/launch.json | 11 +++++++++++ playground/AzureAppConfiguration/.vscode/launch.json | 11 +++++++++++ playground/AzureAppService/.vscode/launch.json | 11 +++++++++++ playground/AzureContainerApps/.vscode/launch.json | 11 +++++++++++ playground/AzureDataLakeEndToEnd/.vscode/launch.json | 11 +++++++++++ playground/AzureFunctionsEndToEnd/.vscode/launch.json | 11 +++++++++++ playground/AzureKusto/.vscode/launch.json | 11 +++++++++++ playground/AzureOpenAIEndToEnd/.vscode/launch.json | 11 +++++++++++ playground/AzureSearchEndToEnd/.vscode/launch.json | 11 +++++++++++ playground/AzureServiceBus/.vscode/launch.json | 11 +++++++++++ playground/AzureStorageEndToEnd/.vscode/launch.json | 11 +++++++++++ playground/BrowserTelemetry/.vscode/launch.json | 11 +++++++++++ playground/CosmosEndToEnd/.vscode/launch.json | 11 +++++++++++ playground/CustomResources/.vscode/launch.json | 11 +++++++++++ playground/DatabaseMigration/.vscode/launch.json | 11 +++++++++++ playground/DevTunnels/.vscode/launch.json | 11 +++++++++++ playground/DotnetTool/.vscode/launch.json | 11 +++++++++++ playground/ExternalServices/.vscode/launch.json | 11 +++++++++++ playground/FileBasedApps/.vscode/launch.json | 11 +++++++++++ playground/GitHubModelsEndToEnd/.vscode/launch.json | 11 +++++++++++ playground/HealthChecks/.vscode/launch.json | 11 +++++++++++ playground/OpenAIEndToEnd/.vscode/launch.json | 11 +++++++++++ playground/OracleEndToEnd/.vscode/launch.json | 11 +++++++++++ playground/ParameterEndToEnd/.vscode/launch.json | 11 +++++++++++ playground/PostgresEndToEnd/.vscode/launch.json | 11 +++++++++++ playground/ProxylessEndToEnd/.vscode/launch.json | 11 +++++++++++ playground/Qdrant/.vscode/launch.json | 11 +++++++++++ playground/Redis/.vscode/launch.json | 11 +++++++++++ playground/SqlServerEndToEnd/.vscode/launch.json | 11 +++++++++++ playground/SqlServerScript/.vscode/launch.json | 11 +++++++++++ playground/Stress/.vscode/launch.json | 11 +++++++++++ playground/TestShop/.vscode/launch.json | 11 +++++++++++ playground/TypeScriptAppHost/.vscode/launch.json | 11 +++++++++++ playground/bicep/.vscode/launch.json | 11 +++++++++++ playground/cdk/.vscode/launch.json | 11 +++++++++++ playground/deployers/.vscode/launch.json | 11 +++++++++++ playground/kafka/.vscode/launch.json | 11 +++++++++++ playground/keycloak/.vscode/launch.json | 11 +++++++++++ playground/milvus/.vscode/launch.json | 11 +++++++++++ playground/mongo/.vscode/launch.json | 11 +++++++++++ playground/mysql/.vscode/launch.json | 11 +++++++++++ playground/nats/.vscode/launch.json | 11 +++++++++++ playground/orleans/.vscode/launch.json | 11 +++++++++++ playground/pipelines/.vscode/launch.json | 11 +++++++++++ playground/publishers/.vscode/launch.json | 11 +++++++++++ playground/python/.vscode/launch.json | 11 +++++++++++ playground/seq/.vscode/launch.json | 11 +++++++++++ playground/signalr/.vscode/launch.json | 11 +++++++++++ playground/waitfor/.vscode/launch.json | 11 +++++++++++ playground/webpubsub/.vscode/launch.json | 11 +++++++++++ playground/withdockerfile/.vscode/launch.json | 11 +++++++++++ playground/yarp/.vscode/launch.json | 11 +++++++++++ 56 files changed, 616 insertions(+) create mode 100644 playground/AspireEventHub/.vscode/launch.json create mode 100644 playground/AspireWithJavaScript/.vscode/launch.json create mode 100644 playground/AspireWithMaui/.vscode/launch.json create mode 100644 playground/AspireWithNode/.vscode/launch.json create mode 100644 playground/AzureAIFoundryEndToEnd/.vscode/launch.json create mode 100644 playground/AzureAppConfiguration/.vscode/launch.json create mode 100644 playground/AzureAppService/.vscode/launch.json create mode 100644 playground/AzureContainerApps/.vscode/launch.json create mode 100644 playground/AzureDataLakeEndToEnd/.vscode/launch.json create mode 100644 playground/AzureFunctionsEndToEnd/.vscode/launch.json create mode 100644 playground/AzureKusto/.vscode/launch.json create mode 100644 playground/AzureOpenAIEndToEnd/.vscode/launch.json create mode 100644 playground/AzureSearchEndToEnd/.vscode/launch.json create mode 100644 playground/AzureServiceBus/.vscode/launch.json create mode 100644 playground/AzureStorageEndToEnd/.vscode/launch.json create mode 100644 playground/BrowserTelemetry/.vscode/launch.json create mode 100644 playground/CosmosEndToEnd/.vscode/launch.json create mode 100644 playground/CustomResources/.vscode/launch.json create mode 100644 playground/DatabaseMigration/.vscode/launch.json create mode 100644 playground/DevTunnels/.vscode/launch.json create mode 100644 playground/DotnetTool/.vscode/launch.json create mode 100644 playground/ExternalServices/.vscode/launch.json create mode 100644 playground/FileBasedApps/.vscode/launch.json create mode 100644 playground/GitHubModelsEndToEnd/.vscode/launch.json create mode 100644 playground/HealthChecks/.vscode/launch.json create mode 100644 playground/OpenAIEndToEnd/.vscode/launch.json create mode 100644 playground/OracleEndToEnd/.vscode/launch.json create mode 100644 playground/ParameterEndToEnd/.vscode/launch.json create mode 100644 playground/PostgresEndToEnd/.vscode/launch.json create mode 100644 playground/ProxylessEndToEnd/.vscode/launch.json create mode 100644 playground/Qdrant/.vscode/launch.json create mode 100644 playground/Redis/.vscode/launch.json create mode 100644 playground/SqlServerEndToEnd/.vscode/launch.json create mode 100644 playground/SqlServerScript/.vscode/launch.json create mode 100644 playground/Stress/.vscode/launch.json create mode 100644 playground/TestShop/.vscode/launch.json create mode 100644 playground/TypeScriptAppHost/.vscode/launch.json create mode 100644 playground/bicep/.vscode/launch.json create mode 100644 playground/cdk/.vscode/launch.json create mode 100644 playground/deployers/.vscode/launch.json create mode 100644 playground/kafka/.vscode/launch.json create mode 100644 playground/keycloak/.vscode/launch.json create mode 100644 playground/milvus/.vscode/launch.json create mode 100644 playground/mongo/.vscode/launch.json create mode 100644 playground/mysql/.vscode/launch.json create mode 100644 playground/nats/.vscode/launch.json create mode 100644 playground/orleans/.vscode/launch.json create mode 100644 playground/pipelines/.vscode/launch.json create mode 100644 playground/publishers/.vscode/launch.json create mode 100644 playground/python/.vscode/launch.json create mode 100644 playground/seq/.vscode/launch.json create mode 100644 playground/signalr/.vscode/launch.json create mode 100644 playground/waitfor/.vscode/launch.json create mode 100644 playground/webpubsub/.vscode/launch.json create mode 100644 playground/withdockerfile/.vscode/launch.json create mode 100644 playground/yarp/.vscode/launch.json diff --git a/playground/AspireEventHub/.vscode/launch.json b/playground/AspireEventHub/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/AspireEventHub/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/AspireWithJavaScript/.vscode/launch.json b/playground/AspireWithJavaScript/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/AspireWithJavaScript/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/AspireWithMaui/.vscode/launch.json b/playground/AspireWithMaui/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/AspireWithMaui/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/AspireWithNode/.vscode/launch.json b/playground/AspireWithNode/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/AspireWithNode/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/AzureAIFoundryEndToEnd/.vscode/launch.json b/playground/AzureAIFoundryEndToEnd/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/AzureAIFoundryEndToEnd/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/AzureAppConfiguration/.vscode/launch.json b/playground/AzureAppConfiguration/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/AzureAppConfiguration/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/AzureAppService/.vscode/launch.json b/playground/AzureAppService/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/AzureAppService/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/AzureContainerApps/.vscode/launch.json b/playground/AzureContainerApps/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/AzureContainerApps/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/AzureDataLakeEndToEnd/.vscode/launch.json b/playground/AzureDataLakeEndToEnd/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/AzureDataLakeEndToEnd/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/AzureFunctionsEndToEnd/.vscode/launch.json b/playground/AzureFunctionsEndToEnd/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/AzureFunctionsEndToEnd/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/AzureKusto/.vscode/launch.json b/playground/AzureKusto/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/AzureKusto/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/AzureOpenAIEndToEnd/.vscode/launch.json b/playground/AzureOpenAIEndToEnd/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/AzureOpenAIEndToEnd/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/AzureSearchEndToEnd/.vscode/launch.json b/playground/AzureSearchEndToEnd/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/AzureSearchEndToEnd/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/AzureServiceBus/.vscode/launch.json b/playground/AzureServiceBus/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/AzureServiceBus/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/AzureStorageEndToEnd/.vscode/launch.json b/playground/AzureStorageEndToEnd/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/AzureStorageEndToEnd/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/BrowserTelemetry/.vscode/launch.json b/playground/BrowserTelemetry/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/BrowserTelemetry/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/CosmosEndToEnd/.vscode/launch.json b/playground/CosmosEndToEnd/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/CosmosEndToEnd/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/CustomResources/.vscode/launch.json b/playground/CustomResources/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/CustomResources/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/DatabaseMigration/.vscode/launch.json b/playground/DatabaseMigration/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/DatabaseMigration/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/DevTunnels/.vscode/launch.json b/playground/DevTunnels/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/DevTunnels/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/DotnetTool/.vscode/launch.json b/playground/DotnetTool/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/DotnetTool/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/ExternalServices/.vscode/launch.json b/playground/ExternalServices/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/ExternalServices/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/FileBasedApps/.vscode/launch.json b/playground/FileBasedApps/.vscode/launch.json new file mode 100644 index 00000000000..9ccc377f303 --- /dev/null +++ b/playground/FileBasedApps/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}/apphost.cs" + } + ] +} diff --git a/playground/GitHubModelsEndToEnd/.vscode/launch.json b/playground/GitHubModelsEndToEnd/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/GitHubModelsEndToEnd/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/HealthChecks/.vscode/launch.json b/playground/HealthChecks/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/HealthChecks/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/OpenAIEndToEnd/.vscode/launch.json b/playground/OpenAIEndToEnd/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/OpenAIEndToEnd/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/OracleEndToEnd/.vscode/launch.json b/playground/OracleEndToEnd/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/OracleEndToEnd/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/ParameterEndToEnd/.vscode/launch.json b/playground/ParameterEndToEnd/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/ParameterEndToEnd/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/PostgresEndToEnd/.vscode/launch.json b/playground/PostgresEndToEnd/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/PostgresEndToEnd/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/ProxylessEndToEnd/.vscode/launch.json b/playground/ProxylessEndToEnd/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/ProxylessEndToEnd/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/Qdrant/.vscode/launch.json b/playground/Qdrant/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/Qdrant/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/Redis/.vscode/launch.json b/playground/Redis/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/Redis/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/SqlServerEndToEnd/.vscode/launch.json b/playground/SqlServerEndToEnd/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/SqlServerEndToEnd/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/SqlServerScript/.vscode/launch.json b/playground/SqlServerScript/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/SqlServerScript/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/Stress/.vscode/launch.json b/playground/Stress/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/Stress/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/TestShop/.vscode/launch.json b/playground/TestShop/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/TestShop/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/TypeScriptAppHost/.vscode/launch.json b/playground/TypeScriptAppHost/.vscode/launch.json new file mode 100644 index 00000000000..19cc680479b --- /dev/null +++ b/playground/TypeScriptAppHost/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}/apphost.ts" + } + ] +} diff --git a/playground/bicep/.vscode/launch.json b/playground/bicep/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/bicep/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/cdk/.vscode/launch.json b/playground/cdk/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/cdk/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/deployers/.vscode/launch.json b/playground/deployers/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/deployers/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/kafka/.vscode/launch.json b/playground/kafka/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/kafka/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/keycloak/.vscode/launch.json b/playground/keycloak/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/keycloak/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/milvus/.vscode/launch.json b/playground/milvus/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/milvus/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/mongo/.vscode/launch.json b/playground/mongo/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/mongo/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/mysql/.vscode/launch.json b/playground/mysql/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/mysql/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/nats/.vscode/launch.json b/playground/nats/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/nats/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/orleans/.vscode/launch.json b/playground/orleans/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/orleans/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/pipelines/.vscode/launch.json b/playground/pipelines/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/pipelines/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/publishers/.vscode/launch.json b/playground/publishers/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/publishers/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/python/.vscode/launch.json b/playground/python/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/python/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/seq/.vscode/launch.json b/playground/seq/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/seq/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/signalr/.vscode/launch.json b/playground/signalr/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/signalr/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/waitfor/.vscode/launch.json b/playground/waitfor/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/waitfor/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/webpubsub/.vscode/launch.json b/playground/webpubsub/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/webpubsub/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/withdockerfile/.vscode/launch.json b/playground/withdockerfile/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/withdockerfile/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} diff --git a/playground/yarp/.vscode/launch.json b/playground/yarp/.vscode/launch.json new file mode 100644 index 00000000000..2ba667c9c2f --- /dev/null +++ b/playground/yarp/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run AppHost", + "type": "aspire", + "request": "launch", + "program": "${workspaceFolder}" + } + ] +} From 8776a7fddf664ed35451459e2f50aba8ce23dc51 Mon Sep 17 00:00:00 2001 From: Karol Zadora-Przylecki Date: Wed, 11 Feb 2026 10:53:05 -0800 Subject: [PATCH 084/256] Add a script for startup performance measurement (#14345) * Add startup perf collection script * Analyze trace more efficiently * Increase pause between iterations * Fix TraceAnalyzer * Add startup-perf skill --- .github/skills/startup-perf/SKILL.md | 193 +++++ AGENTS.md | 1 + docs/getting-perf-traces.md | 10 +- tools/perf/Measure-StartupPerformance.ps1 | 678 ++++++++++++++++++ tools/perf/TraceAnalyzer/Program.cs | 80 +++ tools/perf/TraceAnalyzer/TraceAnalyzer.csproj | 16 + 6 files changed, 977 insertions(+), 1 deletion(-) create mode 100644 .github/skills/startup-perf/SKILL.md create mode 100644 tools/perf/Measure-StartupPerformance.ps1 create mode 100644 tools/perf/TraceAnalyzer/Program.cs create mode 100644 tools/perf/TraceAnalyzer/TraceAnalyzer.csproj diff --git a/.github/skills/startup-perf/SKILL.md b/.github/skills/startup-perf/SKILL.md new file mode 100644 index 00000000000..33ca4d3875f --- /dev/null +++ b/.github/skills/startup-perf/SKILL.md @@ -0,0 +1,193 @@ +--- +name: startup-perf +description: Measures Aspire application startup performance using dotnet-trace and the TraceAnalyzer tool. Use this when asked to measure impact of a code change on Aspire application startup performance. +--- + +# Aspire Startup Performance Measurement + +This skill provides patterns and practices for measuring .NET Aspire application startup performance using the `Measure-StartupPerformance.ps1` script and the companion `TraceAnalyzer` tool. + +## Overview + +The startup performance tooling collects `dotnet-trace` traces from an Aspire AppHost application and computes the startup duration from `AspireEventSource` events. Specifically, it measures the time between the `DcpModelCreationStart` (event ID 17) and `DcpModelCreationStop` (event ID 18) events emitted by the `Microsoft-Aspire-Hosting` EventSource provider. + +**Script Location**: `tools/perf/Measure-StartupPerformance.ps1` +**TraceAnalyzer Location**: `tools/perf/TraceAnalyzer/` +**Documentation**: `docs/getting-perf-traces.md` + +## Prerequisites + +- PowerShell 7+ +- `dotnet-trace` global tool (`dotnet tool install -g dotnet-trace`) +- .NET SDK (restored via `./restore.cmd` or `./restore.sh`) + +## Quick Start + +### Single Measurement + +```powershell +# From repository root — measures the default TestShop.AppHost +.\tools\perf\Measure-StartupPerformance.ps1 +``` + +### Multiple Iterations with Statistics + +```powershell +.\tools\perf\Measure-StartupPerformance.ps1 -Iterations 5 +``` + +### Custom Project + +```powershell +.\tools\perf\Measure-StartupPerformance.ps1 -ProjectPath "path\to\MyApp.AppHost.csproj" -Iterations 3 +``` + +### Preserve Traces for Manual Analysis + +```powershell +.\tools\perf\Measure-StartupPerformance.ps1 -Iterations 3 -PreserveTraces -TraceOutputDirectory "C:\traces" +``` + +### Verbose Output + +```powershell +.\tools\perf\Measure-StartupPerformance.ps1 -Verbose +``` + +## Parameters + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `ProjectPath` | TestShop.AppHost | Path to the AppHost `.csproj` to measure | +| `Iterations` | 1 | Number of measurement runs (1–100) | +| `PreserveTraces` | `$false` | Keep `.nettrace` files after analysis | +| `TraceOutputDirectory` | temp folder | Directory for preserved trace files | +| `SkipBuild` | `$false` | Skip `dotnet build` before running | +| `TraceDurationSeconds` | 60 | Maximum trace collection time (1–86400) | +| `PauseBetweenIterationsSeconds` | 45 | Pause between iterations (0–3600) | +| `Verbose` | `$false` | Show detailed output | + +## How It Works + +The script follows this sequence: + +1. **Prerequisites check** — Verifies `dotnet-trace` is installed and the project exists. +2. **Build** — Builds the AppHost project in Release configuration (unless `-SkipBuild`). +3. **Build TraceAnalyzer** — Builds the companion `tools/perf/TraceAnalyzer` project. +4. **For each iteration:** + a. Locates the compiled executable (Arcade-style or traditional output paths). + b. Reads `launchSettings.json` for environment variables. + c. Launches the AppHost as a separate process. + d. Attaches `dotnet-trace` to the running process with the `Microsoft-Aspire-Hosting` provider. + e. Waits for the trace to complete (duration timeout or process exit). + f. Runs the TraceAnalyzer to extract the startup duration from the `.nettrace` file. + g. Cleans up processes. +5. **Reports results** — Prints per-iteration times and statistics (min, max, average, std dev). + +## TraceAnalyzer Tool + +The `tools/perf/TraceAnalyzer` is a small .NET console app that parses `.nettrace` files using the `Microsoft.Diagnostics.Tracing.TraceEvent` library. + +### What It Does + +- Opens the `.nettrace` file with `EventPipeEventSource` +- Listens for events from the `Microsoft-Aspire-Hosting` provider +- Extracts timestamps for `DcpModelCreationStart` (ID 17) and `DcpModelCreationStop` (ID 18) +- Outputs the duration in milliseconds (or `"null"` if events are not found) + +### Standalone Usage + +```bash +dotnet run --project tools/perf/TraceAnalyzer -c Release -- +``` + +## Understanding Output + +### Successful Run + +``` +================================================== + Aspire Startup Performance Measurement +================================================== + +Project: TestShop.AppHost +Iterations: 3 +... + +Iteration 1 +---------------------------------------- +Starting TestShop.AppHost... +Attaching trace collection to PID 12345... +Collecting performance trace... +Trace collection completed. +Analyzing trace: ... +Startup time: 1234.56 ms + +... + +================================================== + Results Summary +================================================== + +Iteration StartupTimeMs +--------- ------------- + 1 1234.56 + 2 1189.23 + 3 1201.45 + +Statistics: + Successful iterations: 3 / 3 + Minimum: 1189.23 ms + Maximum: 1234.56 ms + Average: 1208.41 ms + Std Dev: 18.92 ms +``` + +### Common Issues + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `dotnet-trace is not installed` | Missing global tool | Run `dotnet tool install -g dotnet-trace` | +| `Could not find compiled executable` | Project not built | Remove `-SkipBuild` or build manually | +| `Could not find DcpModelCreation events` | Trace too short or events not emitted | Increase `-TraceDurationSeconds` | +| `Application exited immediately` | App crash on startup | Check app logs, ensure dependencies are available | +| `dotnet-trace exited with code != 0` | Trace collection error | Check verbose output; trace file may still be valid | + +## Comparing Before/After Performance + +To measure the impact of a code change: + +```powershell +# 1. Measure baseline (on main branch) +git checkout main +.\tools\perf\Measure-StartupPerformance.ps1 -Iterations 5 -PreserveTraces -TraceOutputDirectory "C:\traces\baseline" + +# 2. Measure with changes +git checkout my-feature-branch +.\tools\perf\Measure-StartupPerformance.ps1 -Iterations 5 -PreserveTraces -TraceOutputDirectory "C:\traces\feature" + +# 3. Compare the reported averages and std devs +``` + +Use enough iterations (5+) and a consistent pause between iterations for reliable comparisons. + +## Collecting Traces for Manual Analysis + +If you need to inspect trace files manually (e.g., in PerfView or Visual Studio): + +```powershell +.\tools\perf\Measure-StartupPerformance.ps1 -PreserveTraces -TraceOutputDirectory "C:\my-traces" +``` + +See `docs/getting-perf-traces.md` for guidance on analyzing traces with PerfView or `dotnet trace report`. + +## EventSource Provider Details + +The `Microsoft-Aspire-Hosting` EventSource emits events for key Aspire lifecycle milestones. The startup performance script focuses on: + +| Event ID | Event Name | Description | +|----------|------------|-------------| +| 17 | `DcpModelCreationStart` | Marks the beginning of DCP model creation | +| 18 | `DcpModelCreationStop` | Marks the completion of DCP model creation | + +The measured startup time is the wall-clock difference between these two events, representing the time to create all application services and supporting dependencies. diff --git a/AGENTS.md b/AGENTS.md index cb4d5711eb1..cb6596c3d31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -355,6 +355,7 @@ The following specialized skills are available in `.github/skills/`: - **test-management**: Quarantines or disables flaky/problematic tests using the QuarantineTools utility - **connection-properties**: Expert for creating and improving Connection Properties in Aspire resources - **dependency-update**: Guides dependency version updates by checking nuget.org, triggering the dotnet-migrate-package Azure DevOps pipeline, and monitoring runs +- **startup-perf**: Measures Aspire application startup performance using dotnet-trace and the TraceAnalyzer tool ## Pattern-Based Instructions diff --git a/docs/getting-perf-traces.md b/docs/getting-perf-traces.md index a669c591ee0..94a5a14a0d5 100644 --- a/docs/getting-perf-traces.md +++ b/docs/getting-perf-traces.md @@ -28,8 +28,16 @@ Once you are ready, hit "Start Collection" button and run your scenario. When done with the scenario, hit "Stop Collection". Wait for PerfView to finish merging and analyzing data (the "working" status bar stops flashing). -### Verify that the trace contains Aspire data +### Verify that PerfView trace contains Aspire data This is an optional step, but if you are wondering if your trace has been captured properly, you can check the following: 1. Open the trace (usually named PerfViewData.etl, if you haven't changed the name) and double click Events view. Verify you have a bunch of events from the Microsoft-Aspire-Hosting provider. + +## Profiling scripts + +The `tools/perf` folder in the repository contains scripts that help quickly assess the impact of code changes on key performance scenarios. Currently available scripts are: + +| Script | Description | +| --- | --------- | +| `Measure-StartupPerformance.ps1` | Measures startup time for a specific Aspire project. More specifically, the script measures the time to get all application services and supporting dependencies CREATED; the application is not necessarily responsive after measured time. | diff --git a/tools/perf/Measure-StartupPerformance.ps1 b/tools/perf/Measure-StartupPerformance.ps1 new file mode 100644 index 00000000000..626adff10ed --- /dev/null +++ b/tools/perf/Measure-StartupPerformance.ps1 @@ -0,0 +1,678 @@ +<# +.SYNOPSIS + Measures .NET Aspire application startup performance by collecting ETW traces. + +.DESCRIPTION + This script runs an Aspire application, collects a performance trace + using dotnet-trace, and computes the startup time from AspireEventSource events. + The trace collection ends when the DcpModelCreationStop event is fired. + +.PARAMETER ProjectPath + Path to the AppHost project (.csproj) to measure. Can be absolute or relative. + Defaults to the TestShop.AppHost project in the playground folder. + +.PARAMETER Iterations + Number of times to run the scenario and collect traces. Defaults to 1. + +.PARAMETER PreserveTraces + If specified, trace files are preserved after the run. By default, traces are + stored in a temporary folder and deleted after analysis. + +.PARAMETER TraceOutputDirectory + Directory where trace files will be saved when PreserveTraces is set. + Defaults to a 'traces' subdirectory in the script folder. + +.PARAMETER SkipBuild + If specified, skips building the project before running. + +.PARAMETER TraceDurationSeconds + Duration in seconds for the trace collection. Defaults to 60 (1 minute). + The value is automatically converted to the dd:hh:mm:ss format required by dotnet-trace. + +.PARAMETER PauseBetweenIterationsSeconds + Number of seconds to pause between iterations. Defaults to 15. + Set to 0 to disable the pause. + +.PARAMETER Verbose + If specified, shows detailed output during execution. + +.EXAMPLE + .\Measure-StartupPerformance.ps1 + +.EXAMPLE + .\Measure-StartupPerformance.ps1 -Iterations 5 + +.EXAMPLE + .\Measure-StartupPerformance.ps1 -ProjectPath "C:\MyApp\MyApp.AppHost.csproj" -Iterations 3 + +.EXAMPLE + .\Measure-StartupPerformance.ps1 -Iterations 3 -PreserveTraces -TraceOutputDirectory "C:\traces" + +.EXAMPLE + .\Measure-StartupPerformance.ps1 -TraceDurationSeconds 120 + +.EXAMPLE + .\Measure-StartupPerformance.ps1 -Iterations 5 -PauseBetweenIterationsSeconds 30 + +.NOTES + Requires: + - PowerShell 7+ + - dotnet-trace global tool (dotnet tool install -g dotnet-trace) + - .NET SDK +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$ProjectPath, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 100)] + [int]$Iterations = 1, + + [Parameter(Mandatory = $false)] + [switch]$PreserveTraces, + + [Parameter(Mandatory = $false)] + [string]$TraceOutputDirectory, + + [Parameter(Mandatory = $false)] + [switch]$SkipBuild, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 86400)] + [int]$TraceDurationSeconds = 60, + + [Parameter(Mandatory = $false)] + [ValidateRange(0, 3600)] + [int]$PauseBetweenIterationsSeconds = 45 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Constants +$EventSourceName = 'Microsoft-Aspire-Hosting' +$DcpModelCreationStartEventId = 17 +$DcpModelCreationStopEventId = 18 + +# Get repository root (script is in tools/perf) +$ScriptDir = $PSScriptRoot +$RepoRoot = (Resolve-Path (Join-Path $ScriptDir '..' '..')).Path + +# Resolve project path +if (-not $ProjectPath) { + # Default to TestShop.AppHost + $ProjectPath = Join-Path $RepoRoot 'playground' 'TestShop' 'TestShop.AppHost' 'TestShop.AppHost.csproj' +} +elseif (-not [System.IO.Path]::IsPathRooted($ProjectPath)) { + # Relative path - resolve from current directory + $ProjectPath = (Resolve-Path $ProjectPath -ErrorAction Stop).Path +} + +$AppHostProject = $ProjectPath +$AppHostDir = Split-Path $AppHostProject -Parent +$AppHostName = [System.IO.Path]::GetFileNameWithoutExtension($AppHostProject) + +# Determine output directory for traces - always use temp directory unless explicitly specified +if ($TraceOutputDirectory) { + $OutputDirectory = $TraceOutputDirectory +} +else { + # Always use a temp directory for traces + $OutputDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-perf-$([System.Guid]::NewGuid().ToString('N').Substring(0, 8))" +} + +# Only delete temp directory if not preserving traces and no custom directory was specified +$ShouldCleanupDirectory = -not $PreserveTraces -and -not $TraceOutputDirectory + +# Ensure output directory exists +if (-not (Test-Path $OutputDirectory)) { + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null +} + +# Verify prerequisites +function Test-Prerequisites { + Write-Host "Checking prerequisites..." -ForegroundColor Cyan + + # Check dotnet-trace is installed + $dotnetTrace = Get-Command 'dotnet-trace' -ErrorAction SilentlyContinue + if (-not $dotnetTrace) { + throw "dotnet-trace is not installed. Install it with: dotnet tool install -g dotnet-trace" + } + Write-Verbose "dotnet-trace found at: $($dotnetTrace.Source)" + + # Check project exists + if (-not (Test-Path $AppHostProject)) { + throw "AppHost project not found at: $AppHostProject" + } + Write-Verbose "AppHost project found at: $AppHostProject" + + Write-Host "Prerequisites check passed." -ForegroundColor Green +} + +# Build the project +function Build-AppHost { + Write-Host "Building $AppHostName..." -ForegroundColor Cyan + + Push-Location $AppHostDir + try { + $buildOutput = & dotnet build -c Release --nologo 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host ($buildOutput -join "`n") -ForegroundColor Red + throw "Failed to build $AppHostName" + } + Write-Verbose ($buildOutput -join "`n") + Write-Host "Build completed successfully." -ForegroundColor Green + } + finally { + Pop-Location + } +} + +# Run a single iteration of the performance test +function Invoke-PerformanceIteration { + param( + [int]$IterationNumber, + [string]$TraceOutputPath + ) + + Write-Host "`nIteration $IterationNumber" -ForegroundColor Yellow + Write-Host ("-" * 40) -ForegroundColor Yellow + + $nettracePath = "$TraceOutputPath.nettrace" + $appProcess = $null + $traceProcess = $null + + try { + # Find the compiled executable - we need the path to launch it + $exePath = $null + $dllPath = $null + + # Search in multiple possible output locations: + # 1. Arcade-style: artifacts/bin//Release// + # 2. Traditional: /bin/Release// + $searchPaths = @( + (Join-Path $RepoRoot 'artifacts' 'bin' $AppHostName 'Release'), + (Join-Path $AppHostDir 'bin' 'Release') + ) + + foreach ($basePath in $searchPaths) { + if (-not (Test-Path $basePath)) { + continue + } + + # Find TFM subdirectories (e.g., net8.0, net9.0, net10.0) + $tfmDirs = Get-ChildItem -Path $basePath -Directory -Filter 'net*' -ErrorAction SilentlyContinue + foreach ($tfmDir in $tfmDirs) { + $candidateExe = Join-Path $tfmDir.FullName "$AppHostName.exe" + $candidateDll = Join-Path $tfmDir.FullName "$AppHostName.dll" + + if (Test-Path $candidateExe) { + $exePath = $candidateExe + Write-Verbose "Found executable at: $exePath" + break + } + elseif (Test-Path $candidateDll) { + $dllPath = $candidateDll + Write-Verbose "Found DLL at: $dllPath" + break + } + } + + if ($exePath -or $dllPath) { + break + } + } + + if (-not $exePath -and -not $dllPath) { + $searchedPaths = $searchPaths -join "`n - " + throw "Could not find compiled executable or DLL. Searched in:`n - $searchedPaths`nPlease build the project first (without -SkipBuild)." + } + + # Read launchSettings.json to get environment variables + $launchSettingsPath = Join-Path $AppHostDir 'Properties' 'launchSettings.json' + $envVars = @{} + if (Test-Path $launchSettingsPath) { + Write-Verbose "Reading launch settings from: $launchSettingsPath" + try { + # Read the file and remove JSON comments (// style) before parsing + # Only remove lines that start with // (after optional whitespace) to avoid breaking URLs like https:// + $jsonLines = Get-Content $launchSettingsPath + $filteredLines = $jsonLines | Where-Object { $_.Trim() -notmatch '^//' } + $jsonContent = $filteredLines -join "`n" + $launchSettings = $jsonContent | ConvertFrom-Json + + # Try to find a suitable profile (prefer 'http' for simplicity, then first available) + $profile = $null + if ($launchSettings.profiles.http) { + $profile = $launchSettings.profiles.http + Write-Verbose "Using 'http' launch profile" + } + elseif ($launchSettings.profiles.https) { + $profile = $launchSettings.profiles.https + Write-Verbose "Using 'https' launch profile" + } + else { + # Use first profile that has environmentVariables + foreach ($prop in $launchSettings.profiles.PSObject.Properties) { + if ($prop.Value.environmentVariables) { + $profile = $prop.Value + Write-Verbose "Using '$($prop.Name)' launch profile" + break + } + } + } + + if ($profile -and $profile.environmentVariables) { + foreach ($prop in $profile.environmentVariables.PSObject.Properties) { + $envVars[$prop.Name] = $prop.Value + Write-Verbose " Environment: $($prop.Name)=$($prop.Value)" + } + } + + # Use applicationUrl to set ASPNETCORE_URLS if not already set + if ($profile -and $profile.applicationUrl -and -not $envVars.ContainsKey('ASPNETCORE_URLS')) { + $envVars['ASPNETCORE_URLS'] = $profile.applicationUrl + Write-Verbose " Environment: ASPNETCORE_URLS=$($profile.applicationUrl) (from applicationUrl)" + } + } + catch { + Write-Warning "Failed to parse launchSettings.json: $_" + } + } + else { + Write-Verbose "No launchSettings.json found at: $launchSettingsPath" + } + + # Always ensure Development environment is set + if (-not $envVars.ContainsKey('DOTNET_ENVIRONMENT')) { + $envVars['DOTNET_ENVIRONMENT'] = 'Development' + } + if (-not $envVars.ContainsKey('ASPNETCORE_ENVIRONMENT')) { + $envVars['ASPNETCORE_ENVIRONMENT'] = 'Development' + } + + # Start the AppHost application as a separate process + Write-Host "Starting $AppHostName..." -ForegroundColor Cyan + + $appPsi = [System.Diagnostics.ProcessStartInfo]::new() + if ($exePath) { + $appPsi.FileName = $exePath + $appPsi.Arguments = '' + } + else { + $appPsi.FileName = 'dotnet' + $appPsi.Arguments = "`"$dllPath`"" + } + $appPsi.WorkingDirectory = $AppHostDir + $appPsi.UseShellExecute = $false + $appPsi.RedirectStandardOutput = $true + $appPsi.RedirectStandardError = $true + $appPsi.CreateNoWindow = $true + + # Set environment variables from launchSettings.json + foreach ($key in $envVars.Keys) { + $appPsi.Environment[$key] = $envVars[$key] + } + + $appProcess = [System.Diagnostics.Process]::Start($appPsi) + $appPid = $appProcess.Id + + Write-Verbose "$AppHostName started with PID: $appPid" + + # Give the process a moment to initialize before attaching + Start-Sleep -Milliseconds 200 + + # Verify the process is still running + if ($appProcess.HasExited) { + $stdout = $appProcess.StandardOutput.ReadToEnd() + $stderr = $appProcess.StandardError.ReadToEnd() + throw "Application exited immediately with code $($appProcess.ExitCode).`nStdOut: $stdout`nStdErr: $stderr" + } + + # Start dotnet-trace to attach to the running process + Write-Host "Attaching trace collection to PID $appPid..." -ForegroundColor Cyan + + # Use dotnet-trace with the EventSource provider + # Format: ProviderName:Keywords:Level + # Keywords=0xFFFFFFFF (all), Level=5 (Verbose) + $providers = "${EventSourceName}" + + # Convert TraceDurationSeconds to dd:hh:mm:ss format required by dotnet-trace + $days = [math]::Floor($TraceDurationSeconds / 86400) + $hours = [math]::Floor(($TraceDurationSeconds % 86400) / 3600) + $minutes = [math]::Floor(($TraceDurationSeconds % 3600) / 60) + $seconds = $TraceDurationSeconds % 60 + $traceDuration = '{0:00}:{1:00}:{2:00}:{3:00}' -f $days, $hours, $minutes, $seconds + + $traceArgs = @( + 'collect', + '--process-id', $appPid, + '--providers', $providers, + '--output', $nettracePath, + '--format', 'nettrace', + '--duration', $traceDuration, + '--buffersize', '8192' + ) + + Write-Verbose "dotnet-trace arguments: $($traceArgs -join ' ')" + + $tracePsi = [System.Diagnostics.ProcessStartInfo]::new() + $tracePsi.FileName = 'dotnet-trace' + $tracePsi.Arguments = $traceArgs -join ' ' + $tracePsi.WorkingDirectory = $AppHostDir + $tracePsi.UseShellExecute = $false + $tracePsi.RedirectStandardOutput = $true + $tracePsi.RedirectStandardError = $true + $tracePsi.CreateNoWindow = $true + + $traceProcess = [System.Diagnostics.Process]::Start($tracePsi) + + Write-Host "Collecting performance trace..." -ForegroundColor Cyan + + # Wait for trace to complete + $traceProcess.WaitForExit() + + # Read app process output (what was captured while trace was running) + # Use async read to avoid blocking - read whatever is available + $appStdout = "" + $appStderr = "" + if ($appProcess -and -not $appProcess.HasExited) { + # Process is still running, we can try to read available output + # Note: ReadToEnd would block, so we read what's available after stopping + } + + $traceOutput = $traceProcess.StandardOutput.ReadToEnd() + $traceError = $traceProcess.StandardError.ReadToEnd() + + if ($traceOutput) { Write-Verbose "dotnet-trace output: $traceOutput" } + if ($traceError) { Write-Verbose "dotnet-trace stderr: $traceError" } + + # Check if trace file was created despite any errors + # dotnet-trace may report errors during cleanup but the trace file is often still valid + if ($traceProcess.ExitCode -ne 0) { + if (Test-Path $nettracePath) { + Write-Warning "dotnet-trace exited with code $($traceProcess.ExitCode), but trace file was created. Attempting to analyze." + } + else { + Write-Warning "dotnet-trace exited with code $($traceProcess.ExitCode) and no trace file was created." + return $null + } + } + + Write-Host "Trace collection completed." -ForegroundColor Green + + return $nettracePath + } + finally { + # Clean up the application process and capture its output + if ($appProcess) { + # Read any remaining output before killing the process + $appStdout = "" + $appStderr = "" + try { + # Give a moment for any buffered output + Start-Sleep -Milliseconds 100 + + # We need to read asynchronously since the process may still be running + # Read what's available without blocking indefinitely + $stdoutTask = $appProcess.StandardOutput.ReadToEndAsync() + $stderrTask = $appProcess.StandardError.ReadToEndAsync() + + # Wait briefly for output + [System.Threading.Tasks.Task]::WaitAll(@($stdoutTask, $stderrTask), 1000) | Out-Null + + if ($stdoutTask.IsCompleted) { + $appStdout = $stdoutTask.Result + } + if ($stderrTask.IsCompleted) { + $appStderr = $stderrTask.Result + } + } + catch { + # Ignore errors reading output + } + + if ($appStdout) { + Write-Verbose "Application stdout:`n$appStdout" + } + if ($appStderr) { + Write-Verbose "Application stderr:`n$appStderr" + } + + if (-not $appProcess.HasExited) { + Write-Verbose "Stopping $AppHostName (PID: $($appProcess.Id))..." + try { + # Try graceful shutdown first + $appProcess.Kill($true) + $appProcess.WaitForExit(5000) | Out-Null + } + catch { + Write-Warning "Failed to stop application: $_" + } + } + $appProcess.Dispose() + } + + # Clean up trace process + if ($traceProcess) { + if (-not $traceProcess.HasExited) { + try { + $traceProcess.Kill() + $traceProcess.WaitForExit(2000) | Out-Null + } + catch { + # Ignore errors killing trace process + } + } + $traceProcess.Dispose() + } + } +} + +# Path to the trace analyzer tool +$TraceAnalyzerDir = Join-Path $ScriptDir 'TraceAnalyzer' +$TraceAnalyzerProject = Join-Path $TraceAnalyzerDir 'TraceAnalyzer.csproj' + +# Build the trace analyzer tool +function Build-TraceAnalyzer { + if (-not (Test-Path $TraceAnalyzerProject)) { + Write-Warning "TraceAnalyzer project not found at: $TraceAnalyzerProject" + return $false + } + + Write-Verbose "Building TraceAnalyzer tool..." + $buildOutput = & dotnet build $TraceAnalyzerProject -c Release --verbosity quiet 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to build TraceAnalyzer: $buildOutput" + return $false + } + + Write-Verbose "TraceAnalyzer built successfully" + return $true +} + +# Parse nettrace file using the TraceAnalyzer tool +function Get-StartupTiming { + param( + [string]$TracePath + ) + + Write-Host "Analyzing trace: $TracePath" -ForegroundColor Cyan + + if (-not (Test-Path $TracePath)) { + Write-Warning "Trace file not found: $TracePath" + return $null + } + + try { + $output = & dotnet run --project $TraceAnalyzerProject -c Release --no-build -- $TracePath 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warning "TraceAnalyzer failed: $output" + return $null + } + + $result = $output | Select-Object -Last 1 + if ($result -eq 'null') { + Write-Warning "Could not find DcpModelCreation events in the trace" + return $null + } + + $duration = [double]::Parse($result, [System.Globalization.CultureInfo]::InvariantCulture) + Write-Verbose "Calculated duration: $duration ms" + return $duration + } + catch { + Write-Warning "Error parsing trace: $_" + return $null + } +} + +# Main execution +function Main { + Write-Host "==================================================" -ForegroundColor Cyan + Write-Host " Aspire Startup Performance Measurement" -ForegroundColor Cyan + Write-Host "==================================================" -ForegroundColor Cyan + Write-Host "" + Write-Host "Project: $AppHostName" + Write-Host "Project Path: $AppHostProject" + Write-Host "Iterations: $Iterations" + Write-Host "Trace Duration: $TraceDurationSeconds seconds" + Write-Host "Pause Between Iterations: $PauseBetweenIterationsSeconds seconds" + Write-Host "Preserve Traces: $PreserveTraces" + if ($PreserveTraces -or $TraceOutputDirectory) { + Write-Host "Trace Directory: $OutputDirectory" + } + Write-Host "" + + Test-Prerequisites + + # Build the TraceAnalyzer tool for parsing traces + $traceAnalyzerAvailable = Build-TraceAnalyzer + + # Ensure output directory exists + if (-not (Test-Path $OutputDirectory)) { + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + } + + if (-not $SkipBuild) { + Build-AppHost + } + else { + Write-Host "Skipping build (SkipBuild flag set)" -ForegroundColor Yellow + } + + $results = @() + $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' + + try { + for ($i = 1; $i -le $Iterations; $i++) { + $traceBaseName = "${AppHostName}_startup_${timestamp}_iter${i}" + $traceOutputPath = Join-Path $OutputDirectory $traceBaseName + + $tracePath = Invoke-PerformanceIteration -IterationNumber $i -TraceOutputPath $traceOutputPath + + if ($tracePath -and (Test-Path $tracePath)) { + $duration = $null + if ($traceAnalyzerAvailable) { + $duration = Get-StartupTiming -TracePath $tracePath + } + + if ($null -ne $duration) { + $results += [PSCustomObject]@{ + Iteration = $i + TracePath = $tracePath + StartupTimeMs = [math]::Round($duration, 2) + } + Write-Host "Startup time: $([math]::Round($duration, 2)) ms" -ForegroundColor Green + } + else { + $results += [PSCustomObject]@{ + Iteration = $i + TracePath = $tracePath + StartupTimeMs = $null + } + Write-Host "Trace collected: $tracePath" -ForegroundColor Green + } + } + else { + Write-Warning "No trace file generated for iteration $i" + } + + # Pause between iterations + if ($i -lt $Iterations -and $PauseBetweenIterationsSeconds -gt 0) { + Write-Verbose "Pausing for $PauseBetweenIterationsSeconds seconds before next iteration..." + Start-Sleep -Seconds $PauseBetweenIterationsSeconds + } + } + } + finally { + # Clean up temporary trace directory if not preserving traces + if ($ShouldCleanupDirectory -and (Test-Path $OutputDirectory)) { + Write-Verbose "Cleaning up temporary trace directory: $OutputDirectory" + Remove-Item -Path $OutputDirectory -Recurse -Force -ErrorAction SilentlyContinue + } + } + + # Summary + Write-Host "" + Write-Host "==================================================" -ForegroundColor Cyan + Write-Host " Results Summary" -ForegroundColor Cyan + Write-Host "==================================================" -ForegroundColor Cyan + + # Wrap in @() to ensure array even with single/null results + $validResults = @($results | Where-Object { $null -ne $_.StartupTimeMs }) + + if ($validResults.Count -gt 0) { + Write-Host "" + # Only show TracePath in summary if PreserveTraces is set + if ($PreserveTraces) { + $results | Format-Table -AutoSize + } + else { + $results | Select-Object Iteration, StartupTimeMs | Format-Table -AutoSize + } + + $times = @($validResults | ForEach-Object { $_.StartupTimeMs }) + $avg = ($times | Measure-Object -Average).Average + $min = ($times | Measure-Object -Minimum).Minimum + $max = ($times | Measure-Object -Maximum).Maximum + + Write-Host "" + Write-Host "Statistics:" -ForegroundColor Yellow + Write-Host " Successful iterations: $($validResults.Count) / $Iterations" + Write-Host " Minimum: $([math]::Round($min, 2)) ms" + Write-Host " Maximum: $([math]::Round($max, 2)) ms" + Write-Host " Average: $([math]::Round($avg, 2)) ms" + + if ($validResults.Count -gt 1) { + $stdDev = [math]::Sqrt(($times | ForEach-Object { [math]::Pow($_ - $avg, 2) } | Measure-Object -Average).Average) + Write-Host " Std Dev: $([math]::Round($stdDev, 2)) ms" + } + + if ($PreserveTraces) { + Write-Host "" + Write-Host "Trace files saved to: $OutputDirectory" -ForegroundColor Cyan + } + } + elseif ($results.Count -gt 0) { + Write-Host "" + Write-Host "Collected $($results.Count) trace(s) but could not extract timing." -ForegroundColor Yellow + if ($PreserveTraces) { + Write-Host "" + Write-Host "Trace files saved to: $OutputDirectory" -ForegroundColor Cyan + $results | Select-Object Iteration, TracePath | Format-Table -AutoSize + Write-Host "" + Write-Host "Open traces in PerfView or Visual Studio to analyze startup timing." -ForegroundColor Yellow + } + } + else { + Write-Warning "No traces were collected." + } + + return $results +} + +# Run the script +Main diff --git a/tools/perf/TraceAnalyzer/Program.cs b/tools/perf/TraceAnalyzer/Program.cs new file mode 100644 index 00000000000..76ffe45d44d --- /dev/null +++ b/tools/perf/TraceAnalyzer/Program.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. + +// Tool to analyze .nettrace files and extract Aspire startup timing information. +// Usage: dotnet run -- +// Output: Prints the startup duration in milliseconds to stdout, or "null" if events not found. + +using Microsoft.Diagnostics.Tracing; + +if (args.Length == 0) +{ + Console.Error.WriteLine("Usage: TraceAnalyzer "); + return 1; +} + +var tracePath = args[0]; + +if (!File.Exists(tracePath)) +{ + Console.Error.WriteLine($"Error: File not found: {tracePath}"); + return 1; +} + +// Event IDs from AspireEventSource +const int DcpModelCreationStartEventId = 17; +const int DcpModelCreationStopEventId = 18; + +const string AspireHostingProviderName = "Microsoft-Aspire-Hosting"; + +try +{ + double? startTime = null; + double? stopTime = null; + + using (var source = new EventPipeEventSource(tracePath)) + { + source.Dynamic.AddCallbackForProviderEvents((string pName, string eName) => + { + if (pName != AspireHostingProviderName) + { + return EventFilterResponse.RejectProvider; + } + if (eName == null || eName.StartsWith("DcpModelCreation", StringComparison.Ordinal)) + { + return EventFilterResponse.AcceptEvent; + } + return EventFilterResponse.RejectEvent; + }, + (TraceEvent traceEvent) => + { + if ((int)traceEvent.ID == DcpModelCreationStartEventId) + { + startTime = traceEvent.TimeStampRelativeMSec; + } + else if ((int)traceEvent.ID == DcpModelCreationStopEventId) + { + stopTime = traceEvent.TimeStampRelativeMSec; + } + }); + + source.Process(); + } + + if (startTime.HasValue && stopTime.HasValue) + { + var duration = stopTime.Value - startTime.Value; + Console.WriteLine(duration.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)); + return 0; + } + else + { + Console.WriteLine("null"); + return 0; + } +} +catch (Exception ex) +{ + Console.Error.WriteLine($"Error parsing trace: {ex.Message}"); + return 1; +} diff --git a/tools/perf/TraceAnalyzer/TraceAnalyzer.csproj b/tools/perf/TraceAnalyzer/TraceAnalyzer.csproj new file mode 100644 index 00000000000..f984521fbc3 --- /dev/null +++ b/tools/perf/TraceAnalyzer/TraceAnalyzer.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + enable + enable + + false + + + + + + + From 5aa8a61518fb2fa7b4581ba36342fb61d98fe79a Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Wed, 11 Feb 2026 17:21:49 -0800 Subject: [PATCH 085/256] Add backmerge release workflow to automate merging changes from release/13.2 to main (#14453) * Add backmerge release workflow to automate merging changes from release/13.2 to main * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply more fixes and use dotnet's action --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/README.md | 23 ++++ .github/workflows/backmerge-release.yml | 152 ++++++++++++++++++++++++ .github/workflows/ci.yml | 1 + 3 files changed, 176 insertions(+) create mode 100644 .github/workflows/backmerge-release.yml diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 06975dcd71c..e802e904706 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -99,3 +99,26 @@ When you comment on a PR (not an issue), the workflow will automatically push ch ### Concurrency The workflow uses concurrency groups based on the issue/PR number to prevent race conditions when multiple commands are issued on the same issue. + +## Backmerge Release Workflow + +The `backmerge-release.yml` workflow automatically creates PRs to merge changes from `release/13.2` back into `main`. + +### Schedule + +Runs daily at 00:00 UTC (4pm PT during standard time, 5pm PT during daylight saving time). Can also be triggered manually via `workflow_dispatch`. + +### Behavior + +1. **Change Detection**: Checks if `release/13.2` has commits not in `main` +2. **PR Creation**: If changes exist, creates a PR to merge `release/13.2` → `main` +3. **Auto-merge**: Enables GitHub's auto-merge feature, so the PR merges automatically once approved +4. **Conflict Handling**: If merge conflicts occur, creates an issue instead of a PR + +### Assignees + +PRs and conflict issues are automatically assigned to @joperezr and @radical. + +### Manual Trigger + +To trigger manually, go to Actions → "Backmerge Release to Main" → "Run workflow". diff --git a/.github/workflows/backmerge-release.yml b/.github/workflows/backmerge-release.yml new file mode 100644 index 00000000000..05cd2c507d1 --- /dev/null +++ b/.github/workflows/backmerge-release.yml @@ -0,0 +1,152 @@ +name: Backmerge Release to Main + +on: + schedule: + - cron: '0 0 * * *' # Runs daily at 00:00 UTC (16:00 PST / 17:00 PDT) + workflow_dispatch: # Allow manual trigger + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + backmerge: + runs-on: ubuntu-latest + timeout-minutes: 15 + if: ${{ github.repository_owner == 'dotnet' }} + + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 # Full history needed for merge + + - name: Check for changes to backmerge + id: check + run: | + git fetch origin main release/13.2 + BEHIND_COUNT=$(git rev-list --count origin/main..origin/release/13.2) + echo "behind_count=$BEHIND_COUNT" >> $GITHUB_OUTPUT + if [ "$BEHIND_COUNT" -gt 0 ]; then + echo "changes=true" >> $GITHUB_OUTPUT + echo "Found $BEHIND_COUNT commits in release/13.2 not in main" + else + echo "changes=false" >> $GITHUB_OUTPUT + echo "No changes to backmerge - release/13.2 is up-to-date with main" + fi + + - name: Attempt merge and create branch + if: steps.check.outputs.changes == 'true' + id: merge + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git checkout origin/main + git checkout -b backmerge/release-13.2-to-main + + # Attempt the merge + if git merge origin/release/13.2 --no-edit; then + echo "merge_success=true" >> $GITHUB_OUTPUT + git push origin backmerge/release-13.2-to-main --force + echo "Merge successful, branch pushed" + else + echo "merge_success=false" >> $GITHUB_OUTPUT + git merge --abort + echo "Merge conflicts detected" + fi + + - name: Create Pull Request + if: steps.check.outputs.changes == 'true' && steps.merge.outputs.merge_success == 'true' + id: create-pr + uses: dotnet/actions-create-pull-request@e8d799aa1f8b17f324f9513832811b0a62f1e0b1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + head: backmerge/release-13.2-to-main + base: main + title: "[Automated] Backmerge release/13.2 to main" + labels: area-engineering-systems + body: | + ## Automated Backmerge + + This PR merges changes from `release/13.2` back into `main`. + + **Commits to merge:** ${{ steps.check.outputs.behind_count }} + + This PR was created automatically to keep `main` up-to-date with release branch changes. + Once approved, it will auto-merge. + + --- + *This PR was generated by the [backmerge-release](${{ github.server_url }}/${{ github.repository }}/actions/workflows/backmerge-release.yml) workflow.* + + - name: Add assignees and enable auto-merge + if: steps.create-pr.outputs.pull-request-number + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr edit ${{ steps.create-pr.outputs.pull-request-number }} --add-assignee joperezr,radical + gh pr merge ${{ steps.create-pr.outputs.pull-request-number }} --auto --merge + + - name: Create issue for merge conflicts + if: steps.check.outputs.changes == 'true' && steps.merge.outputs.merge_success == 'false' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const workflowRunUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + // Check if there's already an open issue for this + const existingIssues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'backmerge-conflict', + creator: 'github-actions[bot]' + }); + + if (existingIssues.data.length > 0) { + console.log(`Existing backmerge conflict issue found: #${existingIssues.data[0].number}`); + // Add a comment to the existing issue + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existingIssues.data[0].number, + body: `⚠️ Merge conflicts still exist.\n\n**Workflow run:** ${workflowRunUrl}\n\nPlease resolve the conflicts manually.` + }); + return; + } + + // Create a new issue + const issueBody = [ + '## Backmerge Conflict', + '', + 'The automated backmerge from `release/13.2` to `main` failed due to merge conflicts.', + '', + '### What to do', + '', + '1. Checkout main and attempt the merge locally:', + ' ```bash', + ' git checkout main', + ' git pull origin main', + ' git merge origin/release/13.2', + ' ```', + '2. Resolve the conflicts', + '3. Push the merge commit or create a PR manually', + '', + '### Details', + '', + `**Workflow run:** ${workflowRunUrl}`, + '**Commits to merge:** ${{ steps.check.outputs.behind_count }}', + '', + '---', + `*This issue was created automatically by the [backmerge-release](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/workflows/backmerge-release.yml) workflow.*` + ].join('\n'); + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: '[Backmerge] Merge conflicts between release/13.2 and main', + body: issueBody, + assignees: ['joperezr', 'radical'], + labels: ['area-engineering-systems', 'backmerge-conflict'] + }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0007e8030f..0901bf2f888 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: eng/pipelines/.* eng/test-configuration.json \.github/workflows/apply-test-attributes.yml + \.github/workflows/backmerge-release.yml \.github/workflows/backport.yml \.github/workflows/dogfood-comment.yml \.github/workflows/generate-api-diffs.yml From 62d5c9777626a7c51525420657352cc1dd0421e0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:23:17 -0800 Subject: [PATCH 086/256] Bump Aspire branding from 13.2 to 13.3 (#14456) * Initial plan * Bump Aspire branding from 13.2 to 13.3 Co-authored-by: joperezr <13854455+joperezr@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: joperezr <13854455+joperezr@users.noreply.github.com> --- .github/policies/milestoneAssignment.prClosed.yml | 8 ++++---- eng/Versions.props | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/policies/milestoneAssignment.prClosed.yml b/.github/policies/milestoneAssignment.prClosed.yml index 1ec03595d00..ad9aaad2f57 100644 --- a/.github/policies/milestoneAssignment.prClosed.yml +++ b/.github/policies/milestoneAssignment.prClosed.yml @@ -16,16 +16,16 @@ configuration: branch: main then: - addMilestone: - milestone: 13.2 + milestone: 13.3 description: '[Milestone Assignments] Assign Milestone to PRs merged to the `main` branch' - if: - payloadType: Pull_Request - isAction: action: Closed - targetsBranch: - branch: release/13.1 + branch: release/13.2 then: - removeMilestone - addMilestone: - milestone: 13.1.1 - description: '[Milestone Assignments] Assign Milestone to PRs merged to release/13.1 branch' + milestone: 13.2 + description: '[Milestone Assignments] Assign Milestone to PRs merged to release/13.2 branch' diff --git a/eng/Versions.props b/eng/Versions.props index a2d25ba628a..84da946872a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -2,7 +2,7 @@ 13 - 2 + 3 0 $(MajorVersion).$(MinorVersion).$(PatchVersion) preview.1 From b7ad6a2000274585c761e8fd18df76dd59d86f1b Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Wed, 11 Feb 2026 17:29:54 -0800 Subject: [PATCH 087/256] Add override options for staging feed and quality in configuration schema and implement related tests (#14455) * Add override options for staging feed and quality in configuration schema and implement related tests * Add stagingVersionPrefix property to configuration schemas and implement related logic in PackagingService --- .../aspire-global-settings.schema.json | 17 ++ extension/schemas/aspire-settings.schema.json | 17 ++ src/Aspire.Cli/Packaging/PackageChannel.cs | 25 +- src/Aspire.Cli/Packaging/PackagingService.cs | 42 ++- .../Packaging/PackagingServiceTests.cs | 247 ++++++++++++++++++ 5 files changed, 337 insertions(+), 11 deletions(-) diff --git a/extension/schemas/aspire-global-settings.schema.json b/extension/schemas/aspire-global-settings.schema.json index bf16ec26e56..8f7a8db0414 100644 --- a/extension/schemas/aspire-global-settings.schema.json +++ b/extension/schemas/aspire-global-settings.schema.json @@ -294,6 +294,23 @@ "sdkVersion": { "description": "The Aspire SDK version used for this polyglot AppHost project. Determines the version of Aspire.Hosting packages to use.", "type": "string" + }, + "overrideStagingFeed": { + "description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", + "type": "string" + }, + "overrideStagingQuality": { + "description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", + "type": "string", + "enum": [ + "Stable", + "Prerelease", + "Both" + ] + }, + "stagingVersionPrefix": { + "description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.", + "type": "string" } }, "additionalProperties": false diff --git a/extension/schemas/aspire-settings.schema.json b/extension/schemas/aspire-settings.schema.json index 391cf7adb02..c2da807d981 100644 --- a/extension/schemas/aspire-settings.schema.json +++ b/extension/schemas/aspire-settings.schema.json @@ -298,6 +298,23 @@ "sdkVersion": { "description": "The Aspire SDK version used for this polyglot AppHost project. Determines the version of Aspire.Hosting packages to use.", "type": "string" + }, + "overrideStagingFeed": { + "description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", + "type": "string" + }, + "overrideStagingQuality": { + "description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", + "type": "string", + "enum": [ + "Stable", + "Prerelease", + "Both" + ] + }, + "stagingVersionPrefix": { + "description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.", + "type": "string" } }, "additionalProperties": false diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index e9bc936d6e9..8152e83ca9c 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -8,7 +8,7 @@ namespace Aspire.Cli.Packaging; -internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null) +internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null) { public string Name { get; } = name; public PackageChannelQuality Quality { get; } = quality; @@ -16,9 +16,20 @@ internal class PackageChannel(string name, PackageChannelQuality quality, Packag public PackageChannelType Type { get; } = mappings is null ? PackageChannelType.Implicit : PackageChannelType.Explicit; public bool ConfigureGlobalPackagesFolder { get; } = configureGlobalPackagesFolder; public string? CliDownloadBaseUrl { get; } = cliDownloadBaseUrl; + public SemVersion? VersionPrefix { get; } = versionPrefix; public string SourceDetails { get; } = ComputeSourceDetails(mappings); + private bool MatchesVersionPrefix(SemVersion semVer) + { + if (VersionPrefix is null) + { + return true; + } + + return semVer.Major == VersionPrefix.Major && semVer.Minor == VersionPrefix.Minor; + } + private static string ComputeSourceDetails(PackageMapping[]? mappings) { if (mappings is null) @@ -69,7 +80,7 @@ public async Task> GetTemplatePackagesAsync(DirectoryI { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }); + }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); return filteredPackages; } @@ -104,7 +115,7 @@ public async Task> GetIntegrationPackagesAsync(Directo { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }); + }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); return filteredPackages; } @@ -159,7 +170,7 @@ public async Task> GetPackagesAsync(string packageId, useCache: true, // Enable caching for package channel resolution cancellationToken: cancellationToken); - return packages; + return packages.Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); } // When doing a `dotnet package search` the the results may include stable packages even when searching for @@ -170,14 +181,14 @@ public async Task> GetPackagesAsync(string packageId, { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }); + }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); return filteredPackages; } - public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null) + public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null) { - return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl); + return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, versionPrefix); } public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache) diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index 0456a85bf62..7bde39025b0 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -4,6 +4,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; using Microsoft.Extensions.Configuration; +using Semver; using System.Reflection; namespace Aspire.Cli.Packaging; @@ -75,24 +76,34 @@ public Task> GetChannelsAsync(CancellationToken canc private PackageChannel? CreateStagingChannel() { - var stagingFeedUrl = GetStagingFeedUrl(); + var stagingQuality = GetStagingQuality(); + var hasExplicitFeedOverride = !string.IsNullOrEmpty(configuration["overrideStagingFeed"]); + + // When quality is Prerelease or Both and no explicit feed override is set, + // use the shared daily feed instead of the SHA-specific feed. SHA-specific + // darc-pub-* feeds are only created for stable-quality builds, so a non-Stable + // quality without an explicit feed override can only work with the shared feed. + var useSharedFeed = !hasExplicitFeedOverride && + stagingQuality is not PackageChannelQuality.Stable; + + var stagingFeedUrl = GetStagingFeedUrl(useSharedFeed); if (stagingFeedUrl is null) { return null; } - var stagingQuality = GetStagingQuality(); + var versionPrefix = GetStagingVersionPrefix(); var stagingChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, stagingQuality, new[] { new PackageMapping("Aspire*", stagingFeedUrl), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nuGetPackageCache, configureGlobalPackagesFolder: true, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily"); + }, nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", versionPrefix: versionPrefix); return stagingChannel; } - private string? GetStagingFeedUrl() + private string? GetStagingFeedUrl(bool useSharedFeed) { // Check for configuration override first var overrideFeed = configuration["overrideStagingFeed"]; @@ -107,6 +118,12 @@ public Task> GetChannelsAsync(CancellationToken canc // Invalid URL, fall through to default behavior } + // Use the shared daily feed when builds aren't marked stable + if (useSharedFeed) + { + return "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"; + } + // Extract commit hash from assembly version to build staging feed URL // Staging feed URL template: https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-dotnet-aspire-{commitHash}/nuget/v3/index.json var assembly = Assembly.GetExecutingAssembly(); @@ -148,4 +165,21 @@ private PackageChannelQuality GetStagingQuality() // Default to Stable if not specified or invalid return PackageChannelQuality.Stable; } + + private SemVersion? GetStagingVersionPrefix() + { + var versionPrefixValue = configuration["stagingVersionPrefix"]; + if (string.IsNullOrEmpty(versionPrefixValue)) + { + return null; + } + + // Parse "Major.Minor" format (e.g., "13.2") as a SemVersion for comparison + if (SemVersion.TryParse($"{versionPrefixValue}.0", SemVersionStyles.Strict, out var semVersion)) + { + return semVersion; + } + + return null; + } } diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 5928d77e2d5..74b71823ea7 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -482,4 +482,251 @@ public async Task GetChannelsAsync_WhenStagingChannelDisabled_OrderIsDefaultStab Assert.True(stableIndex < dailyIndex, "stable should come before daily"); Assert.True(dailyIndex < pr12345Index, "daily should come before pr-12345"); } + + [Fact] + public async Task GetChannelsAsync_WhenStagingQualityPrerelease_AndNoFeedOverride_UsesSharedFeed() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + // Set quality to Prerelease but do NOT set overrideStagingFeed + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.Equal(PackageChannelQuality.Prerelease, stagingChannel.Quality); + Assert.False(stagingChannel.ConfigureGlobalPackagesFolder); + + var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*"); + Assert.NotNull(aspireMapping); + Assert.Equal("https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json", aspireMapping.Source); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingQualityBoth_AndNoFeedOverride_UsesSharedFeed() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + // Set quality to Both but do NOT set overrideStagingFeed + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Both" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality); + Assert.False(stagingChannel.ConfigureGlobalPackagesFolder); + + var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*"); + Assert.NotNull(aspireMapping); + Assert.Equal("https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json", aspireMapping.Source); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingQualityPrerelease_WithFeedOverride_UsesFeedOverride() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + // Set both quality override AND feed override — feed override should win + var customFeed = "https://custom-feed.example.com/v3/index.json"; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease", + ["overrideStagingFeed"] = customFeed + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.Equal(PackageChannelQuality.Prerelease, stagingChannel.Quality); + // When an explicit feed override is provided, globalPackagesFolder stays enabled + Assert.True(stagingChannel.ConfigureGlobalPackagesFolder); + + var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*"); + Assert.NotNull(aspireMapping); + Assert.Equal(customFeed, aspireMapping.Source); + } + + [Fact] + public async Task NuGetConfigMerger_WhenStagingUsesSharedFeed_DoesNotAddGlobalPackagesFolder() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + // Quality=Prerelease with no feed override → shared feed mode + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease" + }) + .Build(); + + var packagingService = new PackagingService( + new CliExecutionContext(tempDir, tempDir, tempDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"), + new FakeNuGetPackageCache(), + features, + configuration); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.First(c => c.Name == "staging"); + + // Act + await NuGetConfigMerger.CreateOrUpdateAsync(tempDir, stagingChannel).DefaultTimeout(); + + // Assert + var nugetConfigPath = Path.Combine(tempDir.FullName, "nuget.config"); + Assert.True(File.Exists(nugetConfigPath)); + + var configContent = await File.ReadAllTextAsync(nugetConfigPath); + Assert.DoesNotContain("globalPackagesFolder", configContent); + Assert.DoesNotContain(".nugetpackages", configContent); + + // Verify it still has the shared feed URL + Assert.Contains("dotnet9", configContent); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingVersionPrefixSet_ChannelHasVersionPrefix() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json", + ["stagingVersionPrefix"] = "13.2" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.NotNull(stagingChannel.VersionPrefix); + Assert.Equal(13, stagingChannel.VersionPrefix!.Major); + Assert.Equal(2, stagingChannel.VersionPrefix.Minor); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingVersionPrefixNotSet_ChannelHasNoVersionPrefix() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.Null(stagingChannel.VersionPrefix); + } + + [Fact] + public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoVersionPrefix() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json", + ["stagingVersionPrefix"] = "not-a-version" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert + var stagingChannel = channels.First(c => c.Name == "staging"); + Assert.Null(stagingChannel.VersionPrefix); + } } From 8a8262135d4dbc79795de3b7f72bc263d3ded42a Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 12 Feb 2026 09:54:37 -0600 Subject: [PATCH 088/256] Update Azure.Core to latest version - lift all runtime dependencies to latest (#14361) * Update to Azure.Core 1.51.1 Use latest versions for all dotnet/runtime nuget packages. This simplifies our dependency management. Remove ForceLatestDotnetVersions property from multiple project files * Update AzureDeployerTests to use WaitForShutdown instead of StopAsync There is a timing issue when using Start/Stop since the background pipeline might still be running and it cancels the pipeline before it can complete. * Fix AuxiliaryBackchannelTests by adding a Task that completes when the AuxiliaryBackchannelService is listening and ready for connections. * Remove double registration of AuxiliaryBackchannelService as an IHostedService. * Fix ResourceLoggerForwarderServiceTests to ensure the ResourceLoggerForwarderService has started before signalling the stopping token. --- Directory.Packages.props | 70 ++++++----------- .../AzureAIFoundryEndToEnd.WebStory.csproj | 3 - .../AzureOpenAIEndToEnd.WebStory.csproj | 3 - .../GitHubModelsEndToEnd.WebStory.csproj | 3 - .../OpenAIEndToEnd.WebStory.csproj | 3 - .../AuxiliaryBackchannelService.cs | 10 +++ .../Aspire.Azure.AI.Inference.csproj | 2 - .../Aspire.Azure.AI.OpenAI.csproj | 2 - .../Aspire.OpenAI/Aspire.OpenAI.csproj | 2 - .../Aspire.Azure.AI.Inference.Tests.csproj | 3 - .../Aspire.Azure.AI.OpenAI.Tests.csproj | 3 - .../AzureDeployerTests.cs | 4 +- .../ResourceLoggerForwarderServiceTests.cs | 18 +++++ .../Backchannel/AuxiliaryBackchannelTests.cs | 75 +++++-------------- .../Aspire.OpenAI.Tests.csproj | 3 - 15 files changed, 71 insertions(+), 133 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 8e4674e5743..097be0ed6d4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -175,17 +175,18 @@ - + - + + - + @@ -195,6 +196,23 @@ + + + + + + + + + + + + + + + + @@ -218,18 +236,6 @@ - - - - - - - - - - - - @@ -254,26 +260,8 @@ - - - - - - - - - - - - - - - - - - - + @@ -295,17 +283,5 @@ - - - - - - - - - - - - diff --git a/playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.WebStory/AzureAIFoundryEndToEnd.WebStory.csproj b/playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.WebStory/AzureAIFoundryEndToEnd.WebStory.csproj index cd25e05445f..aa19fad6805 100644 --- a/playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.WebStory/AzureAIFoundryEndToEnd.WebStory.csproj +++ b/playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.WebStory/AzureAIFoundryEndToEnd.WebStory.csproj @@ -4,9 +4,6 @@ $(DefaultTargetFramework) enable enable - - - true diff --git a/playground/AzureOpenAIEndToEnd/AzureOpenAIEndToEnd.WebStory/AzureOpenAIEndToEnd.WebStory.csproj b/playground/AzureOpenAIEndToEnd/AzureOpenAIEndToEnd.WebStory/AzureOpenAIEndToEnd.WebStory.csproj index cefd34325a6..ede52a85213 100644 --- a/playground/AzureOpenAIEndToEnd/AzureOpenAIEndToEnd.WebStory/AzureOpenAIEndToEnd.WebStory.csproj +++ b/playground/AzureOpenAIEndToEnd/AzureOpenAIEndToEnd.WebStory/AzureOpenAIEndToEnd.WebStory.csproj @@ -4,9 +4,6 @@ $(DefaultTargetFramework) enable enable - - - true diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj index cd25e05445f..aa19fad6805 100644 --- a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj @@ -4,9 +4,6 @@ $(DefaultTargetFramework) enable enable - - - true diff --git a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj index 2df50b9502b..60f4356d1a3 100644 --- a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj +++ b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj @@ -4,9 +4,6 @@ $(DefaultTargetFramework) enable enable - - - true diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs index 6bf750cff6a..2ef434dd1af 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs @@ -22,12 +22,21 @@ internal sealed class AuxiliaryBackchannelService( : BackgroundService { private Socket? _serverSocket; + private readonly TaskCompletionSource _listeningTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); /// /// Gets the Unix socket path where the auxiliary backchannel is listening. /// public string? SocketPath { get; private set; } + /// + /// Gets a task that completes when the server socket is bound and listening for connections. + /// + /// + /// Used by tests to wait until the backchannel is ready before attempting to connect. + /// + internal Task ListeningTask => _listeningTcs.Task; + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try @@ -72,6 +81,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _serverSocket.Listen(backlog: 10); // Allow multiple pending connections logger.LogDebug("Auxiliary backchannel listening on {SocketPath}", SocketPath); + _listeningTcs.TrySetResult(); // Accept connections in a loop (supporting multiple concurrent connections) while (!stoppingToken.IsCancellationRequested) diff --git a/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj b/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj index 7370e834905..7926621c583 100644 --- a/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj +++ b/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj @@ -9,8 +9,6 @@ $(NoWarn);SYSLIB1100;SYSLIB1101 true - - true diff --git a/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj b/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj index 99d48574b2e..db2deffe6bc 100644 --- a/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj +++ b/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj @@ -10,8 +10,6 @@ $(NoWarn);SYSLIB1100;SYSLIB1101;AOAI001 true - - true diff --git a/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj b/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj index 836c1d9d11c..6b76d148c20 100644 --- a/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj +++ b/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj @@ -8,8 +8,6 @@ $(NoWarn);SYSLIB1100;SYSLIB1101 true - - true diff --git a/tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj b/tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj index cca72f73ac9..948e92b0096 100644 --- a/tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj +++ b/tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj @@ -2,9 +2,6 @@ $(AllTargetFrameworks) - - - true diff --git a/tests/Aspire.Azure.AI.OpenAI.Tests/Aspire.Azure.AI.OpenAI.Tests.csproj b/tests/Aspire.Azure.AI.OpenAI.Tests/Aspire.Azure.AI.OpenAI.Tests.csproj index a27e07a3a63..2f45183b667 100644 --- a/tests/Aspire.Azure.AI.OpenAI.Tests/Aspire.Azure.AI.OpenAI.Tests.csproj +++ b/tests/Aspire.Azure.AI.OpenAI.Tests/Aspire.Azure.AI.OpenAI.Tests.csproj @@ -2,9 +2,6 @@ $(AllTargetFrameworks) - - - true diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index 0c9ff5c0492..09460f25c8d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -1072,7 +1072,7 @@ public async Task DeployAsync_WithAzureResourceDependencies_DoesNotHang(string s // Act using var app = builder.Build(); await app.StartAsync(); - await app.StopAsync(); + await app.WaitForShutdownAsync(); if (step == "diagnostics") { @@ -1159,7 +1159,7 @@ public async Task DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDep // Act using var app = builder.Build(); await app.StartAsync(); - await app.StopAsync(); + await app.WaitForShutdownAsync(); // In diagnostics mode, verify the deployment graph shows correct dependencies var logs = mockActivityReporter.LoggedMessages diff --git a/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs b/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs index 2e1084139c8..d10d3e2775f 100644 --- a/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs @@ -35,11 +35,29 @@ public async Task ExecuteDoesNotThrowOperationCanceledWhenAppStoppingTokenSignal var loggerFactory = new NullLoggerFactory(); var resourceLogForwarder = new ResourceLoggerForwarderService(resourceNotificationService, resourceLoggerService, hostEnvironment, loggerFactory); + // use a task to signal when the resourceLogForwarder has started executing + var subscribedTcs = new TaskCompletionSource(); + var subscriberLoop = Task.Run(async () => + { + await foreach (var sub in resourceLoggerService.WatchAnySubscribersAsync(hostApplicationLifetime.ApplicationStopping)) + { + subscribedTcs.TrySetResult(); + return; + } + }); + await resourceLogForwarder.StartAsync(hostApplicationLifetime.ApplicationStopping); Assert.NotNull(resourceLogForwarder.ExecuteTask); Assert.Equal(TaskStatus.WaitingForActivation, resourceLogForwarder.ExecuteTask.Status); + // Publish an update to the resource to kickstart the notification service loop + var myresource = new CustomResource("myresource"); + await resourceNotificationService.PublishUpdateAsync(myresource, snapshot => snapshot with { State = "Running" }); + + // Wait for the log stream to begin + await subscribedTcs.Task.WaitAsync(TimeSpan.FromSeconds(15)); + // Signal the stopping token hostApplicationLifetime.StopApplication(); diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs index 86f7622be67..1c06ea8f482 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs @@ -28,16 +28,13 @@ public async Task CanStartAuxiliaryBackchannelService() return Task.CompletedTask; }); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service and verify it started var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); Assert.True(File.Exists(service.SocketPath)); @@ -71,25 +68,22 @@ public async Task CanConnectMultipleClientsToAuxiliaryBackchannel() return Task.CompletedTask; }); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect multiple clients concurrently var client1Socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); var client2Socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); var client3Socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); - + var endpoint = new UnixDomainSocketEndPoint(service.SocketPath); - + await client1Socket.ConnectAsync(endpoint).WaitAsync(TimeSpan.FromSeconds(60)); await client2Socket.ConnectAsync(endpoint).WaitAsync(TimeSpan.FromSeconds(60)); await client3Socket.ConnectAsync(endpoint).WaitAsync(TimeSpan.FromSeconds(60)); @@ -116,16 +110,13 @@ public async Task CanInvokeRpcMethodOnAuxiliaryBackchannel() // When the Dashboard is not part of the app model, null should be returned using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -154,16 +145,13 @@ public async Task GetAppHostInformationAsyncReturnsAppHostPath() // This test verifies that GetAppHostInformationAsync returns the AppHost path using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -198,16 +186,13 @@ public async Task MultipleClientsCanInvokeRpcMethodsConcurrently() // When the Dashboard is not part of the app model, null should be returned using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Create multiple clients and invoke RPC methods concurrently @@ -245,16 +230,13 @@ public async Task GetAppHostInformationAsyncReturnsFilePathWithExtension() // For .csproj-based AppHosts, it should include the .csproj extension using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -275,10 +257,10 @@ public async Task GetAppHostInformationAsyncReturnsFilePathWithExtension() Assert.NotNull(appHostInfo); Assert.NotNull(appHostInfo.AppHostPath); Assert.NotEmpty(appHostInfo.AppHostPath); - + // The path should be an absolute path Assert.True(Path.IsPathRooted(appHostInfo.AppHostPath), $"Expected absolute path but got: {appHostInfo.AppHostPath}"); - + // In test scenarios where assembly metadata is not available, we may get a path without extension // (falling back to AppHost:Path). In real scenarios with proper metadata, we should get .csproj or .cs // So we just verify the path is non-empty and rooted @@ -294,22 +276,19 @@ public async Task SocketPathUsesAuxiPrefix() // to avoid Windows reserved device name issues (AUX is reserved on Windows < 11) using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Verify that the socket path uses "auxi.sock." prefix var fileName = Path.GetFileName(service.SocketPath); Assert.StartsWith("auxi.sock.", fileName); - + // Verify that the socket file can be created (not blocked by Windows reserved names) Assert.True(File.Exists(service.SocketPath), $"Socket file should exist at: {service.SocketPath}"); @@ -328,16 +307,13 @@ public async Task CallResourceMcpToolAsyncThrowsWhenResourceNotFound() // Add a simple container resource (without MCP) builder.AddContainer("mycontainer", "nginx"); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -372,16 +348,13 @@ public async Task CallResourceMcpToolAsyncThrowsWhenResourceHasNoMcpAnnotation() // Add a simple container resource (without MCP) builder.AddContainer("mycontainer", "nginx"); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -412,16 +385,13 @@ public async Task StopAppHostAsyncInitiatesShutdown() // This test verifies that StopAppHostAsync initiates AppHost shutdown using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -464,16 +434,13 @@ public async Task GetCapabilitiesAsyncReturnsV1AndV2() // This test verifies that GetCapabilitiesAsync returns both v1 and v2 capabilities using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -505,16 +472,13 @@ public async Task GetAppHostInfoAsyncV2ReturnsAppHostInfo() // This test verifies that the v2 GetAppHostInfoAsync returns AppHost info using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -551,16 +515,13 @@ public async Task GetResourcesAsyncV2ReturnsResources() // Add a simple parameter resource builder.AddParameter("myparam"); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client diff --git a/tests/Aspire.OpenAI.Tests/Aspire.OpenAI.Tests.csproj b/tests/Aspire.OpenAI.Tests/Aspire.OpenAI.Tests.csproj index 62892924e61..dec5f627481 100644 --- a/tests/Aspire.OpenAI.Tests/Aspire.OpenAI.Tests.csproj +++ b/tests/Aspire.OpenAI.Tests/Aspire.OpenAI.Tests.csproj @@ -2,9 +2,6 @@ $(AllTargetFrameworks) - - - true From ec55d98ba2d434815355df838ac927918622d82f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:09:50 -0800 Subject: [PATCH 089/256] Hide the aspire setup command if the bundle isn't available (#14464) * Initial plan * Hide aspire setup command when bundle is not available Conditionally add SetupCommand to the CLI command tree only when IBundleService.IsBundle is true, following the same pattern used for ExecCommand and SdkCommand feature-flag gating. Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Add tests for setup command visibility based on bundle availability Add two tests to RootCommandTests: - SetupCommand_NotAvailable_WhenBundleIsNotAvailable: verifies setup command is hidden when the CLI has no embedded bundle - SetupCommand_Available_WhenBundleIsAvailable: verifies setup command is visible when the CLI has an embedded bundle Also add BundleServiceFactory to CliServiceCollectionTestOptions and TestBundleService to support overriding bundle behavior in tests. Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Cli/Commands/RootCommand.cs | 8 ++++- .../Commands/RootCommandTests.cs | 29 +++++++++++++++++++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 21 +++++++++++++- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index db956046a9a..6126c8d8761 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -10,6 +10,7 @@ using System.Diagnostics; #endif +using Aspire.Cli.Bundles; using Aspire.Cli.Commands.Sdk; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; @@ -131,6 +132,7 @@ public RootCommand( SdkCommand sdkCommand, SetupCommand setupCommand, ExtensionInternalCommand extensionInternalCommand, + IBundleService bundleService, IFeatures featureFlags, IInteractionService interactionService) : base(RootCommandStrings.Description) @@ -208,7 +210,11 @@ public RootCommand( Subcommands.Add(agentCommand); Subcommands.Add(telemetryCommand); Subcommands.Add(docsCommand); - Subcommands.Add(setupCommand); + + if (bundleService.IsBundle) + { + Subcommands.Add(setupCommand); + } if (featureFlags.IsFeatureEnabled(KnownFeatures.ExecCommandEnabled, false)) { diff --git a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs index c58ec07c433..5a11b6da189 100644 --- a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs @@ -344,4 +344,33 @@ public async Task InformationalFlag_DoesNotCreateSentinel_OnSubsequentFirstRun() Assert.True(sentinel.WasCreated); } + [Fact] + public void SetupCommand_NotAvailable_WhenBundleIsNotAvailable() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var hasSetupCommand = command.Subcommands.Any(cmd => cmd.Name == "setup"); + + Assert.False(hasSetupCommand); + } + + [Fact] + public void SetupCommand_Available_WhenBundleIsAvailable() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.BundleServiceFactory = _ => new TestBundleService(isBundle: true); + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var hasSetupCommand = command.Subcommands.Any(cmd => cmd.Name == "setup"); + + Assert.True(hasSetupCommand); + } + } diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 5ef18fe61ad..7b746e42e66 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -130,7 +130,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work // Bundle layout services - return null/no-op implementations to trigger SDK mode fallback // This ensures backward compatibility: no layout found = use legacy SDK mode services.AddSingleton(options.LayoutDiscoveryFactory); - services.AddSingleton(); + services.AddSingleton(options.BundleServiceFactory); services.AddSingleton(); // AppHost project handlers - must match Program.cs registration pattern @@ -501,6 +501,9 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser // Layout discovery - returns null by default (no bundle layout), causing SDK mode fallback public Func LayoutDiscoveryFactory { get; set; } = _ => new NullLayoutDiscovery(); + // Bundle service - returns no-op implementation by default (no embedded bundle) + public Func BundleServiceFactory { get; set; } = _ => new NullBundleService(); + public Func McpServerTransportFactory { get; set; } = (IServiceProvider serviceProvider) => { var loggerFactory = serviceProvider.GetService(); @@ -553,6 +556,22 @@ public Task ExtractAsync(string destinationPath, bool force => Task.FromResult(null); } +/// +/// A configurable bundle service for testing bundle-dependent behavior. +/// +internal sealed class TestBundleService(bool isBundle) : IBundleService +{ + public bool IsBundle => isBundle; + + public Task EnsureExtractedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task ExtractAsync(string destinationPath, bool force = false, CancellationToken cancellationToken = default) + => Task.FromResult(isBundle ? BundleExtractResult.AlreadyUpToDate : BundleExtractResult.NoPayload); + + public Task EnsureExtractedAndGetLayoutAsync(CancellationToken cancellationToken = default) + => Task.FromResult(null); +} + internal sealed class TestOutputTextWriter : TextWriter { private readonly ITestOutputHelper _outputHelper; From 6d4f74759870b5d9e926870f937f4be2a1fa06f5 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:06:22 +0000 Subject: [PATCH 090/256] Update Arcade to latest version from the .NET 10 Eng channel (#13556) Co-authored-by: Jose Perez Rodriguez --- eng/Version.Details.xml | 28 +- eng/Versions.props | 6 +- eng/build.sh | 2 +- eng/common/SetupNugetSources.ps1 | 17 +- eng/common/SetupNugetSources.sh | 17 +- eng/common/build.ps1 | 2 - eng/common/build.sh | 7 +- eng/common/core-templates/job/job.yml | 8 - .../job/publish-build-assets.yml | 18 +- .../core-templates/job/source-build.yml | 8 +- .../core-templates/post-build/post-build.yml | 463 +++++++++--------- .../core-templates/steps/generate-sbom.yml | 2 +- .../steps/install-microbuild-impl.yml | 34 -- .../steps/install-microbuild.yml | 64 ++- .../core-templates/steps/source-build.yml | 2 +- .../steps/source-index-stage1-publish.yml | 8 +- eng/common/darc-init.sh | 2 +- eng/common/dotnet-install.sh | 2 +- eng/common/dotnet.sh | 2 +- eng/common/internal-feed-operations.sh | 2 +- eng/common/native/install-dependencies.sh | 4 +- eng/common/post-build/redact-logs.ps1 | 3 +- .../templates/variables/pool-providers.yml | 2 +- eng/common/tools.ps1 | 17 +- eng/common/tools.sh | 4 + eng/restore-toolset.sh | 2 +- global.json | 6 +- 27 files changed, 357 insertions(+), 375 deletions(-) delete mode 100644 eng/common/core-templates/steps/install-microbuild-impl.yml diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index ea697099b37..0002a2353b2 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -179,33 +179,33 @@ - + https://github.com/dotnet/arcade - 27e190e2a8053738859c082e2f70df62e01ff524 + 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d - + https://github.com/dotnet/arcade - 27e190e2a8053738859c082e2f70df62e01ff524 + 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d - + https://github.com/dotnet/arcade - 27e190e2a8053738859c082e2f70df62e01ff524 + 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d - + https://github.com/dotnet/arcade - 27e190e2a8053738859c082e2f70df62e01ff524 + 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d - + https://github.com/dotnet/arcade - 27e190e2a8053738859c082e2f70df62e01ff524 + 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d - + https://github.com/dotnet/arcade - 27e190e2a8053738859c082e2f70df62e01ff524 + 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d - + https://github.com/dotnet/arcade - 27e190e2a8053738859c082e2f70df62e01ff524 + 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d diff --git a/eng/Versions.props b/eng/Versions.props index 84da946872a..cc14524afa9 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -38,9 +38,9 @@ 0.22.4 0.22.4 - 11.0.0-beta.25610.3 - 11.0.0-beta.25610.3 - 11.0.0-beta.25610.3 + 10.0.0-beta.26110.1 + 10.0.0-beta.26110.1 + 10.0.0-beta.26110.1 10.0.2 10.2.0 diff --git a/eng/build.sh b/eng/build.sh index c80b2c68aba..58596335da2 100755 --- a/eng/build.sh +++ b/eng/build.sh @@ -150,7 +150,7 @@ while [[ $# > 0 ]]; do ;; -mauirestore) - extraargs="$extraargs -restoreMaui" + export restore_maui=true shift 1 ;; diff --git a/eng/common/SetupNugetSources.ps1 b/eng/common/SetupNugetSources.ps1 index fc8d618014e..65ed3a8adef 100644 --- a/eng/common/SetupNugetSources.ps1 +++ b/eng/common/SetupNugetSources.ps1 @@ -1,6 +1,7 @@ # This script adds internal feeds required to build commits that depend on internal package sources. For instance, -# dotnet6-internal would be added automatically if dotnet6 was found in the nuget.config file. In addition also enables -# disabled internal Maestro (darc-int*) feeds. +# dotnet6-internal would be added automatically if dotnet6 was found in the nuget.config file. Similarly, +# dotnet-eng-internal and dotnet-tools-internal are added if dotnet-eng and dotnet-tools are present. +# In addition, this script also enables disabled internal Maestro (darc-int*) feeds. # # Optionally, this script also adds a credential entry for each of the internal feeds if supplied. # @@ -173,4 +174,16 @@ foreach ($dotnetVersion in $dotnetVersions) { } } +# Check for dotnet-eng and add dotnet-eng-internal if present +$dotnetEngSource = $sources.SelectSingleNode("add[@key='dotnet-eng']") +if ($dotnetEngSource -ne $null) { + AddOrEnablePackageSource -Sources $sources -DisabledPackageSources $disabledSources -SourceName "dotnet-eng-internal" -SourceEndPoint "https://pkgs.dev.azure.com/dnceng/internal/_packaging/dotnet-eng-internal/nuget/$feedSuffix" -Creds $creds -Username $userName -pwd $Password +} + +# Check for dotnet-tools and add dotnet-tools-internal if present +$dotnetToolsSource = $sources.SelectSingleNode("add[@key='dotnet-tools']") +if ($dotnetToolsSource -ne $null) { + AddOrEnablePackageSource -Sources $sources -DisabledPackageSources $disabledSources -SourceName "dotnet-tools-internal" -SourceEndPoint "https://pkgs.dev.azure.com/dnceng/internal/_packaging/dotnet-tools-internal/nuget/$feedSuffix" -Creds $creds -Username $userName -pwd $Password +} + $doc.Save($filename) diff --git a/eng/common/SetupNugetSources.sh b/eng/common/SetupNugetSources.sh index b97cc536379..b2163abbe71 100755 --- a/eng/common/SetupNugetSources.sh +++ b/eng/common/SetupNugetSources.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # This script adds internal feeds required to build commits that depend on internal package sources. For instance, -# dotnet6-internal would be added automatically if dotnet6 was found in the nuget.config file. In addition also enables -# disabled internal Maestro (darc-int*) feeds. +# dotnet6-internal would be added automatically if dotnet6 was found in the nuget.config file. Similarly, +# dotnet-eng-internal and dotnet-tools-internal are added if dotnet-eng and dotnet-tools are present. +# In addition, this script also enables disabled internal Maestro (darc-int*) feeds. # # Optionally, this script also adds a credential entry for each of the internal feeds if supplied. # @@ -173,6 +174,18 @@ for DotNetVersion in ${DotNetVersions[@]} ; do fi done +# Check for dotnet-eng and add dotnet-eng-internal if present +grep -i " /dev/null +if [ "$?" == "0" ]; then + AddOrEnablePackageSource "dotnet-eng-internal" "https://pkgs.dev.azure.com/dnceng/internal/_packaging/dotnet-eng-internal/nuget/$FeedSuffix" +fi + +# Check for dotnet-tools and add dotnet-tools-internal if present +grep -i " /dev/null +if [ "$?" == "0" ]; then + AddOrEnablePackageSource "dotnet-tools-internal" "https://pkgs.dev.azure.com/dnceng/internal/_packaging/dotnet-tools-internal/nuget/$FeedSuffix" +fi + # I want things split line by line PrevIFS=$IFS IFS=$'\n' diff --git a/eng/common/build.ps1 b/eng/common/build.ps1 index c10aba98ac6..8cfee107e7a 100644 --- a/eng/common/build.ps1 +++ b/eng/common/build.ps1 @@ -30,7 +30,6 @@ Param( [string] $runtimeSourceFeedKey = '', [switch] $excludePrereleaseVS, [switch] $nativeToolsOnMachine, - [switch] $restoreMaui, [switch] $help, [Parameter(ValueFromRemainingArguments=$true)][String[]]$properties ) @@ -77,7 +76,6 @@ function Print-Usage() { Write-Host " -nodeReuse Sets nodereuse msbuild parameter ('true' or 'false')" Write-Host " -buildCheck Sets /check msbuild parameter" Write-Host " -fromVMR Set when building from within the VMR" - Write-Host " -restoreMaui Restore the MAUI workload after restore (only on Windows/macOS)" Write-Host "" Write-Host "Command line arguments not listed above are passed thru to msbuild." diff --git a/eng/common/build.sh b/eng/common/build.sh index 09d1f8e6d9c..9767bb411a4 100755 --- a/eng/common/build.sh +++ b/eng/common/build.sh @@ -44,7 +44,6 @@ usage() echo " --warnAsError Sets warnaserror msbuild parameter ('true' or 'false')" echo " --buildCheck Sets /check msbuild parameter" echo " --fromVMR Set when building from within the VMR" - echo " --restoreMaui Restore the MAUI workload after restore (only on macOS)" echo "" echo "Command line arguments not listed above are passed thru to msbuild." echo "Arguments can also be passed in with a single hyphen." @@ -77,7 +76,6 @@ sign=false public=false ci=false clean=false -restore_maui=false warn_as_error=true node_reuse=true @@ -94,7 +92,7 @@ runtime_source_feed='' runtime_source_feed_key='' properties=() -while [[ $# -gt 0 ]]; do +while [[ $# > 0 ]]; do opt="$(echo "${1/#--/-}" | tr "[:upper:]" "[:lower:]")" case "$opt" in -help|-h) @@ -185,9 +183,6 @@ while [[ $# -gt 0 ]]; do -buildcheck) build_check=true ;; - -restoremaui|-restore-maui) - restore_maui=true - ;; -runtimesourcefeed) runtime_source_feed=$2 shift diff --git a/eng/common/core-templates/job/job.yml b/eng/common/core-templates/job/job.yml index 748c4f07a64..5ce51840619 100644 --- a/eng/common/core-templates/job/job.yml +++ b/eng/common/core-templates/job/job.yml @@ -19,8 +19,6 @@ parameters: # publishing defaults artifacts: '' enableMicrobuild: false - enablePreviewMicrobuild: false - microbuildPluginVersion: 'latest' enableMicrobuildForMacAndLinux: false microbuildUseESRP: true enablePublishBuildArtifacts: false @@ -73,8 +71,6 @@ jobs: templateContext: ${{ parameters.templateContext }} variables: - - name: AllowPtrToDetectTestRunRetryFiles - value: true - ${{ if ne(parameters.enableTelemetry, 'false') }}: - name: DOTNET_CLI_TELEMETRY_PROFILE value: '$(Build.Repository.Uri)' @@ -132,8 +128,6 @@ jobs: - template: /eng/common/core-templates/steps/install-microbuild.yml parameters: enableMicrobuild: ${{ parameters.enableMicrobuild }} - enablePreviewMicrobuild: ${{ parameters.enablePreviewMicrobuild }} - microbuildPluginVersion: ${{ parameters.microbuildPluginVersion }} enableMicrobuildForMacAndLinux: ${{ parameters.enableMicrobuildForMacAndLinux }} microbuildUseESRP: ${{ parameters.microbuildUseESRP }} continueOnError: ${{ parameters.continueOnError }} @@ -159,8 +153,6 @@ jobs: - template: /eng/common/core-templates/steps/cleanup-microbuild.yml parameters: enableMicrobuild: ${{ parameters.enableMicrobuild }} - enablePreviewMicrobuild: ${{ parameters.enablePreviewMicrobuild }} - microbuildPluginVersion: ${{ parameters.microbuildPluginVersion }} enableMicrobuildForMacAndLinux: ${{ parameters.enableMicrobuildForMacAndLinux }} continueOnError: ${{ parameters.continueOnError }} diff --git a/eng/common/core-templates/job/publish-build-assets.yml b/eng/common/core-templates/job/publish-build-assets.yml index 8b5c635fe80..b955fac6e13 100644 --- a/eng/common/core-templates/job/publish-build-assets.yml +++ b/eng/common/core-templates/job/publish-build-assets.yml @@ -80,7 +80,7 @@ jobs: # If it's not devdiv, it's dnceng ${{ if ne(variables['System.TeamProject'], 'DevDiv') }}: name: NetCore1ESPool-Publishing-Internal - image: windows.vs2019.amd64 + image: windows.vs2022.amd64 os: windows steps: - ${{ if eq(parameters.is1ESPipeline, '') }}: @@ -91,8 +91,8 @@ jobs: fetchDepth: 3 clean: true - - ${{ if eq(parameters.isAssetlessBuild, 'false') }}: - - ${{ if eq(parameters.publishingVersion, 3) }}: + - ${{ if eq(parameters.isAssetlessBuild, 'false') }}: + - ${{ if eq(parameters.publishingVersion, 3) }}: - task: DownloadPipelineArtifact@2 displayName: Download Asset Manifests inputs: @@ -117,7 +117,7 @@ jobs: flattenFolders: true condition: ${{ parameters.condition }} continueOnError: ${{ parameters.continueOnError }} - + - task: NuGetAuthenticate@1 # Populate internal runtime variables. @@ -125,7 +125,7 @@ jobs: ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: parameters: legacyCredential: $(dn-bot-dnceng-artifact-feeds-rw) - + - template: /eng/common/templates/steps/enable-internal-runtimes.yml - task: AzureCLI@2 @@ -145,7 +145,7 @@ jobs: condition: ${{ parameters.condition }} continueOnError: ${{ parameters.continueOnError }} - + - task: powershell@2 displayName: Create ReleaseConfigs Artifact inputs: @@ -173,7 +173,7 @@ jobs: artifactName: AssetManifests displayName: 'Publish Merged Manifest' retryCountOnTaskFailure: 10 # for any logs being locked - sbomEnabled: false # we don't need SBOM for logs + sbomEnabled: false # we don't need SBOM for logs - template: /eng/common/core-templates/steps/publish-build-artifacts.yml parameters: @@ -190,7 +190,7 @@ jobs: BARBuildId: ${{ parameters.BARBuildId }} PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} is1ESPipeline: ${{ parameters.is1ESPipeline }} - + # Darc is targeting 8.0, so make sure it's installed - task: UseDotNet@2 inputs: @@ -218,4 +218,4 @@ jobs: - template: /eng/common/core-templates/steps/publish-logs.yml parameters: is1ESPipeline: ${{ parameters.is1ESPipeline }} - JobLabel: 'Publish_Artifacts_Logs' + JobLabel: 'Publish_Artifacts_Logs' diff --git a/eng/common/core-templates/job/source-build.yml b/eng/common/core-templates/job/source-build.yml index 9d820f97421..1997c2ae00d 100644 --- a/eng/common/core-templates/job/source-build.yml +++ b/eng/common/core-templates/job/source-build.yml @@ -60,19 +60,19 @@ jobs: pool: ${{ if eq(variables['System.TeamProject'], 'public') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore-Svc-Public' ), False, 'NetCore-Public')] - demands: ImageOverride -equals build.ubuntu.2204.amd64 + demands: ImageOverride -equals build.azurelinux.3.amd64.open ${{ if eq(variables['System.TeamProject'], 'internal') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore1ESPool-Svc-Internal'), False, 'NetCore1ESPool-Internal')] - image: 1es-azurelinux-3 + image: build.azurelinux.3.amd64 os: linux ${{ else }}: pool: ${{ if eq(variables['System.TeamProject'], 'public') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore-Svc-Public' ), False, 'NetCore-Public')] - demands: ImageOverride -equals Build.Ubuntu.2204.Amd64.Open + demands: ImageOverride -equals build.azurelinux.3.amd64.open ${{ if eq(variables['System.TeamProject'], 'internal') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore1ESPool-Svc-Internal'), False, 'NetCore1ESPool-Internal')] - demands: ImageOverride -equals Build.Ubuntu.2204.Amd64 + demands: ImageOverride -equals build.azurelinux.3.amd64 ${{ if ne(parameters.platform.pool, '') }}: pool: ${{ parameters.platform.pool }} diff --git a/eng/common/core-templates/post-build/post-build.yml b/eng/common/core-templates/post-build/post-build.yml index 06864cd1feb..b942a79ef02 100644 --- a/eng/common/core-templates/post-build/post-build.yml +++ b/eng/common/core-templates/post-build/post-build.yml @@ -1,106 +1,106 @@ parameters: -# Which publishing infra should be used. THIS SHOULD MATCH THE VERSION ON THE BUILD MANIFEST. -# Publishing V1 is no longer supported -# Publishing V2 is no longer supported -# Publishing V3 is the default -- name: publishingInfraVersion - displayName: Which version of publishing should be used to promote the build definition? - type: number - default: 3 - values: - - 3 - -- name: BARBuildId - displayName: BAR Build Id - type: number - default: 0 - -- name: PromoteToChannelIds - displayName: Channel to promote BARBuildId to - type: string - default: '' - -- name: enableSourceLinkValidation - displayName: Enable SourceLink validation - type: boolean - default: false - -- name: enableSigningValidation - displayName: Enable signing validation - type: boolean - default: true - -- name: enableSymbolValidation - displayName: Enable symbol validation - type: boolean - default: false - -- name: enableNugetValidation - displayName: Enable NuGet validation - type: boolean - default: true - -- name: publishInstallersAndChecksums - displayName: Publish installers and checksums - type: boolean - default: true - -- name: requireDefaultChannels - displayName: Fail the build if there are no default channel(s) registrations for the current build - type: boolean - default: false - -- name: SDLValidationParameters - type: object - default: - enable: false - publishGdn: false - continueOnError: false - params: '' - artifactNames: '' - downloadArtifacts: true - -- name: isAssetlessBuild - type: boolean - displayName: Is Assetless Build - default: false - -# These parameters let the user customize the call to sdk-task.ps1 for publishing -# symbols & general artifacts as well as for signing validation -- name: symbolPublishingAdditionalParameters - displayName: Symbol publishing additional parameters - type: string - default: '' - -- name: artifactsPublishingAdditionalParameters - displayName: Artifact publishing additional parameters - type: string - default: '' - -- name: signingValidationAdditionalParameters - displayName: Signing validation additional parameters - type: string - default: '' - -# Which stages should finish execution before post-build stages start -- name: validateDependsOn - type: object - default: - - build - -- name: publishDependsOn - type: object - default: - - Validate - -# Optional: Call asset publishing rather than running in a separate stage -- name: publishAssetsImmediately - type: boolean - default: false - -- name: is1ESPipeline - type: boolean - default: false + # Which publishing infra should be used. THIS SHOULD MATCH THE VERSION ON THE BUILD MANIFEST. + # Publishing V1 is no longer supported + # Publishing V2 is no longer supported + # Publishing V3 is the default + - name: publishingInfraVersion + displayName: Which version of publishing should be used to promote the build definition? + type: number + default: 3 + values: + - 3 + + - name: BARBuildId + displayName: BAR Build Id + type: number + default: 0 + + - name: PromoteToChannelIds + displayName: Channel to promote BARBuildId to + type: string + default: '' + + - name: enableSourceLinkValidation + displayName: Enable SourceLink validation + type: boolean + default: false + + - name: enableSigningValidation + displayName: Enable signing validation + type: boolean + default: true + + - name: enableSymbolValidation + displayName: Enable symbol validation + type: boolean + default: false + + - name: enableNugetValidation + displayName: Enable NuGet validation + type: boolean + default: true + + - name: publishInstallersAndChecksums + displayName: Publish installers and checksums + type: boolean + default: true + + - name: requireDefaultChannels + displayName: Fail the build if there are no default channel(s) registrations for the current build + type: boolean + default: false + + - name: SDLValidationParameters + type: object + default: + enable: false + publishGdn: false + continueOnError: false + params: '' + artifactNames: '' + downloadArtifacts: true + + - name: isAssetlessBuild + type: boolean + displayName: Is Assetless Build + default: false + + # These parameters let the user customize the call to sdk-task.ps1 for publishing + # symbols & general artifacts as well as for signing validation + - name: symbolPublishingAdditionalParameters + displayName: Symbol publishing additional parameters + type: string + default: '' + + - name: artifactsPublishingAdditionalParameters + displayName: Artifact publishing additional parameters + type: string + default: '' + + - name: signingValidationAdditionalParameters + displayName: Signing validation additional parameters + type: string + default: '' + + # Which stages should finish execution before post-build stages start + - name: validateDependsOn + type: object + default: + - build + + - name: publishDependsOn + type: object + default: + - Validate + + # Optional: Call asset publishing rather than running in a separate stage + - name: publishAssetsImmediately + type: boolean + default: false + + - name: is1ESPipeline + type: boolean + default: false stages: - ${{ if or(eq( parameters.enableNugetValidation, 'true'), eq(parameters.enableSigningValidation, 'true'), eq(parameters.enableSourceLinkValidation, 'true'), eq(parameters.SDLValidationParameters.enable, 'true')) }}: @@ -108,10 +108,10 @@ stages: dependsOn: ${{ parameters.validateDependsOn }} displayName: Validate Build Assets variables: - - template: /eng/common/core-templates/post-build/common-variables.yml - - template: /eng/common/core-templates/variables/pool-providers.yml - parameters: - is1ESPipeline: ${{ parameters.is1ESPipeline }} + - template: /eng/common/core-templates/post-build/common-variables.yml + - template: /eng/common/core-templates/variables/pool-providers.yml + parameters: + is1ESPipeline: ${{ parameters.is1ESPipeline }} jobs: - job: displayName: NuGet Validation @@ -134,28 +134,28 @@ stages: demands: ImageOverride -equals windows.vs2026preview.scout.amd64 steps: - - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml - parameters: - BARBuildId: ${{ parameters.BARBuildId }} - PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} - is1ESPipeline: ${{ parameters.is1ESPipeline }} - - - task: DownloadBuildArtifacts@0 - displayName: Download Package Artifacts - inputs: - buildType: specific - buildVersionToDownload: specific - project: $(AzDOProjectName) - pipeline: $(AzDOPipelineId) - buildId: $(AzDOBuildId) - artifactName: PackageArtifacts - checkDownloadedFiles: true - - - task: PowerShell@2 - displayName: Validate - inputs: - filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/nuget-validation.ps1 - arguments: -PackagesPath $(Build.ArtifactStagingDirectory)/PackageArtifacts/ + - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml + parameters: + BARBuildId: ${{ parameters.BARBuildId }} + PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} + is1ESPipeline: ${{ parameters.is1ESPipeline }} + + - task: DownloadBuildArtifacts@0 + displayName: Download Package Artifacts + inputs: + buildType: specific + buildVersionToDownload: specific + project: $(AzDOProjectName) + pipeline: $(AzDOPipelineId) + buildId: $(AzDOBuildId) + artifactName: PackageArtifacts + checkDownloadedFiles: true + + - task: PowerShell@2 + displayName: Validate + inputs: + filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/nuget-validation.ps1 + arguments: -PackagesPath $(Build.ArtifactStagingDirectory)/PackageArtifacts/ - job: displayName: Signing Validation @@ -169,7 +169,7 @@ stages: os: windows # If it's not devdiv, it's dnceng ${{ else }}: - ${{ if eq(parameters.is1ESPipeline, true) }}: + ${{ if eq(parameters.is1ESPipeline, true) }}: name: $(DncEngInternalBuildPool) image: 1es-windows-2022 os: windows @@ -177,46 +177,46 @@ stages: name: $(DncEngInternalBuildPool) demands: ImageOverride -equals windows.vs2026preview.scout.amd64 steps: - - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml - parameters: - BARBuildId: ${{ parameters.BARBuildId }} - PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} - is1ESPipeline: ${{ parameters.is1ESPipeline }} - - - task: DownloadBuildArtifacts@0 - displayName: Download Package Artifacts - inputs: - buildType: specific - buildVersionToDownload: specific - project: $(AzDOProjectName) - pipeline: $(AzDOPipelineId) - buildId: $(AzDOBuildId) - artifactName: PackageArtifacts - checkDownloadedFiles: true - - # This is necessary whenever we want to publish/restore to an AzDO private feed - # Since sdk-task.ps1 tries to restore packages we need to do this authentication here - # otherwise it'll complain about accessing a private feed. - - task: NuGetAuthenticate@1 - displayName: 'Authenticate to AzDO Feeds' - - # Signing validation will optionally work with the buildmanifest file which is downloaded from - # Azure DevOps above. - - task: PowerShell@2 - displayName: Validate - inputs: - filePath: eng\common\sdk-task.ps1 - arguments: -task SigningValidation -restore -msbuildEngine vs - /p:PackageBasePath='$(Build.ArtifactStagingDirectory)/PackageArtifacts' - /p:SignCheckExclusionsFile='$(System.DefaultWorkingDirectory)/eng/SignCheckExclusionsFile.txt' - ${{ parameters.signingValidationAdditionalParameters }} - - - template: /eng/common/core-templates/steps/publish-logs.yml - parameters: - is1ESPipeline: ${{ parameters.is1ESPipeline }} - StageLabel: 'Validation' - JobLabel: 'Signing' - BinlogToolVersion: $(BinlogToolVersion) + - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml + parameters: + BARBuildId: ${{ parameters.BARBuildId }} + PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} + is1ESPipeline: ${{ parameters.is1ESPipeline }} + + - task: DownloadBuildArtifacts@0 + displayName: Download Package Artifacts + inputs: + buildType: specific + buildVersionToDownload: specific + project: $(AzDOProjectName) + pipeline: $(AzDOPipelineId) + buildId: $(AzDOBuildId) + artifactName: PackageArtifacts + checkDownloadedFiles: true + + # This is necessary whenever we want to publish/restore to an AzDO private feed + # Since sdk-task.ps1 tries to restore packages we need to do this authentication here + # otherwise it'll complain about accessing a private feed. + - task: NuGetAuthenticate@1 + displayName: 'Authenticate to AzDO Feeds' + + # Signing validation will optionally work with the buildmanifest file which is downloaded from + # Azure DevOps above. + - task: PowerShell@2 + displayName: Validate + inputs: + filePath: eng\common\sdk-task.ps1 + arguments: -task SigningValidation -restore -msbuildEngine vs + /p:PackageBasePath='$(Build.ArtifactStagingDirectory)/PackageArtifacts' + /p:SignCheckExclusionsFile='$(System.DefaultWorkingDirectory)/eng/SignCheckExclusionsFile.txt' + ${{ parameters.signingValidationAdditionalParameters }} + + - template: /eng/common/core-templates/steps/publish-logs.yml + parameters: + is1ESPipeline: ${{ parameters.is1ESPipeline }} + StageLabel: 'Validation' + JobLabel: 'Signing' + BinlogToolVersion: $(BinlogToolVersion) - job: displayName: SourceLink Validation @@ -230,7 +230,7 @@ stages: os: windows # If it's not devdiv, it's dnceng ${{ else }}: - ${{ if eq(parameters.is1ESPipeline, true) }}: + ${{ if eq(parameters.is1ESPipeline, true) }}: name: $(DncEngInternalBuildPool) image: 1es-windows-2022 os: windows @@ -238,33 +238,33 @@ stages: name: $(DncEngInternalBuildPool) demands: ImageOverride -equals windows.vs2026preview.scout.amd64 steps: - - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml - parameters: - BARBuildId: ${{ parameters.BARBuildId }} - PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} - is1ESPipeline: ${{ parameters.is1ESPipeline }} - - - task: DownloadBuildArtifacts@0 - displayName: Download Blob Artifacts - inputs: - buildType: specific - buildVersionToDownload: specific - project: $(AzDOProjectName) - pipeline: $(AzDOPipelineId) - buildId: $(AzDOBuildId) - artifactName: BlobArtifacts - checkDownloadedFiles: true - - - task: PowerShell@2 - displayName: Validate - inputs: - filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/sourcelink-validation.ps1 - arguments: -InputPath $(Build.ArtifactStagingDirectory)/BlobArtifacts/ - -ExtractPath $(Agent.BuildDirectory)/Extract/ - -GHRepoName $(Build.Repository.Name) - -GHCommit $(Build.SourceVersion) - -SourcelinkCliVersion $(SourceLinkCLIVersion) - continueOnError: true + - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml + parameters: + BARBuildId: ${{ parameters.BARBuildId }} + PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} + is1ESPipeline: ${{ parameters.is1ESPipeline }} + + - task: DownloadBuildArtifacts@0 + displayName: Download Blob Artifacts + inputs: + buildType: specific + buildVersionToDownload: specific + project: $(AzDOProjectName) + pipeline: $(AzDOPipelineId) + buildId: $(AzDOBuildId) + artifactName: BlobArtifacts + checkDownloadedFiles: true + + - task: PowerShell@2 + displayName: Validate + inputs: + filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/sourcelink-validation.ps1 + arguments: -InputPath $(Build.ArtifactStagingDirectory)/BlobArtifacts/ + -ExtractPath $(Agent.BuildDirectory)/Extract/ + -GHRepoName $(Build.Repository.Name) + -GHCommit $(Build.SourceVersion) + -SourcelinkCliVersion $(SourceLinkCLIVersion) + continueOnError: true - ${{ if ne(parameters.publishAssetsImmediately, 'true') }}: - stage: publish_using_darc @@ -274,10 +274,10 @@ stages: dependsOn: ${{ parameters.validateDependsOn }} displayName: Publish using Darc variables: - - template: /eng/common/core-templates/post-build/common-variables.yml - - template: /eng/common/core-templates/variables/pool-providers.yml - parameters: - is1ESPipeline: ${{ parameters.is1ESPipeline }} + - template: /eng/common/core-templates/post-build/common-variables.yml + - template: /eng/common/core-templates/variables/pool-providers.yml + parameters: + is1ESPipeline: ${{ parameters.is1ESPipeline }} jobs: - job: displayName: Publish Using Darc @@ -291,41 +291,42 @@ stages: os: windows # If it's not devdiv, it's dnceng ${{ else }}: - ${{ if eq(parameters.is1ESPipeline, true) }}: + ${{ if eq(parameters.is1ESPipeline, true) }}: name: NetCore1ESPool-Publishing-Internal - image: windows.vs2019.amd64 + image: windows.vs2022.amd64 os: windows ${{ else }}: name: NetCore1ESPool-Publishing-Internal - demands: ImageOverride -equals windows.vs2019.amd64 + demands: ImageOverride -equals windows.vs2022.amd64 steps: - - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml - parameters: - BARBuildId: ${{ parameters.BARBuildId }} - PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} - is1ESPipeline: ${{ parameters.is1ESPipeline }} - - - task: NuGetAuthenticate@1 - - # Populate internal runtime variables. - - template: /eng/common/templates/steps/enable-internal-sources.yml - parameters: - legacyCredential: $(dn-bot-dnceng-artifact-feeds-rw) - - - template: /eng/common/templates/steps/enable-internal-runtimes.yml - - - task: UseDotNet@2 - inputs: - version: 8.0.x - - - task: AzureCLI@2 - displayName: Publish Using Darc - inputs: - azureSubscription: "Darc: Maestro Production" - scriptType: ps - scriptLocation: scriptPath - scriptPath: $(System.DefaultWorkingDirectory)/eng/common/post-build/publish-using-darc.ps1 - arguments: > + - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml + parameters: + BARBuildId: ${{ parameters.BARBuildId }} + PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} + is1ESPipeline: ${{ parameters.is1ESPipeline }} + + - task: NuGetAuthenticate@1 + + # Populate internal runtime variables. + - template: /eng/common/templates/steps/enable-internal-sources.yml + parameters: + legacyCredential: $(dn-bot-dnceng-artifact-feeds-rw) + + - template: /eng/common/templates/steps/enable-internal-runtimes.yml + + # Darc is targeting 8.0, so make sure it's installed + - task: UseDotNet@2 + inputs: + version: 8.0.x + + - task: AzureCLI@2 + displayName: Publish Using Darc + inputs: + azureSubscription: "Darc: Maestro Production" + scriptType: ps + scriptLocation: scriptPath + scriptPath: $(System.DefaultWorkingDirectory)/eng/common/post-build/publish-using-darc.ps1 + arguments: > -BuildId $(BARBuildId) -PublishingInfraVersion ${{ parameters.publishingInfraVersion }} -AzdoToken '$(System.AccessToken)' diff --git a/eng/common/core-templates/steps/generate-sbom.yml b/eng/common/core-templates/steps/generate-sbom.yml index 003f7eae0fa..c05f6502797 100644 --- a/eng/common/core-templates/steps/generate-sbom.yml +++ b/eng/common/core-templates/steps/generate-sbom.yml @@ -5,7 +5,7 @@ # IgnoreDirectories - Directories to ignore for SBOM generation. This will be passed through to the CG component detector. parameters: - PackageVersion: 11.0.0 + PackageVersion: 10.0.0 BuildDropPath: '$(System.DefaultWorkingDirectory)/artifacts' PackageName: '.NET' ManifestDirPath: $(Build.ArtifactStagingDirectory)/sbom diff --git a/eng/common/core-templates/steps/install-microbuild-impl.yml b/eng/common/core-templates/steps/install-microbuild-impl.yml deleted file mode 100644 index b9e0143ee92..00000000000 --- a/eng/common/core-templates/steps/install-microbuild-impl.yml +++ /dev/null @@ -1,34 +0,0 @@ -parameters: - - name: microbuildTaskInputs - type: object - default: {} - - - name: microbuildEnv - type: object - default: {} - - - name: enablePreviewMicrobuild - type: boolean - default: false - - - name: condition - type: string - - - name: continueOnError - type: boolean - -steps: -- ${{ if eq(parameters.enablePreviewMicrobuild, 'true') }}: - - task: MicroBuildSigningPluginPreview@4 - displayName: Install Preview MicroBuild plugin - inputs: ${{ parameters.microbuildTaskInputs }} - env: ${{ parameters.microbuildEnv }} - continueOnError: ${{ parameters.continueOnError }} - condition: ${{ parameters.condition }} -- ${{ else }}: - - task: MicroBuildSigningPlugin@4 - displayName: Install MicroBuild plugin - inputs: ${{ parameters.microbuildTaskInputs }} - env: ${{ parameters.microbuildEnv }} - continueOnError: ${{ parameters.continueOnError }} - condition: ${{ parameters.condition }} diff --git a/eng/common/core-templates/steps/install-microbuild.yml b/eng/common/core-templates/steps/install-microbuild.yml index 4f4b56ed2a6..553fce66b94 100644 --- a/eng/common/core-templates/steps/install-microbuild.yml +++ b/eng/common/core-templates/steps/install-microbuild.yml @@ -4,8 +4,6 @@ parameters: # Enable install tasks for MicroBuild on Mac and Linux # Will be ignored if 'enableMicrobuild' is false or 'Agent.Os' is 'Windows_NT' enableMicrobuildForMacAndLinux: false - # Enable preview version of MB signing plugin - enablePreviewMicrobuild: false # Determines whether the ESRP service connection information should be passed to the signing plugin. # This overlaps with _SignType to some degree. We only need the service connection for real signing. # It's important that the service connection not be passed to the MicroBuildSigningPlugin task in this place. @@ -15,8 +13,6 @@ parameters: microbuildUseESRP: true # Microbuild installation directory microBuildOutputFolder: $(Agent.TempDirectory)/MicroBuild - # Microbuild version - microbuildPluginVersion: 'latest' continueOnError: false @@ -73,46 +69,42 @@ steps: # YAML expansion, and Windows vs. Linux/Mac uses different service connections. However, # we can avoid including the MB install step if not enabled at all. This avoids a bunch of # extra pipeline authorizations, since most pipelines do not sign on non-Windows. - - template: /eng/common/core-templates/steps/install-microbuild-impl.yml@self - parameters: - enablePreviewMicrobuild: ${{ parameters.enablePreviewMicrobuild }} - microbuildTaskInputs: + - task: MicroBuildSigningPlugin@4 + displayName: Install MicroBuild plugin (Windows) + inputs: + signType: $(_SignType) + zipSources: false + feedSource: https://dnceng.pkgs.visualstudio.com/_packaging/MicroBuildToolset/nuget/v3/index.json + ${{ if eq(parameters.microbuildUseESRP, true) }}: + ConnectedServiceName: 'MicroBuild Signing Task (DevDiv)' + ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: + ConnectedPMEServiceName: 6cc74545-d7b9-4050-9dfa-ebefcc8961ea + ${{ else }}: + ConnectedPMEServiceName: 248d384a-b39b-46e3-8ad5-c2c210d5e7ca + env: + TeamName: $(_TeamName) + MicroBuildOutputFolderOverride: ${{ parameters.microBuildOutputFolder }} + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + continueOnError: ${{ parameters.continueOnError }} + condition: and(succeeded(), eq(variables['Agent.Os'], 'Windows_NT'), in(variables['_SignType'], 'real', 'test')) + + - ${{ if eq(parameters.enableMicrobuildForMacAndLinux, true) }}: + - task: MicroBuildSigningPlugin@4 + displayName: Install MicroBuild plugin (non-Windows) + inputs: signType: $(_SignType) zipSources: false feedSource: https://dnceng.pkgs.visualstudio.com/_packaging/MicroBuildToolset/nuget/v3/index.json - version: ${{ parameters.microbuildPluginVersion }} + workingDirectory: ${{ parameters.microBuildOutputFolder }} ${{ if eq(parameters.microbuildUseESRP, true) }}: ConnectedServiceName: 'MicroBuild Signing Task (DevDiv)' ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: - ConnectedPMEServiceName: 6cc74545-d7b9-4050-9dfa-ebefcc8961ea + ConnectedPMEServiceName: beb8cb23-b303-4c95-ab26-9e44bc958d39 ${{ else }}: - ConnectedPMEServiceName: 248d384a-b39b-46e3-8ad5-c2c210d5e7ca - microbuildEnv: + ConnectedPMEServiceName: c24de2a5-cc7a-493d-95e4-8e5ff5cad2bc + env: TeamName: $(_TeamName) MicroBuildOutputFolderOverride: ${{ parameters.microBuildOutputFolder }} SYSTEM_ACCESSTOKEN: $(System.AccessToken) continueOnError: ${{ parameters.continueOnError }} - condition: and(succeeded(), eq(variables['Agent.Os'], 'Windows_NT'), in(variables['_SignType'], 'real', 'test')) - - - ${{ if eq(parameters.enableMicrobuildForMacAndLinux, true) }}: - - template: /eng/common/core-templates/steps/install-microbuild-impl.yml@self - parameters: - enablePreviewMicrobuild: ${{ parameters.enablePreviewMicrobuild }} - microbuildTaskInputs: - signType: $(_SignType) - zipSources: false - feedSource: https://dnceng.pkgs.visualstudio.com/_packaging/MicroBuildToolset/nuget/v3/index.json - version: ${{ parameters.microbuildPluginVersion }} - workingDirectory: ${{ parameters.microBuildOutputFolder }} - ${{ if eq(parameters.microbuildUseESRP, true) }}: - ConnectedServiceName: 'MicroBuild Signing Task (DevDiv)' - ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: - ConnectedPMEServiceName: beb8cb23-b303-4c95-ab26-9e44bc958d39 - ${{ else }}: - ConnectedPMEServiceName: c24de2a5-cc7a-493d-95e4-8e5ff5cad2bc - microbuildEnv: - TeamName: $(_TeamName) - MicroBuildOutputFolderOverride: ${{ parameters.microBuildOutputFolder }} - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - continueOnError: ${{ parameters.continueOnError }} - condition: and(succeeded(), ne(variables['Agent.Os'], 'Windows_NT'), eq(variables['_SignType'], 'real')) + condition: and(succeeded(), ne(variables['Agent.Os'], 'Windows_NT'), eq(variables['_SignType'], 'real')) diff --git a/eng/common/core-templates/steps/source-build.yml b/eng/common/core-templates/steps/source-build.yml index acf16ed3496..b9c86c18ae4 100644 --- a/eng/common/core-templates/steps/source-build.yml +++ b/eng/common/core-templates/steps/source-build.yml @@ -24,7 +24,7 @@ steps: # in the default public locations. internalRuntimeDownloadArgs= if [ '$(dotnetbuilds-internal-container-read-token-base64)' != '$''(dotnetbuilds-internal-container-read-token-base64)' ]; then - internalRuntimeDownloadArgs='/p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) --runtimesourcefeed https://ci.dot.net/internal --runtimesourcefeedkey $(dotnetbuilds-internal-container-read-token-base64)' + internalRuntimeDownloadArgs='/p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) --runtimesourcefeed https://ci.dot.net/internal --runtimesourcefeedkey '$(dotnetbuilds-internal-container-read-token-base64)'' fi buildConfig=Release diff --git a/eng/common/core-templates/steps/source-index-stage1-publish.yml b/eng/common/core-templates/steps/source-index-stage1-publish.yml index ac019e2d033..e9a694afa58 100644 --- a/eng/common/core-templates/steps/source-index-stage1-publish.yml +++ b/eng/common/core-templates/steps/source-index-stage1-publish.yml @@ -1,6 +1,6 @@ parameters: - sourceIndexUploadPackageVersion: 2.0.0-20250906.1 - sourceIndexProcessBinlogPackageVersion: 1.0.1-20250906.1 + sourceIndexUploadPackageVersion: 2.0.0-20250818.1 + sourceIndexProcessBinlogPackageVersion: 1.0.1-20250818.1 sourceIndexPackageSource: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json binlogPath: artifacts/log/Debug/Build.binlog @@ -14,8 +14,8 @@ steps: workingDirectory: $(Agent.TempDirectory) - script: | - $(Agent.TempDirectory)/dotnet/dotnet tool install BinLogToSln --version ${{parameters.sourceIndexProcessBinlogPackageVersion}} --source ${{parameters.SourceIndexPackageSource}} --tool-path $(Agent.TempDirectory)/.source-index/tools - $(Agent.TempDirectory)/dotnet/dotnet tool install UploadIndexStage1 --version ${{parameters.sourceIndexUploadPackageVersion}} --source ${{parameters.SourceIndexPackageSource}} --tool-path $(Agent.TempDirectory)/.source-index/tools + $(Agent.TempDirectory)/dotnet/dotnet tool install BinLogToSln --version ${{parameters.sourceIndexProcessBinlogPackageVersion}} --add-source ${{parameters.SourceIndexPackageSource}} --tool-path $(Agent.TempDirectory)/.source-index/tools + $(Agent.TempDirectory)/dotnet/dotnet tool install UploadIndexStage1 --version ${{parameters.sourceIndexUploadPackageVersion}} --add-source ${{parameters.SourceIndexPackageSource}} --tool-path $(Agent.TempDirectory)/.source-index/tools displayName: "Source Index: Download netsourceindex Tools" # Set working directory to temp directory so 'dotnet' doesn't try to use global.json and use the repo's sdk. workingDirectory: $(Agent.TempDirectory) diff --git a/eng/common/darc-init.sh b/eng/common/darc-init.sh index 9f5ad6b763b..e889f439b8d 100755 --- a/eng/common/darc-init.sh +++ b/eng/common/darc-init.sh @@ -5,7 +5,7 @@ darcVersion='' versionEndpoint='https://maestro.dot.net/api/assets/darc-version?api-version=2020-02-20' verbosity='minimal' -while [[ $# -gt 0 ]]; do +while [[ $# > 0 ]]; do opt="$(echo "$1" | tr "[:upper:]" "[:lower:]")" case "$opt" in --darcversion) diff --git a/eng/common/dotnet-install.sh b/eng/common/dotnet-install.sh index 61f302bb677..7b9d97e3bd4 100755 --- a/eng/common/dotnet-install.sh +++ b/eng/common/dotnet-install.sh @@ -18,7 +18,7 @@ architecture='' runtime='dotnet' runtimeSourceFeed='' runtimeSourceFeedKey='' -while [[ $# -gt 0 ]]; do +while [[ $# > 0 ]]; do opt="$(echo "$1" | tr "[:upper:]" "[:lower:]")" case "$opt" in -version|-v) diff --git a/eng/common/dotnet.sh b/eng/common/dotnet.sh index f6d24871c1d..2ef68235675 100644 --- a/eng/common/dotnet.sh +++ b/eng/common/dotnet.sh @@ -19,7 +19,7 @@ source $scriptroot/tools.sh InitializeDotNetCli true # install # Invoke acquired SDK with args if they are provided -if [[ $# -gt 0 ]]; then +if [[ $# > 0 ]]; then __dotnetDir=${_InitializeDotNetCli} dotnetPath=${__dotnetDir}/dotnet ${dotnetPath} "$@" diff --git a/eng/common/internal-feed-operations.sh b/eng/common/internal-feed-operations.sh index 6299e7effd4..9378223ba09 100755 --- a/eng/common/internal-feed-operations.sh +++ b/eng/common/internal-feed-operations.sh @@ -100,7 +100,7 @@ operation='' authToken='' repoName='' -while [[ $# -gt 0 ]]; do +while [[ $# > 0 ]]; do opt="$(echo "$1" | tr "[:upper:]" "[:lower:]")" case "$opt" in --operation) diff --git a/eng/common/native/install-dependencies.sh b/eng/common/native/install-dependencies.sh index 64b87d0bcc3..477a44f335b 100644 --- a/eng/common/native/install-dependencies.sh +++ b/eng/common/native/install-dependencies.sh @@ -27,11 +27,9 @@ case "$os" in libssl-dev libkrb5-dev pigz cpio localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 - elif [ "$ID" = "fedora" ] || [ "$ID" = "rhel" ] || [ "$ID" = "azurelinux" ] || [ "$ID" = "centos"]; then + elif [ "$ID" = "fedora" ] || [ "$ID" = "rhel" ] || [ "$ID" = "azurelinux" ]; then pkg_mgr="$(command -v tdnf 2>/dev/null || command -v dnf)" $pkg_mgr install -y cmake llvm lld lldb clang python curl libicu-devel openssl-devel krb5-devel lttng-ust-devel pigz cpio - elif [ "$ID" = "amzn" ]; then - dnf install -y cmake llvm lld lldb clang python libicu-devel openssl-devel krb5-devel lttng-ust-devel pigz cpio elif [ "$ID" = "alpine" ]; then apk add build-base cmake bash curl clang llvm-dev lld lldb krb5-dev lttng-ust-dev icu-dev openssl-dev pigz cpio else diff --git a/eng/common/post-build/redact-logs.ps1 b/eng/common/post-build/redact-logs.ps1 index fc0218a013d..472d5bb562c 100644 --- a/eng/common/post-build/redact-logs.ps1 +++ b/eng/common/post-build/redact-logs.ps1 @@ -9,8 +9,7 @@ param( [Parameter(Mandatory=$false)][string] $TokensFilePath, [Parameter(ValueFromRemainingArguments=$true)][String[]]$TokensToRedact, [Parameter(Mandatory=$false)][string] $runtimeSourceFeed, - [Parameter(Mandatory=$false)][string] $runtimeSourceFeedKey -) + [Parameter(Mandatory=$false)][string] $runtimeSourceFeedKey) try { $ErrorActionPreference = 'Stop' diff --git a/eng/common/templates/variables/pool-providers.yml b/eng/common/templates/variables/pool-providers.yml index e0b19c14a07..18693ea120d 100644 --- a/eng/common/templates/variables/pool-providers.yml +++ b/eng/common/templates/variables/pool-providers.yml @@ -23,7 +23,7 @@ # # pool: # name: $(DncEngInternalBuildPool) -# demands: ImageOverride -equals windows.vs2019.amd64 +# demands: ImageOverride -equals windows.vs2022.amd64 variables: - ${{ if eq(variables['System.TeamProject'], 'internal') }}: - template: /eng/common/templates-official/variables/pool-providers.yml diff --git a/eng/common/tools.ps1 b/eng/common/tools.ps1 index e8e9f7615f1..049fe6db994 100644 --- a/eng/common/tools.ps1 +++ b/eng/common/tools.ps1 @@ -157,6 +157,9 @@ function InitializeDotNetCli([bool]$install, [bool]$createSdkLocationFile) { return $global:_DotNetInstallDir } + # Don't resolve runtime, shared framework, or SDK from other locations to ensure build determinism + $env:DOTNET_MULTILEVEL_LOOKUP=0 + # Disable first run since we do not need all ASP.NET packages restored. $env:DOTNET_NOLOGO=1 @@ -222,6 +225,7 @@ function InitializeDotNetCli([bool]$install, [bool]$createSdkLocationFile) { # Make Sure that our bootstrapped dotnet cli is available in future steps of the Azure Pipelines build Write-PipelinePrependPath -Path $dotnetRoot + Write-PipelineSetVariable -Name 'DOTNET_MULTILEVEL_LOOKUP' -Value '0' Write-PipelineSetVariable -Name 'DOTNET_NOLOGO' -Value '1' return $global:_DotNetInstallDir = $dotnetRoot @@ -556,19 +560,26 @@ function LocateVisualStudio([object]$vsRequirements = $null){ }) } - if (!$vsRequirements) { $vsRequirements = $GlobalJson.tools.vs } + if (!$vsRequirements) { + if (Get-Member -InputObject $GlobalJson.tools -Name 'vs' -ErrorAction SilentlyContinue) { + $vsRequirements = $GlobalJson.tools.vs + } else { + $vsRequirements = $null + } + } + $args = @('-latest', '-format', 'json', '-requires', 'Microsoft.Component.MSBuild', '-products', '*') if (!$excludePrereleaseVS) { $args += '-prerelease' } - if (Get-Member -InputObject $vsRequirements -Name 'version') { + if ($vsRequirements -and (Get-Member -InputObject $vsRequirements -Name 'version' -ErrorAction SilentlyContinue)) { $args += '-version' $args += $vsRequirements.version } - if (Get-Member -InputObject $vsRequirements -Name 'components') { + if ($vsRequirements -and (Get-Member -InputObject $vsRequirements -Name 'components' -ErrorAction SilentlyContinue)) { foreach ($component in $vsRequirements.components) { $args += '-requires' $args += $component diff --git a/eng/common/tools.sh b/eng/common/tools.sh index 6c121300ac7..c1841c9dfd0 100755 --- a/eng/common/tools.sh +++ b/eng/common/tools.sh @@ -115,6 +115,9 @@ function InitializeDotNetCli { local install=$1 + # Don't resolve runtime, shared framework, or SDK from other locations to ensure build determinism + export DOTNET_MULTILEVEL_LOOKUP=0 + # Disable first run since we want to control all package sources export DOTNET_NOLOGO=1 @@ -163,6 +166,7 @@ function InitializeDotNetCli { # build steps from using anything other than what we've downloaded. Write-PipelinePrependPath -path "$dotnet_root" + Write-PipelineSetVariable -name "DOTNET_MULTILEVEL_LOOKUP" -value "0" Write-PipelineSetVariable -name "DOTNET_NOLOGO" -value "1" # return value diff --git a/eng/restore-toolset.sh b/eng/restore-toolset.sh index 8a7bb526c06..cdcf18f1d19 100644 --- a/eng/restore-toolset.sh +++ b/eng/restore-toolset.sh @@ -3,7 +3,7 @@ # Install MAUI workload if -restoreMaui was passed # Only on macOS (MAUI doesn't support Linux, Windows uses .cmd) -if [[ "$restore_maui" == true ]]; then +if [[ "${restore_maui:-false}" == true ]]; then # Check if we're on macOS if [[ "$(uname -s)" == "Darwin" ]]; then echo "" diff --git a/global.json b/global.json index 087505bbcae..39ccee4a4d2 100644 --- a/global.json +++ b/global.json @@ -33,8 +33,8 @@ "msbuild-sdks": { "Microsoft.Build.NoTargets": "3.7.0", "Microsoft.Build.Traversal": "3.2.0", - "Microsoft.DotNet.Arcade.Sdk": "11.0.0-beta.25610.3", - "Microsoft.DotNet.Helix.Sdk": "11.0.0-beta.25610.3", - "Microsoft.DotNet.SharedFramework.Sdk": "11.0.0-beta.25610.3" + "Microsoft.DotNet.Arcade.Sdk": "10.0.0-beta.26110.1", + "Microsoft.DotNet.Helix.Sdk": "10.0.0-beta.26110.1", + "Microsoft.DotNet.SharedFramework.Sdk": "10.0.0-beta.26110.1" } } From fb80caff01f839571218cb4290c7eb0c548da3d2 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Thu, 12 Feb 2026 13:59:10 -0800 Subject: [PATCH 091/256] Refactor backmerge PR creation to update existing PRs and streamline body formatting (#14476) --- .github/workflows/backmerge-release.yml | 67 ++++++++++++++++--------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/.github/workflows/backmerge-release.yml b/.github/workflows/backmerge-release.yml index 05cd2c507d1..b8c92f0d4fc 100644 --- a/.github/workflows/backmerge-release.yml +++ b/.github/workflows/backmerge-release.yml @@ -57,36 +57,57 @@ jobs: echo "Merge conflicts detected" fi - - name: Create Pull Request + - name: Create or update Pull Request if: steps.check.outputs.changes == 'true' && steps.merge.outputs.merge_success == 'true' id: create-pr - uses: dotnet/actions-create-pull-request@e8d799aa1f8b17f324f9513832811b0a62f1e0b1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - head: backmerge/release-13.2-to-main - base: main - title: "[Automated] Backmerge release/13.2 to main" - labels: area-engineering-systems - body: | - ## Automated Backmerge - - This PR merges changes from `release/13.2` back into `main`. - - **Commits to merge:** ${{ steps.check.outputs.behind_count }} - - This PR was created automatically to keep `main` up-to-date with release branch changes. - Once approved, it will auto-merge. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Check if a PR already exists for this branch + EXISTING_PR=$(gh pr list --head backmerge/release-13.2-to-main --base main --json number --jq '.[0].number // empty') - --- - *This PR was generated by the [backmerge-release](${{ github.server_url }}/${{ github.repository }}/actions/workflows/backmerge-release.yml) workflow.* + if [ -n "$EXISTING_PR" ]; then + echo "PR #$EXISTING_PR already exists, updating it" + echo "pull_request_number=$EXISTING_PR" >> $GITHUB_OUTPUT + else + PR_BODY="## Automated Backmerge + + This PR merges changes from \`release/13.2\` back into \`main\`. + + **Commits to merge:** ${{ steps.check.outputs.behind_count }} + + This PR was created automatically to keep \`main\` up-to-date with release branch changes. + Once approved, it will auto-merge. + + --- + *This PR was generated by the [backmerge-release](${{ github.server_url }}/${{ github.repository }}/actions/workflows/backmerge-release.yml) workflow.*" + + # Remove leading whitespace from heredoc-style body + PR_BODY=$(echo "$PR_BODY" | sed 's/^ //') + + PR_URL=$(gh pr create \ + --head backmerge/release-13.2-to-main \ + --base main \ + --title "[Automated] Backmerge release/13.2 to main" \ + --body "$PR_BODY" \ + --assignee joperezr,radical \ + --label area-engineering-systems) + + PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') + if [ -z "$PR_NUMBER" ]; then + echo "::error::Failed to extract PR number from: $PR_URL" + exit 1 + fi + echo "pull_request_number=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "Created PR #$PR_NUMBER" + fi - - name: Add assignees and enable auto-merge - if: steps.create-pr.outputs.pull-request-number + - name: Enable auto-merge + if: steps.create-pr.outputs.pull_request_number env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh pr edit ${{ steps.create-pr.outputs.pull-request-number }} --add-assignee joperezr,radical - gh pr merge ${{ steps.create-pr.outputs.pull-request-number }} --auto --merge + gh pr merge ${{ steps.create-pr.outputs.pull_request_number }} --auto --merge - name: Create issue for merge conflicts if: steps.check.outputs.changes == 'true' && steps.merge.outputs.merge_success == 'false' From da9cf85c99584c2311bb1794757ad76b7ac2a2da Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:17:53 -0600 Subject: [PATCH 092/256] Fix transitive Azure role assignments through WaitFor dependencies (#14473) * Initial plan * Fix transitive Azure role assignments through WaitFor dependencies Remove CollectAnnotationDependencies calls from CollectDependenciesFromValue to prevent WaitFor/parent/connection-string-redirect annotations from referenced resources being included as direct dependencies of the caller. Add tests verifying: - DirectOnly mode excludes WaitFor deps from referenced resources - WaitFor doesn't create transitive role assignments in Azure publish Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --- .../ApplicationModel/ResourceExtensions.cs | 2 -- .../RoleAssignmentTests.cs | 30 +++++++++++++++++++ .../ResourceDependencyTests.cs | 25 ++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs index 4836d473b9a..fec5e55d6c7 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs @@ -1416,7 +1416,6 @@ private static void CollectDependenciesFromValue(object? value, HashSet("server", launchProfileName: null) + .WithHttpEndpoint() + .WithExternalHttpEndpoints() + .WithReference(cache) + .WaitFor(cache); + + builder.AddProject("webfrontend", launchProfileName: null) + .WithReference(server) + .WaitFor(server); + + var app = builder.Build(); + var model = app.Services.GetRequiredService(); + + await ExecuteBeforeStartHooksAsync(app, default); + + // The server should have a role assignment to the cache since it directly references it + Assert.Single(model.Resources.OfType(), r => r.Name == "server-roles-cache"); + + // The webfrontend should NOT have a role assignment to the cache since it only references the server + Assert.DoesNotContain(model.Resources, r => r.Name == "webfrontend-roles-cache"); + } + private static async Task RoleAssignmentTest( string azureResourceName, Action configureBuilder, diff --git a/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs b/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs index 65ae7925b24..0688a07e352 100644 --- a/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs @@ -545,6 +545,31 @@ public async Task DirectOnlyIncludesReferencedResourcesFromConnectionString() Assert.Contains(postgres.Resource, dependencies); } + [Fact] + public async Task DirectOnlyDoesNotIncludeWaitForDependenciesFromReferencedResources() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // Chain: A -> (ref) B -> (waitfor) C + // A has WithReference(B) and WaitFor(B) + // B has WaitFor(C) but A does NOT reference C directly + var c = builder.AddRedis("c"); + var b = builder.AddContainer("b", "alpine") + .WithHttpEndpoint(5000, 5000, "http") + .WaitFor(c); + var a = builder.AddContainer("a", "alpine") + .WithReference(b.GetEndpoint("http")) + .WaitFor(b); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await a.Resource.GetResourceDependenciesAsync(executionContext, ResourceDependencyDiscoveryMode.DirectOnly); + + // A depends on B (via WithReference and WaitFor) + Assert.Contains(b.Resource, dependencies); + // A should NOT depend on C because C is only a WaitFor dependency of B, not of A + Assert.DoesNotContain(c.Resource, dependencies); + } + [Fact] public async Task DefaultOverloadUsesTransitiveClosure() { From 715a77f0d7d7a72056278f16d8b9805b7e5bf212 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 12 Feb 2026 17:22:33 -0600 Subject: [PATCH 093/256] Stop ViteApps (build only containers) from getting Azure managed identities and roles (#14474) * Stop ViteApps (build only containers) from getting Azure managed identities and roles These resources don't get deployed, so they should be filtered from getting role assignments and managed identities added for them. * Update src/Aspire.Hosting.Azure/AzureResourcePreparer.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../AzureResourcePreparer.cs | 23 +++-------- .../Aspire.Hosting.Azure.Tests.csproj | 1 + .../AzureResourcePreparerTests.cs | 39 +++++++++++++++++++ 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs index 2f068b6404d..4006f488429 100644 --- a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs +++ b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs @@ -125,19 +125,13 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap // - if in PublishMode // - if a compute resource has RoleAssignmentAnnotations, use them // - if the resource doesn't, copy the DefaultRoleAssignments to RoleAssignmentAnnotations to apply the defaults - var resourceSnapshot = appModel.Resources.ToArray(); // avoid modifying the collection while iterating + var resourceSnapshot = appModel.GetComputeResources() + .Concat(appModel.Resources + .OfType() + .Where(r => !r.IsExcludedFromPublish())) + .ToArray(); // avoid modifying the collection while iterating foreach (var resource in resourceSnapshot) { - if (resource.IsExcludedFromPublish()) - { - continue; - } - - if (!IsResourceValidForRoleAssignments(resource)) - { - continue; - } - var azureReferences = await GetAzureReferences(resource, cancellationToken).ConfigureAwait(false); var azureReferencesWithRoleAssignments = @@ -231,13 +225,6 @@ private async Task BuildRoleAssignmentAnnotations(DistributedApplicationModel ap { CreateGlobalRoleAssignments(appModel, globalRoleAssignments); } - - // We can derive role assignments for compute resources and declared - // AzureUserAssignedIdentityResources - static bool IsResourceValidForRoleAssignments(IResource resource) - { - return resource.IsContainer() || resource is ProjectResource || resource is AzureUserAssignedIdentityResource; - } } private static Dictionary> GetAllRoleAssignments(IResource resource) diff --git a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj index 8d5c2093659..cbfc36135a2 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj +++ b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs index a7d3d4a3282..6ec0f7accaf 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureResourcePreparerTests.cs @@ -297,6 +297,45 @@ public async Task AppliesRoleAssignmentsOnlyToDirectReferences() n => Assert.Equal("api-roles-storage", n)); } + [Fact] + public async Task ViteAppDoesNotGetManagedIdentity() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + builder.AddAzureContainerAppEnvironment("env"); + + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + + var api = builder.AddProject("api", launchProfileName: null) + .WithHttpEndpoint() + .WithReference(blobs) + .WaitFor(blobs); + + var frontend = builder.AddViteApp("frontend", "./frontend") + .WithReference(api) + .WithReference(blobs) + .WaitFor(blobs); + + using var app = builder.Build(); + var model = app.Services.GetRequiredService(); + await ExecuteBeforeStartHooksAsync(app, default); + + Assert.Collection(model.Resources.Select(r => r.Name), + n => Assert.StartsWith("azure", n), + n => Assert.Equal("env-acr", n), + n => Assert.Equal("env", n), + n => Assert.Equal("storage", n), + n => Assert.Equal("blobs", n), + n => Assert.Equal("api", n), + n => Assert.Equal("frontend", n), + n => Assert.Equal("api-identity", n), + n => Assert.Equal("api-roles-storage", n)); + + // The ViteApp should NOT get a managed identity since it is a BuildOnlyContainer resource, + // even though it references the storage account. Only the API should get a managed identity. + Assert.DoesNotContain(model.Resources, r => r.Name == "frontend-identity"); + } + private sealed class Project : IProjectMetadata { public string ProjectPath => "project"; From 79d58386799cd13f8ad450c13e972811c237bd46 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:28:04 -0600 Subject: [PATCH 094/256] [release/13.2] Update Azure.Core to latest version - lift all runtime dependencies to latest (#14467) * Update to Azure.Core 1.51.1 Use latest versions for all dotnet/runtime nuget packages. This simplifies our dependency management. Remove ForceLatestDotnetVersions property from multiple project files * Update AzureDeployerTests to use WaitForShutdown instead of StopAsync There is a timing issue when using Start/Stop since the background pipeline might still be running and it cancels the pipeline before it can complete. * Fix AuxiliaryBackchannelTests by adding a Task that completes when the AuxiliaryBackchannelService is listening and ready for connections. * Remove double registration of AuxiliaryBackchannelService as an IHostedService. * Fix ResourceLoggerForwarderServiceTests to ensure the ResourceLoggerForwarderService has started before signalling the stopping token. --------- Co-authored-by: Eric Erhardt --- Directory.Packages.props | 70 ++++++----------- .../AzureAIFoundryEndToEnd.WebStory.csproj | 3 - .../AzureOpenAIEndToEnd.WebStory.csproj | 3 - .../GitHubModelsEndToEnd.WebStory.csproj | 3 - .../OpenAIEndToEnd.WebStory.csproj | 3 - .../AuxiliaryBackchannelService.cs | 10 +++ .../Aspire.Azure.AI.Inference.csproj | 2 - .../Aspire.Azure.AI.OpenAI.csproj | 2 - .../Aspire.OpenAI/Aspire.OpenAI.csproj | 2 - .../Aspire.Azure.AI.Inference.Tests.csproj | 3 - .../Aspire.Azure.AI.OpenAI.Tests.csproj | 3 - .../AzureDeployerTests.cs | 4 +- .../ResourceLoggerForwarderServiceTests.cs | 18 +++++ .../Backchannel/AuxiliaryBackchannelTests.cs | 75 +++++-------------- .../Aspire.OpenAI.Tests.csproj | 3 - 15 files changed, 71 insertions(+), 133 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 8e4674e5743..097be0ed6d4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -175,17 +175,18 @@ - + - + + - + @@ -195,6 +196,23 @@ + + + + + + + + + + + + + + + + @@ -218,18 +236,6 @@ - - - - - - - - - - - - @@ -254,26 +260,8 @@ - - - - - - - - - - - - - - - - - - - + @@ -295,17 +283,5 @@ - - - - - - - - - - - - diff --git a/playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.WebStory/AzureAIFoundryEndToEnd.WebStory.csproj b/playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.WebStory/AzureAIFoundryEndToEnd.WebStory.csproj index cd25e05445f..aa19fad6805 100644 --- a/playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.WebStory/AzureAIFoundryEndToEnd.WebStory.csproj +++ b/playground/AzureAIFoundryEndToEnd/AzureAIFoundryEndToEnd.WebStory/AzureAIFoundryEndToEnd.WebStory.csproj @@ -4,9 +4,6 @@ $(DefaultTargetFramework) enable enable - - - true diff --git a/playground/AzureOpenAIEndToEnd/AzureOpenAIEndToEnd.WebStory/AzureOpenAIEndToEnd.WebStory.csproj b/playground/AzureOpenAIEndToEnd/AzureOpenAIEndToEnd.WebStory/AzureOpenAIEndToEnd.WebStory.csproj index cefd34325a6..ede52a85213 100644 --- a/playground/AzureOpenAIEndToEnd/AzureOpenAIEndToEnd.WebStory/AzureOpenAIEndToEnd.WebStory.csproj +++ b/playground/AzureOpenAIEndToEnd/AzureOpenAIEndToEnd.WebStory/AzureOpenAIEndToEnd.WebStory.csproj @@ -4,9 +4,6 @@ $(DefaultTargetFramework) enable enable - - - true diff --git a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj index cd25e05445f..aa19fad6805 100644 --- a/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj +++ b/playground/GitHubModelsEndToEnd/GitHubModelsEndToEnd.WebStory/GitHubModelsEndToEnd.WebStory.csproj @@ -4,9 +4,6 @@ $(DefaultTargetFramework) enable enable - - - true diff --git a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj index 2df50b9502b..60f4356d1a3 100644 --- a/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj +++ b/playground/OpenAIEndToEnd/OpenAIEndToEnd.WebStory/OpenAIEndToEnd.WebStory.csproj @@ -4,9 +4,6 @@ $(DefaultTargetFramework) enable enable - - - true diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs index 6bf750cff6a..2ef434dd1af 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelService.cs @@ -22,12 +22,21 @@ internal sealed class AuxiliaryBackchannelService( : BackgroundService { private Socket? _serverSocket; + private readonly TaskCompletionSource _listeningTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); /// /// Gets the Unix socket path where the auxiliary backchannel is listening. /// public string? SocketPath { get; private set; } + /// + /// Gets a task that completes when the server socket is bound and listening for connections. + /// + /// + /// Used by tests to wait until the backchannel is ready before attempting to connect. + /// + internal Task ListeningTask => _listeningTcs.Task; + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try @@ -72,6 +81,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _serverSocket.Listen(backlog: 10); // Allow multiple pending connections logger.LogDebug("Auxiliary backchannel listening on {SocketPath}", SocketPath); + _listeningTcs.TrySetResult(); // Accept connections in a loop (supporting multiple concurrent connections) while (!stoppingToken.IsCancellationRequested) diff --git a/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj b/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj index 7370e834905..7926621c583 100644 --- a/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj +++ b/src/Components/Aspire.Azure.AI.Inference/Aspire.Azure.AI.Inference.csproj @@ -9,8 +9,6 @@ $(NoWarn);SYSLIB1100;SYSLIB1101 true - - true diff --git a/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj b/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj index 99d48574b2e..db2deffe6bc 100644 --- a/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj +++ b/src/Components/Aspire.Azure.AI.OpenAI/Aspire.Azure.AI.OpenAI.csproj @@ -10,8 +10,6 @@ $(NoWarn);SYSLIB1100;SYSLIB1101;AOAI001 true - - true diff --git a/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj b/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj index 836c1d9d11c..6b76d148c20 100644 --- a/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj +++ b/src/Components/Aspire.OpenAI/Aspire.OpenAI.csproj @@ -8,8 +8,6 @@ $(NoWarn);SYSLIB1100;SYSLIB1101 true - - true diff --git a/tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj b/tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj index cca72f73ac9..948e92b0096 100644 --- a/tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj +++ b/tests/Aspire.Azure.AI.Inference.Tests/Aspire.Azure.AI.Inference.Tests.csproj @@ -2,9 +2,6 @@ $(AllTargetFrameworks) - - - true diff --git a/tests/Aspire.Azure.AI.OpenAI.Tests/Aspire.Azure.AI.OpenAI.Tests.csproj b/tests/Aspire.Azure.AI.OpenAI.Tests/Aspire.Azure.AI.OpenAI.Tests.csproj index a27e07a3a63..2f45183b667 100644 --- a/tests/Aspire.Azure.AI.OpenAI.Tests/Aspire.Azure.AI.OpenAI.Tests.csproj +++ b/tests/Aspire.Azure.AI.OpenAI.Tests/Aspire.Azure.AI.OpenAI.Tests.csproj @@ -2,9 +2,6 @@ $(AllTargetFrameworks) - - - true diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index 0c9ff5c0492..09460f25c8d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -1072,7 +1072,7 @@ public async Task DeployAsync_WithAzureResourceDependencies_DoesNotHang(string s // Act using var app = builder.Build(); await app.StartAsync(); - await app.StopAsync(); + await app.WaitForShutdownAsync(); if (step == "diagnostics") { @@ -1159,7 +1159,7 @@ public async Task DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDep // Act using var app = builder.Build(); await app.StartAsync(); - await app.StopAsync(); + await app.WaitForShutdownAsync(); // In diagnostics mode, verify the deployment graph shows correct dependencies var logs = mockActivityReporter.LoggedMessages diff --git a/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs b/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs index 2e1084139c8..d10d3e2775f 100644 --- a/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/ResourceLoggerForwarderServiceTests.cs @@ -35,11 +35,29 @@ public async Task ExecuteDoesNotThrowOperationCanceledWhenAppStoppingTokenSignal var loggerFactory = new NullLoggerFactory(); var resourceLogForwarder = new ResourceLoggerForwarderService(resourceNotificationService, resourceLoggerService, hostEnvironment, loggerFactory); + // use a task to signal when the resourceLogForwarder has started executing + var subscribedTcs = new TaskCompletionSource(); + var subscriberLoop = Task.Run(async () => + { + await foreach (var sub in resourceLoggerService.WatchAnySubscribersAsync(hostApplicationLifetime.ApplicationStopping)) + { + subscribedTcs.TrySetResult(); + return; + } + }); + await resourceLogForwarder.StartAsync(hostApplicationLifetime.ApplicationStopping); Assert.NotNull(resourceLogForwarder.ExecuteTask); Assert.Equal(TaskStatus.WaitingForActivation, resourceLogForwarder.ExecuteTask.Status); + // Publish an update to the resource to kickstart the notification service loop + var myresource = new CustomResource("myresource"); + await resourceNotificationService.PublishUpdateAsync(myresource, snapshot => snapshot with { State = "Running" }); + + // Wait for the log stream to begin + await subscribedTcs.Task.WaitAsync(TimeSpan.FromSeconds(15)); + // Signal the stopping token hostApplicationLifetime.StopApplication(); diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs index 86f7622be67..1c06ea8f482 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelTests.cs @@ -28,16 +28,13 @@ public async Task CanStartAuxiliaryBackchannelService() return Task.CompletedTask; }); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service and verify it started var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); Assert.True(File.Exists(service.SocketPath)); @@ -71,25 +68,22 @@ public async Task CanConnectMultipleClientsToAuxiliaryBackchannel() return Task.CompletedTask; }); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect multiple clients concurrently var client1Socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); var client2Socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); var client3Socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); - + var endpoint = new UnixDomainSocketEndPoint(service.SocketPath); - + await client1Socket.ConnectAsync(endpoint).WaitAsync(TimeSpan.FromSeconds(60)); await client2Socket.ConnectAsync(endpoint).WaitAsync(TimeSpan.FromSeconds(60)); await client3Socket.ConnectAsync(endpoint).WaitAsync(TimeSpan.FromSeconds(60)); @@ -116,16 +110,13 @@ public async Task CanInvokeRpcMethodOnAuxiliaryBackchannel() // When the Dashboard is not part of the app model, null should be returned using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -154,16 +145,13 @@ public async Task GetAppHostInformationAsyncReturnsAppHostPath() // This test verifies that GetAppHostInformationAsync returns the AppHost path using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -198,16 +186,13 @@ public async Task MultipleClientsCanInvokeRpcMethodsConcurrently() // When the Dashboard is not part of the app model, null should be returned using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Create multiple clients and invoke RPC methods concurrently @@ -245,16 +230,13 @@ public async Task GetAppHostInformationAsyncReturnsFilePathWithExtension() // For .csproj-based AppHosts, it should include the .csproj extension using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TimeSpan.FromSeconds(60)); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -275,10 +257,10 @@ public async Task GetAppHostInformationAsyncReturnsFilePathWithExtension() Assert.NotNull(appHostInfo); Assert.NotNull(appHostInfo.AppHostPath); Assert.NotEmpty(appHostInfo.AppHostPath); - + // The path should be an absolute path Assert.True(Path.IsPathRooted(appHostInfo.AppHostPath), $"Expected absolute path but got: {appHostInfo.AppHostPath}"); - + // In test scenarios where assembly metadata is not available, we may get a path without extension // (falling back to AppHost:Path). In real scenarios with proper metadata, we should get .csproj or .cs // So we just verify the path is non-empty and rooted @@ -294,22 +276,19 @@ public async Task SocketPathUsesAuxiPrefix() // to avoid Windows reserved device name issues (AUX is reserved on Windows < 11) using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Verify that the socket path uses "auxi.sock." prefix var fileName = Path.GetFileName(service.SocketPath); Assert.StartsWith("auxi.sock.", fileName); - + // Verify that the socket file can be created (not blocked by Windows reserved names) Assert.True(File.Exists(service.SocketPath), $"Socket file should exist at: {service.SocketPath}"); @@ -328,16 +307,13 @@ public async Task CallResourceMcpToolAsyncThrowsWhenResourceNotFound() // Add a simple container resource (without MCP) builder.AddContainer("mycontainer", "nginx"); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -372,16 +348,13 @@ public async Task CallResourceMcpToolAsyncThrowsWhenResourceHasNoMcpAnnotation() // Add a simple container resource (without MCP) builder.AddContainer("mycontainer", "nginx"); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -412,16 +385,13 @@ public async Task StopAppHostAsyncInitiatesShutdown() // This test verifies that StopAppHostAsync initiates AppHost shutdown using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -464,16 +434,13 @@ public async Task GetCapabilitiesAsyncReturnsV1AndV2() // This test verifies that GetCapabilitiesAsync returns both v1 and v2 capabilities using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -505,16 +472,13 @@ public async Task GetAppHostInfoAsyncV2ReturnsAppHostInfo() // This test verifies that the v2 GetAppHostInfoAsync returns AppHost info using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(outputHelper); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client @@ -551,16 +515,13 @@ public async Task GetResourcesAsyncV2ReturnsResources() // Add a simple parameter resource builder.AddParameter("myparam"); - // Register the auxiliary backchannel service - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - using var app = builder.Build(); await app.StartAsync().WaitAsync(TestConstants.DefaultTimeoutTimeSpan); // Get the service var service = app.Services.GetRequiredService(); + await service.ListeningTask.WaitAsync(TimeSpan.FromSeconds(60)); Assert.NotNull(service.SocketPath); // Connect a client diff --git a/tests/Aspire.OpenAI.Tests/Aspire.OpenAI.Tests.csproj b/tests/Aspire.OpenAI.Tests/Aspire.OpenAI.Tests.csproj index 62892924e61..dec5f627481 100644 --- a/tests/Aspire.OpenAI.Tests/Aspire.OpenAI.Tests.csproj +++ b/tests/Aspire.OpenAI.Tests/Aspire.OpenAI.Tests.csproj @@ -2,9 +2,6 @@ $(AllTargetFrameworks) - - - true From e9c1fc1d7f874a8e582f215a511664c41ef0af07 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:03:40 -0800 Subject: [PATCH 095/256] [main] Fix transitive Azure role assignments through WaitFor dependencies (#14478) * Initial plan * Fix transitive Azure role assignments through WaitFor dependencies Remove CollectAnnotationDependencies calls from CollectDependenciesFromValue to prevent WaitFor/parent/connection-string-redirect annotations from referenced resources being included as direct dependencies of the caller. Add tests verifying: - DirectOnly mode excludes WaitFor deps from referenced resources - WaitFor doesn't create transitive role assignments in Azure publish Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --- .../ApplicationModel/ResourceExtensions.cs | 2 -- .../RoleAssignmentTests.cs | 30 +++++++++++++++++++ .../ResourceDependencyTests.cs | 25 ++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs index 4836d473b9a..fec5e55d6c7 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs @@ -1416,7 +1416,6 @@ private static void CollectDependenciesFromValue(object? value, HashSet("server", launchProfileName: null) + .WithHttpEndpoint() + .WithExternalHttpEndpoints() + .WithReference(cache) + .WaitFor(cache); + + builder.AddProject("webfrontend", launchProfileName: null) + .WithReference(server) + .WaitFor(server); + + var app = builder.Build(); + var model = app.Services.GetRequiredService(); + + await ExecuteBeforeStartHooksAsync(app, default); + + // The server should have a role assignment to the cache since it directly references it + Assert.Single(model.Resources.OfType(), r => r.Name == "server-roles-cache"); + + // The webfrontend should NOT have a role assignment to the cache since it only references the server + Assert.DoesNotContain(model.Resources, r => r.Name == "webfrontend-roles-cache"); + } + private static async Task RoleAssignmentTest( string azureResourceName, Action configureBuilder, diff --git a/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs b/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs index 65ae7925b24..0688a07e352 100644 --- a/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs @@ -545,6 +545,31 @@ public async Task DirectOnlyIncludesReferencedResourcesFromConnectionString() Assert.Contains(postgres.Resource, dependencies); } + [Fact] + public async Task DirectOnlyDoesNotIncludeWaitForDependenciesFromReferencedResources() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // Chain: A -> (ref) B -> (waitfor) C + // A has WithReference(B) and WaitFor(B) + // B has WaitFor(C) but A does NOT reference C directly + var c = builder.AddRedis("c"); + var b = builder.AddContainer("b", "alpine") + .WithHttpEndpoint(5000, 5000, "http") + .WaitFor(c); + var a = builder.AddContainer("a", "alpine") + .WithReference(b.GetEndpoint("http")) + .WaitFor(b); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await a.Resource.GetResourceDependenciesAsync(executionContext, ResourceDependencyDiscoveryMode.DirectOnly); + + // A depends on B (via WithReference and WaitFor) + Assert.Contains(b.Resource, dependencies); + // A should NOT depend on C because C is only a WaitFor dependency of B, not of A + Assert.DoesNotContain(c.Resource, dependencies); + } + [Fact] public async Task DefaultOverloadUsesTransitiveClosure() { From 9a28e7ce6b625ed91e84be3d3ec204314caa90eb Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Thu, 12 Feb 2026 18:15:47 -0800 Subject: [PATCH 096/256] Update pipeline image names in public-pipeline-template (#14486) --- eng/pipelines/templates/public-pipeline-template.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eng/pipelines/templates/public-pipeline-template.yml b/eng/pipelines/templates/public-pipeline-template.yml index ad9e96a7a56..5c394939d03 100644 --- a/eng/pipelines/templates/public-pipeline-template.yml +++ b/eng/pipelines/templates/public-pipeline-template.yml @@ -86,7 +86,7 @@ stages: pool: name: $(DncEngPublicBuildPool) - demands: ImageOverride -equals windows.vs2022preview.amd64.open + demands: ImageOverride -equals 1es-windows-2022-open variables: - name: _buildScript @@ -119,7 +119,7 @@ stages: pool: name: $(DncEngPublicBuildPool) - demands: ImageOverride -equals build.ubuntu.2204.amd64.open + demands: ImageOverride -equals Build.Ubuntu.2204.Amd64.Open variables: - name: _buildScript From 7fcc0cb9c4a049c55630f0cae9ee5cb6d67317ef Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 12 Feb 2026 22:00:38 -0800 Subject: [PATCH 097/256] Improve detach mode: fix pipe handle inheritance and unify log naming (#14424) * Improve detach mode: fix pipe handle inheritance and unify log naming * Show child log file path on detach success * Fix detach: redirect child output to suppress console bleed * Suppress child console output via --log-file instead of pipe redirection The previous approach (RedirectStandardOutput=true + close streams) still created inheritable pipe handles. Instead, keep Redirect=false to avoid pipe inheritance and have the child process suppress its own console output when --log-file is specified (the signal that it's a detach child). * Add DetachedProcessLauncher with native CreateProcess on Windows Replace Process.Start with platform-specific launcher that suppresses child output and prevents handle/fd inheritance to grandchildren: - Windows: P/Invoke CreateProcess with STARTUPINFOEX and PROC_THREAD_ATTRIBUTE_HANDLE_LIST (same approach as Docker/hcsshim). Child stdout/stderr go to NUL, only NUL handle is inheritable. - Linux/macOS: Process.Start with RedirectStdout=true + close streams. Pipes are O_CLOEXEC so grandchild never inherits them. Removes the Console.SetOut(TextWriter.Null) workaround from Program.cs. * Fix misleading O_CLOEXEC comments in Unix launcher dup2 onto fd 0/1/2 clears O_CLOEXEC, so grandchildren DO inherit the pipe as their stdio. With the parent's read-end closed, writes produce harmless EPIPE. Updated comments to accurately describe the Unix fd inheritance model based on dotnet/runtime pal_process.c source. * Fix command-line quoting for Windows backslash escaping * Fix detach log parsing and child log path handling --- src/Aspire.Cli/Aspire.Cli.csproj | 1 + src/Aspire.Cli/Commands/CacheCommand.cs | 9 +- src/Aspire.Cli/Commands/RunCommand.cs | 112 +++--- .../Diagnostics/FileLoggerProvider.cs | 21 +- .../Processes/DetachedProcessLauncher.Unix.cs | 48 +++ .../DetachedProcessLauncher.Windows.cs | 325 ++++++++++++++++++ .../Processes/DetachedProcessLauncher.cs | 80 +++++ src/Aspire.Cli/Program.cs | 33 +- .../Resources/RunCommandStrings.Designer.cs | 6 + .../Resources/RunCommandStrings.resx | 3 + .../Resources/xlf/RunCommandStrings.cs.xlf | 5 + .../Resources/xlf/RunCommandStrings.de.xlf | 5 + .../Resources/xlf/RunCommandStrings.es.xlf | 5 + .../Resources/xlf/RunCommandStrings.fr.xlf | 5 + .../Resources/xlf/RunCommandStrings.it.xlf | 5 + .../Resources/xlf/RunCommandStrings.ja.xlf | 5 + .../Resources/xlf/RunCommandStrings.ko.xlf | 5 + .../Resources/xlf/RunCommandStrings.pl.xlf | 5 + .../Resources/xlf/RunCommandStrings.pt-BR.xlf | 5 + .../Resources/xlf/RunCommandStrings.ru.xlf | 5 + .../Resources/xlf/RunCommandStrings.tr.xlf | 5 + .../xlf/RunCommandStrings.zh-Hans.xlf | 5 + .../xlf/RunCommandStrings.zh-Hant.xlf | 5 + .../Commands/RunCommandTests.cs | 37 ++ tests/Aspire.Cli.Tests/ProgramTests.cs | 31 ++ 25 files changed, 703 insertions(+), 68 deletions(-) create mode 100644 src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs create mode 100644 src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs create mode 100644 src/Aspire.Cli/Processes/DetachedProcessLauncher.cs create mode 100644 tests/Aspire.Cli.Tests/ProgramTests.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 654c480a3a3..688c6e0525a 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -6,6 +6,7 @@ net10.0 enable enable + true false aspire Aspire.Cli diff --git a/src/Aspire.Cli/Commands/CacheCommand.cs b/src/Aspire.Cli/Commands/CacheCommand.cs index eef3d1501b7..ef69f774a60 100644 --- a/src/Aspire.Cli/Commands/CacheCommand.cs +++ b/src/Aspire.Cli/Commands/CacheCommand.cs @@ -45,7 +45,7 @@ protected override Task ExecuteAsync(ParseResult parseResult, CancellationT { var cacheDirectory = ExecutionContext.CacheDirectory; var filesDeleted = 0; - + // Delete cache files and subdirectories if (cacheDirectory.Exists) { @@ -110,14 +110,13 @@ protected override Task ExecuteAsync(ParseResult parseResult, CancellationT // Also clear the logs directory (skip current process's log file) var logsDirectory = ExecutionContext.LogsDirectory; - // Log files are named cli-{timestamp}-{pid}.log, so we need to check the suffix - var currentLogFileSuffix = $"-{Environment.ProcessId}.log"; + var currentLogFilePath = ExecutionContext.LogFilePath; if (logsDirectory.Exists) { foreach (var file in logsDirectory.GetFiles("*", SearchOption.AllDirectories)) { // Skip the current process's log file to avoid deleting it while in use - if (file.Name.EndsWith(currentLogFileSuffix, StringComparison.OrdinalIgnoreCase)) + if (file.FullName.Equals(currentLogFilePath, StringComparison.OrdinalIgnoreCase)) { continue; } @@ -167,4 +166,4 @@ protected override Task ExecuteAsync(ParseResult parseResult, CancellationT } } } -} \ No newline at end of file +} diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index e8f202b82d1..3e00efb00c2 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -11,6 +11,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; using Aspire.Cli.Interaction; +using Aspire.Cli.Processes; using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; @@ -86,6 +87,11 @@ internal sealed class RunCommand : BaseCommand { Description = RunCommandStrings.NoBuildArgumentDescription }; + private static readonly Option s_logFileOption = new("--log-file") + { + Description = "Path to write the log file (used internally by --detach).", + Hidden = true + }; private readonly Option? _startDebugSessionOption; public RunCommand( @@ -126,6 +132,7 @@ public RunCommand( Options.Add(s_formatOption); Options.Add(s_isolatedOption); Options.Add(s_noBuildOption); + Options.Add(s_logFileOption); if (ExtensionHelper.IsExtensionHost(InteractionService, out _, out _)) { @@ -294,9 +301,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Handle remote environments (Codespaces, Remote Containers, SSH) var isCodespaces = dashboardUrls.CodespacesUrlWithLoginToken is not null; - var isRemoteContainers = _configuration.GetValue("REMOTE_CONTAINERS", false); - var isSshRemote = _configuration.GetValue("VSCODE_IPC_HOOK_CLI") is not null - && _configuration.GetValue("SSH_CONNECTION") is not null; + var isRemoteContainers = string.Equals(_configuration["REMOTE_CONTAINERS"], "true", StringComparison.OrdinalIgnoreCase); + var isSshRemote = _configuration["VSCODE_IPC_HOOK_CLI"] is not null + && _configuration["SSH_CONNECTION"] is not null; AppendCtrlCMessage(longestLocalizedLengthWithColon); @@ -492,7 +499,7 @@ internal static int RenderAppHostSummary( new Align(new Markup($"[bold green]{dashboardLabel}[/]:"), HorizontalAlignment.Right), new Markup("[dim]N/A[/]")); } - grid.AddRow(Text.Empty, Text.Empty); + grid.AddRow(Text.Empty, Text.Empty); } // Logs row @@ -655,18 +662,23 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? _logger.LogDebug("Found {Count} running instance(s) for this AppHost, stopping them first", existingSockets.Length); var manager = new RunningInstanceManager(_logger, _interactionService, _timeProvider); // Stop all running instances in parallel - don't block on failures - var stopTasks = existingSockets.Select(socket => + var stopTasks = existingSockets.Select(socket => manager.StopRunningInstanceAsync(socket, cancellationToken)); await Task.WhenAll(stopTasks).ConfigureAwait(false); } // Build the arguments for the child CLI process + // Tell the child where to write its log so we can find it on failure. + var childLogFile = GenerateChildLogFilePath(); + var args = new List { "run", "--non-interactive", "--project", - effectiveAppHostFile.FullName + effectiveAppHostFile.FullName, + "--log-file", + childLogFile }; // Pass through global options that should be forwarded to child CLI @@ -707,29 +719,15 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? dotnetPath, isDotnetHost, string.Join(" ", args)); _logger.LogDebug("Working directory: {WorkingDirectory}", ExecutionContext.WorkingDirectory.FullName); - // Redirect stdout/stderr to suppress child output - it writes to log file anyway - var startInfo = new ProcessStartInfo - { - FileName = dotnetPath, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = false, - WorkingDirectory = ExecutionContext.WorkingDirectory.FullName - }; - - // If we're running via `dotnet aspire.dll`, add the DLL as first arg - // When running native AOT, don't add the DLL even if it exists in the same folder + // Build the full argument list for the child process, including the entry assembly + // path when running via `dotnet aspire.dll` + var childArgs = new List(); if (isDotnetHost && !string.IsNullOrEmpty(entryAssemblyPath) && entryAssemblyPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) { - startInfo.ArgumentList.Add(entryAssemblyPath); + childArgs.Add(entryAssemblyPath); } - foreach (var arg in args) - { - startInfo.ArgumentList.Add(arg); - } + childArgs.AddRange(args); // Start the child process and wait for the backchannel in a single status spinner Process? childProcess = null; @@ -741,30 +739,10 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? // Failure mode 2: Failed to spawn child process try { - childProcess = Process.Start(startInfo); - if (childProcess is null) - { - return null; - } - - // Start async reading of stdout/stderr to prevent buffer blocking - // Log output for debugging purposes - childProcess.OutputDataReceived += (_, e) => - { - if (e.Data is not null) - { - _logger.LogDebug("Child stdout: {Line}", e.Data); - } - }; - childProcess.ErrorDataReceived += (_, e) => - { - if (e.Data is not null) - { - _logger.LogDebug("Child stderr: {Line}", e.Data); - } - }; - childProcess.BeginOutputReadLine(); - childProcess.BeginErrorReadLine(); + childProcess = DetachedProcessLauncher.Start( + dotnetPath, + childArgs, + ExecutionContext.WorkingDirectory.FullName); } catch (Exception ex) { @@ -843,10 +821,8 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? if (childExitedEarly) { - _interactionService.DisplayError(string.Format( - CultureInfo.CurrentCulture, - RunCommandStrings.AppHostExitedWithCode, - childExitCode)); + // Show a friendly message based on well-known exit codes from the child + _interactionService.DisplayError(GetDetachedFailureMessage(childExitCode)); } else { @@ -866,11 +842,11 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? } } - // Always show log file path for troubleshooting + // Point to the child's log file — it contains the actual build/runtime errors _interactionService.DisplayMessage("magnifying_glass_tilted_right", string.Format( CultureInfo.CurrentCulture, RunCommandStrings.CheckLogsForDetails, - _fileLoggerProvider.LogFilePath.EscapeMarkup())); + childLogFile.EscapeMarkup())); return ExitCodeConstants.FailedToDotnetRunAppHost; } @@ -890,7 +866,7 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? pid, childProcess.Id, dashboardUrls?.BaseUrlWithLoginToken, - _fileLoggerProvider.LogFilePath); + childLogFile); var json = JsonSerializer.Serialize(result, RunCommandJsonContext.RelaxedEscaping.DetachOutputInfo); _interactionService.DisplayRawText(json); } @@ -903,7 +879,7 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? appHostRelativePath, dashboardUrls?.BaseUrlWithLoginToken, codespacesUrl: null, - _fileLoggerProvider.LogFilePath, + childLogFile, isExtensionHost, pid); _ansiConsole.WriteLine(); @@ -913,4 +889,26 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? return ExitCodeConstants.Success; } + + internal static string GetDetachedFailureMessage(int childExitCode) + { + return childExitCode switch + { + ExitCodeConstants.FailedToBuildArtifacts => RunCommandStrings.AppHostFailedToBuild, + _ => string.Format(CultureInfo.CurrentCulture, RunCommandStrings.AppHostExitedWithCode, childExitCode) + }; + } + + internal static string GenerateChildLogFilePath(string logsDirectory, TimeProvider timeProvider) + { + var timestamp = timeProvider.GetUtcNow().ToString("yyyyMMddTHHmmssfff", CultureInfo.InvariantCulture); + var uniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + var fileName = $"cli_{timestamp}_detach-child_{uniqueId}.log"; + return Path.Combine(logsDirectory, fileName); + } + + private string GenerateChildLogFilePath() + { + return GenerateChildLogFilePath(ExecutionContext.LogsDirectory.FullName, _timeProvider); + } } diff --git a/src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs b/src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs index 4035352669d..f833c445359 100644 --- a/src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs +++ b/src/Aspire.Cli/Diagnostics/FileLoggerProvider.cs @@ -29,6 +29,22 @@ internal sealed class FileLoggerProvider : ILoggerProvider /// public string LogFilePath => _logFilePath; + /// + /// Generates a unique, chronologically-sortable log file name. + /// + /// The directory where log files will be written. + /// The time provider for timestamp generation. + /// An optional suffix appended before the extension (e.g. "detach-child"). + internal static string GenerateLogFilePath(string logsDirectory, TimeProvider timeProvider, string? suffix = null) + { + var timestamp = timeProvider.GetUtcNow().ToString("yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture); + var id = Guid.NewGuid().ToString("N")[..8]; + var name = suffix is null + ? $"cli_{timestamp}_{id}.log" + : $"cli_{timestamp}_{id}_{suffix}.log"; + return Path.Combine(logsDirectory, name); + } + /// /// Creates a new FileLoggerProvider that writes to the specified directory. /// @@ -37,10 +53,7 @@ internal sealed class FileLoggerProvider : ILoggerProvider /// Optional console for error messages. Defaults to stderr. public FileLoggerProvider(string logsDirectory, TimeProvider timeProvider, IAnsiConsole? errorConsole = null) { - var pid = Environment.ProcessId; - var timestamp = timeProvider.GetUtcNow().ToString("yyyy-MM-dd-HH-mm-ss", CultureInfo.InvariantCulture); - // Timestamp first so files sort chronologically by name - _logFilePath = Path.Combine(logsDirectory, $"cli-{timestamp}-{pid}.log"); + _logFilePath = GenerateLogFilePath(logsDirectory, timeProvider); try { diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs new file mode 100644 index 00000000000..ee7620a2fd2 --- /dev/null +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs @@ -0,0 +1,48 @@ +// 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; + +namespace Aspire.Cli.Processes; + +internal static partial class DetachedProcessLauncher +{ + /// + /// Unix implementation using Process.Start with stdio redirection. + /// On Linux/macOS, the redirect pipes' original fds are created with O_CLOEXEC, + /// but dup2 onto fd 0/1/2 clears that flag — so grandchildren DO inherit the pipe + /// as their stdio. However, since we close the parent's read-end immediately, the + /// pipe has no reader and writes produce EPIPE (harmless). The key difference from + /// Windows is that on Unix, only fds 0/1/2 survive exec — no extra handle leakage. + /// + private static Process StartUnix(string fileName, IReadOnlyList arguments, string workingDirectory) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = false, + WorkingDirectory = workingDirectory + }; + + foreach (var arg in arguments) + { + startInfo.ArgumentList.Add(arg); + } + + var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start detached process"); + + // Close the parent's read-end of the pipes. This means the pipe has no reader, + // so if the grandchild (AppHost) writes to inherited stdout/stderr, it gets EPIPE + // which is harmless. The important thing is no caller is blocked waiting on the + // pipe — unlike Windows where the handle stays open and blocks execSync callers. + process.StandardOutput.Close(); + process.StandardError.Close(); + + return process; + } +} diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs new file mode 100644 index 00000000000..da509a0be25 --- /dev/null +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs @@ -0,0 +1,325 @@ +// 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 System.Runtime.Versioning; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace Aspire.Cli.Processes; + +internal static partial class DetachedProcessLauncher +{ + /// + /// Windows implementation using CreateProcess with STARTUPINFOEX and + /// PROC_THREAD_ATTRIBUTE_HANDLE_LIST to prevent handle inheritance to grandchildren. + /// + [SupportedOSPlatform("windows")] + private static Process StartWindows(string fileName, IReadOnlyList arguments, string workingDirectory) + { + // Open NUL device for stdout/stderr — child writes go nowhere + using var nulHandle = CreateFileW( + "NUL", + GenericWrite, + FileShareWrite, + nint.Zero, + OpenExisting, + 0, + nint.Zero); + + if (nulHandle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to open NUL device"); + } + + // Mark the NUL handle as inheritable (required for STARTUPINFO hStdOutput assignment) + if (!SetHandleInformation(nulHandle, HandleFlagInherit, HandleFlagInherit)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to set NUL handle inheritance"); + } + + // Initialize a process thread attribute list with 1 slot (HANDLE_LIST) + var attrListSize = nint.Zero; + InitializeProcThreadAttributeList(nint.Zero, 1, 0, ref attrListSize); + + var attrList = Marshal.AllocHGlobal(attrListSize); + try + { + if (!InitializeProcThreadAttributeList(attrList, 1, 0, ref attrListSize)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to initialize process thread attribute list"); + } + + try + { + // Whitelist only the NUL handle for inheritance. + // The grandchild (AppHost) will inherit this harmless handle instead of + // any pipes from the caller's process tree. + var handles = new[] { nulHandle.DangerousGetHandle() }; + var pinnedHandles = GCHandle.Alloc(handles, GCHandleType.Pinned); + try + { + if (!UpdateProcThreadAttribute( + attrList, + 0, + s_procThreadAttributeHandleList, + pinnedHandles.AddrOfPinnedObject(), + (nint)(nint.Size * handles.Length), + nint.Zero, + nint.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to update process thread attribute list"); + } + + var nulRawHandle = nulHandle.DangerousGetHandle(); + + var si = new STARTUPINFOEX(); + si.cb = Marshal.SizeOf(); + si.dwFlags = StartfUseStdHandles; + si.hStdInput = nint.Zero; + si.hStdOutput = nulRawHandle; + si.hStdError = nulRawHandle; + si.lpAttributeList = attrList; + + // Build the command line string: "fileName" arg1 arg2 ... + var commandLine = BuildCommandLine(fileName, arguments); + + var flags = CreateUnicodeEnvironment | ExtendedStartupInfoPresent | CreateNoWindow; + + if (!CreateProcessW( + null, + commandLine, + nint.Zero, + nint.Zero, + bInheritHandles: true, // TRUE but HANDLE_LIST restricts what's actually inherited + flags, + nint.Zero, + workingDirectory, + ref si, + out var pi)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create detached process"); + } + + Process detachedProcess; + try + { + detachedProcess = Process.GetProcessById(pi.dwProcessId); + } + finally + { + // Close the process and thread handles returned by CreateProcess. + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + } + + return detachedProcess; + } + finally + { + pinnedHandles.Free(); + } + } + finally + { + DeleteProcThreadAttributeList(attrList); + } + } + finally + { + Marshal.FreeHGlobal(attrList); + } + } + + /// + /// Builds a Windows command line string with correct quoting rules. + /// Adapted from dotnet/runtime PasteArguments.AppendArgument. + /// + private static StringBuilder BuildCommandLine(string fileName, IReadOnlyList arguments) + { + var sb = new StringBuilder(); + + // Quote the executable path + sb.Append('"').Append(fileName).Append('"'); + + foreach (var arg in arguments) + { + sb.Append(' '); + AppendArgument(sb, arg); + } + + return sb; + } + + /// + /// Appends a correctly-quoted argument to the command line. + /// Copied from dotnet/runtime src/libraries/System.Private.CoreLib/src/System/PasteArguments.cs + /// + private static void AppendArgument(StringBuilder sb, string argument) + { + // Windows command-line parsing rules: + // - Backslash is normal except when followed by a quote + // - 2N backslashes + quote → N literal backslashes + unescaped quote + // - 2N+1 backslashes + quote → N literal backslashes + literal quote + if (argument.Length != 0 && !argument.AsSpan().ContainsAny(' ', '\t', '"')) + { + sb.Append(argument); + return; + } + + sb.Append('"'); + var idx = 0; + while (idx < argument.Length) + { + var c = argument[idx++]; + if (c == '\\') + { + var numBackslash = 1; + while (idx < argument.Length && argument[idx] == '\\') + { + idx++; + numBackslash++; + } + + if (idx == argument.Length) + { + // Trailing backslashes before closing quote — must double them + sb.Append('\\', numBackslash * 2); + } + else if (argument[idx] == '"') + { + // Backslashes followed by quote — double them + escape the quote + sb.Append('\\', numBackslash * 2 + 1); + sb.Append('"'); + idx++; + } + else + { + // Backslashes not followed by quote — emit as-is + sb.Append('\\', numBackslash); + } + + continue; + } + + if (c == '"') + { + sb.Append('\\'); + sb.Append('"'); + continue; + } + + sb.Append(c); + } + + sb.Append('"'); + } + + // --- Constants --- + private const uint GenericWrite = 0x40000000; + private const uint FileShareWrite = 0x00000002; + private const uint OpenExisting = 3; + private const uint HandleFlagInherit = 0x00000001; + private const uint StartfUseStdHandles = 0x00000100; + private const uint CreateUnicodeEnvironment = 0x00000400; + private const uint ExtendedStartupInfoPresent = 0x00080000; + private const uint CreateNoWindow = 0x08000000; + private static readonly nint s_procThreadAttributeHandleList = (nint)0x00020002; + + // --- Structs --- + + [StructLayout(LayoutKind.Sequential)] + private struct STARTUPINFOEX + { + public int cb; + public nint lpReserved; + public nint lpDesktop; + public nint lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public uint dwFlags; + public ushort wShowWindow; + public ushort cbReserved2; + public nint lpReserved2; + public nint hStdInput; + public nint hStdOutput; + public nint hStdError; + public nint lpAttributeList; + } + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION + { + public nint hProcess; + public nint hThread; + public int dwProcessId; + public int dwThreadId; + } + + // --- P/Invoke declarations --- + + [LibraryImport("kernel32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + private static partial SafeFileHandle CreateFileW( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + nint lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + nint hTemplateFile); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetHandleInformation( + SafeFileHandle hObject, + uint dwMask, + uint dwFlags); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool InitializeProcThreadAttributeList( + nint lpAttributeList, + int dwAttributeCount, + int dwFlags, + ref nint lpSize); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool UpdateProcThreadAttribute( + nint lpAttributeList, + uint dwFlags, + nint attribute, + nint lpValue, + nint cbSize, + nint lpPreviousValue, + nint lpReturnSize); + + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial void DeleteProcThreadAttributeList(nint lpAttributeList); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] +#pragma warning disable CA1838 // CreateProcessW requires a mutable command line buffer + private static extern bool CreateProcessW( + string? lpApplicationName, + StringBuilder lpCommandLine, + nint lpProcessAttributes, + nint lpThreadAttributes, + bool bInheritHandles, + uint dwCreationFlags, + nint lpEnvironment, + string? lpCurrentDirectory, + ref STARTUPINFOEX lpStartupInfo, + out PROCESS_INFORMATION lpProcessInformation); +#pragma warning restore CA1838 + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool CloseHandle(nint hObject); +} diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs new file mode 100644 index 00000000000..006a4c86025 --- /dev/null +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.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.Diagnostics; + +namespace Aspire.Cli.Processes; + +// ============================================================================ +// DetachedProcessLauncher — Platform-aware child process launcher for --detach +// ============================================================================ +// +// When `aspire run --detach` is used, the CLI spawns a child CLI process which +// in turn spawns the AppHost (the "grandchild"). Two constraints must hold: +// +// 1. The child's stdout/stderr must NOT appear on the parent's console. +// The parent renders its own summary UX (dashboard URL, PID, log path) and +// if the child's output (spinners, "Press CTRL+C", etc.) bleeds through, it +// corrupts the parent's terminal — and breaks E2E tests that pattern-match +// on the parent's output. +// +// 2. No pipe or handle from the parent→child stdio redirection may leak into +// the grandchild (AppHost). If it does, callers that wait for the CLI's +// stdout to close (e.g. Node.js `execSync`, shell `$(...)` substitution) +// will hang until the AppHost exits — which defeats the purpose of --detach. +// +// These two constraints conflict when using .NET's Process.Start: +// +// • RedirectStandardOutput = true → solves (1) but violates (2) on Windows, +// because .NET calls CreateProcess with bInheritHandles=TRUE, and the pipe +// write-handle is duplicated into the child. The child passes it to the +// grandchild (AppHost), keeping the pipe alive. +// +// • RedirectStandardOutput = false → solves (2) but violates (1), because +// the child inherits the parent's console and writes directly to it. +// +// The solution is platform-specific: +// +// ┌─────────┬────────────────────────────────────────────────────────────────┐ +// │ Windows │ P/Invoke CreateProcess with STARTUPINFOEX and an explicit │ +// │ │ PROC_THREAD_ATTRIBUTE_HANDLE_LIST. This lets us set │ +// │ │ bInheritHandles=TRUE (required to assign hStdOutput to NUL) │ +// │ │ while restricting inheritance to ONLY the NUL handle — so the │ +// │ │ grandchild inherits nothing useful. Child stdout/stderr go to │ +// │ │ the NUL device. This is the same approach used by Docker's │ +// │ │ Windows container runtime (microsoft/hcsshim). │ +// │ │ │ +// │ Linux / │ Process.Start with RedirectStandard{Output,Error} = true, │ +// │ macOS │ then immediately close the parent's read-end pipe streams. │ +// │ │ The original pipe fds have O_CLOEXEC, but dup2 onto fd 0/1/2 │ +// │ │ clears it — so grandchildren inherit the pipe as their stdio. │ +// │ │ With no reader, writes produce harmless EPIPE. The critical │ +// │ │ difference from Windows is that no caller gets stuck waiting │ +// │ │ on a pipe handle — closing the read-end is sufficient. │ +// └─────────┴────────────────────────────────────────────────────────────────┘ +// + +/// +/// Launches a child process with stdout/stderr suppressed and no handle/fd +/// inheritance to grandchild processes. Used by aspire run --detach. +/// +internal static partial class DetachedProcessLauncher +{ + /// + /// Starts a detached child process with stdout/stderr going to the null device + /// and no inheritable handles/fds leaking to grandchildren. + /// + /// The executable path (e.g. dotnet or the native CLI). + /// The command-line arguments for the child process. + /// The working directory for the child process. + /// A object representing the launched child. + public static Process Start(string fileName, IReadOnlyList arguments, string workingDirectory) + { + if (OperatingSystem.IsWindows()) + { + return StartWindows(fileName, arguments, workingDirectory); + } + + return StartUnix(fileName, arguments, workingDirectory); + } +} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index e7e1e91cde5..ebc0c3ea7c1 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -92,6 +92,33 @@ private static (LogLevel? ConsoleLogLevel, bool DebugMode) ParseLoggingOptions(s return (logLevel, debugMode); } + /// + /// Parses --log-file from raw args before the host is built. + /// Used by --detach to tell the child CLI where to write its log. + /// + internal static string? ParseLogFileOption(string[]? args) + { + if (args is null) + { + return null; + } + + for (var i = 0; i < args.Length; i++) + { + if (args[i] == "--") + { + break; + } + + if (args[i] == "--log-file" && i + 1 < args.Length) + { + return args[i + 1]; + } + } + + return null; + } + private static string GetGlobalSettingsPath() { var usersAspirePath = GetUsersAspirePath(); @@ -159,7 +186,10 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar // Always register FileLoggerProvider to capture logs to disk // This captures complete CLI session details for diagnostics var logsDirectory = Path.Combine(GetUsersAspirePath(), "logs"); - var fileLoggerProvider = new FileLoggerProvider(logsDirectory, TimeProvider.System); + var logFilePath = ParseLogFileOption(args); + var fileLoggerProvider = logFilePath is not null + ? new FileLoggerProvider(logFilePath) + : new FileLoggerProvider(logsDirectory, TimeProvider.System); builder.Services.AddSingleton(fileLoggerProvider); // Register for direct access to LogFilePath builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton(fileLoggerProvider)); @@ -706,4 +736,3 @@ public void Enrich(Profile profile) profile.Capabilities.Interactive = true; } } - diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs index 5a58b653555..73fd7e4da7f 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs @@ -255,6 +255,12 @@ public static string AppHostExitedWithCode { } } + public static string AppHostFailedToBuild { + get { + return ResourceManager.GetString("AppHostFailedToBuild", resourceCulture); + } + } + public static string TimeoutWaitingForAppHost { get { return ResourceManager.GetString("TimeoutWaitingForAppHost", resourceCulture); diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.resx b/src/Aspire.Cli/Resources/RunCommandStrings.resx index 3b5ca858c10..a5fabb4943d 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.resx +++ b/src/Aspire.Cli/Resources/RunCommandStrings.resx @@ -226,6 +226,9 @@ AppHost process exited with code {0}. + + AppHost failed to build. + Timeout waiting for AppHost to start. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf index 454f6dc920c..5535b7f2e9b 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf @@ -37,6 +37,11 @@ Proces AppHost byl ukončen s kódem {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. Hostitel aplikací (AppHost) se úspěšně spustil. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf index 37a94547020..1278b7bd643 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf @@ -37,6 +37,11 @@ Der AppHost-Prozess wurde mit Code {0} beendet. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. Der AppHost wurde erfolgreich gestartet. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf index 22f5ddcb6dd..a39b710377e 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf @@ -37,6 +37,11 @@ El proceso AppHost se cerró con código {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost se ha iniciado correctamente. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf index adaaed0befc..fb0a0a6e08a 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf @@ -37,6 +37,11 @@ Processus AppHost arrêté avec le code {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost a démarré correctement. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf index df0aadbf184..5296e1ed136 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf @@ -37,6 +37,11 @@ Processo AppHost terminato con codice {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost avviato correttamente. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf index 79e19c80b02..8e73ec517c9 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf @@ -37,6 +37,11 @@ AppHost プロセスが終了し、コード {0} を返しました。 + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost が正常に起動しました。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf index f88d2ab250b..6d10f499767 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf @@ -37,6 +37,11 @@ AppHost 프로세스가 {0} 코드로 종료되었습니다. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost를 시작했습니다. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf index 3d299fe03aa..9f2531431c7 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf @@ -37,6 +37,11 @@ Proces hosta aplikacji zakończył się z kodem {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. Host aplikacji został pomyślnie uruchomiony. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf index 68c69dbc585..543eb04e807 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf @@ -37,6 +37,11 @@ O processo AppHost foi encerrado com o código {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost iniciado com sucesso. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf index f92d6c1056b..9ffd508797b 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf @@ -37,6 +37,11 @@ Процесс AppHost завершился с кодом {0}. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. Запуск AppHost выполнен. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf index f1802c2992a..6bd56480556 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf @@ -37,6 +37,11 @@ AppHost işlemi {0} koduyla sonlandırıldı. + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. AppHost başarıyla başlatıldı. diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf index b23f5447f08..703a2736efa 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf @@ -37,6 +37,11 @@ AppHost 进程已退出,代码为 {0}。 + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. 已成功启动 AppHost。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf index f165b8200a3..20f829b7698 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf @@ -37,6 +37,11 @@ AppHost 程序以返回碼 {0} 結束。 + + AppHost failed to build. + AppHost failed to build. + + AppHost started successfully. 已成功啟動 AppHost。 diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 2a84fbbe614..0cdf41939b4 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -87,6 +87,38 @@ public async Task RunCommand_WhenProjectFileDoesNotExist_ReturnsNonZeroExitCode( Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); } + [Fact] + public void GetDetachedFailureMessage_ReturnsBuildSpecificMessage_ForBuildFailureExitCode() + { + var message = RunCommand.GetDetachedFailureMessage(ExitCodeConstants.FailedToBuildArtifacts); + + Assert.Equal(RunCommandStrings.AppHostFailedToBuild, message); + } + + [Fact] + public void GetDetachedFailureMessage_ReturnsExitCodeMessage_ForUnknownExitCode() + { + var message = RunCommand.GetDetachedFailureMessage(123); + + Assert.Contains("123", message, StringComparison.Ordinal); + } + + [Fact] + public void GenerateChildLogFilePath_UsesDetachChildNamingWithoutProcessId() + { + var logsDirectory = Path.Combine(Path.GetTempPath(), "aspire-cli-tests"); + var now = new DateTimeOffset(2026, 02, 12, 18, 00, 00, TimeSpan.Zero); + var timeProvider = new FixedTimeProvider(now); + + var path = RunCommand.GenerateChildLogFilePath(logsDirectory, timeProvider); + var fileName = Path.GetFileName(path); + + Assert.StartsWith(logsDirectory, path, StringComparison.OrdinalIgnoreCase); + Assert.StartsWith("cli_20260212T180000000_detach-child_", fileName, StringComparison.Ordinal); + Assert.EndsWith(".log", fileName, StringComparison.Ordinal); + Assert.DoesNotContain($"_{Environment.ProcessId}", fileName, StringComparison.Ordinal); + } + private sealed class ProjectFileDoesNotExistLocator : Aspire.Cli.Projects.IProjectLocator { public Task UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken) @@ -166,6 +198,11 @@ public Task UseOrFindAppHostProjectFileAsync(FileInf } } + private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => utcNow; + } + private async IAsyncEnumerable ReturnLogEntriesUntilCancelledAsync([EnumeratorCancellation] CancellationToken cancellationToken) { var logEntryIndex = 0; diff --git a/tests/Aspire.Cli.Tests/ProgramTests.cs b/tests/Aspire.Cli.Tests/ProgramTests.cs new file mode 100644 index 00000000000..bcaa4d6b7fa --- /dev/null +++ b/tests/Aspire.Cli.Tests/ProgramTests.cs @@ -0,0 +1,31 @@ +// 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.Tests; + +public class ProgramTests +{ + [Fact] + public void ParseLogFileOption_ReturnsNull_WhenArgsAreNull() + { + var result = Program.ParseLogFileOption(null); + + Assert.Null(result); + } + + [Fact] + public void ParseLogFileOption_ReturnsValue_WhenOptionAppearsBeforeDelimiter() + { + var result = Program.ParseLogFileOption(["run", "--log-file", "cli.log", "--", "--log-file", "app.log"]); + + Assert.Equal("cli.log", result); + } + + [Fact] + public void ParseLogFileOption_IgnoresValue_WhenOptionAppearsAfterDelimiter() + { + var result = Program.ParseLogFileOption(["run", "--", "--log-file", "app.log"]); + + Assert.Null(result); + } +} From 16bd7423f196b8af4933ec3cb8dbd17496455194 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:30:04 +0000 Subject: [PATCH 098/256] [Automated] Update AI Foundry Models (#14368) Co-authored-by: sebastienros --- .../AIFoundryModel.Generated.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs b/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs index 0dad5597109..fe76d906051 100644 --- a/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs +++ b/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs @@ -43,6 +43,11 @@ public static partial class Anthropic /// public static readonly AIFoundryModel ClaudeOpus45 = new() { Name = "claude-opus-4-5", Version = "20251101", Format = "Anthropic" }; + /// + /// Claude Opus 4.6 is the latest version of Anthropic's most intelligent model, and the world's best model for coding, enterprise agents, and professional work. With a 1M token context window (beta) and 128K max output, Opus 4.6 is ideal for production code, + /// + public static readonly AIFoundryModel ClaudeOpus46 = new() { Name = "claude-opus-4-6", Version = "1", Format = "Anthropic" }; + /// /// Claude Sonnet 4.5 is Anthropic's most capable model for complex agents and an industry leader for coding and computer use. /// @@ -220,7 +225,7 @@ public static partial class Meta /// /// The Llama 3.1 instruction tuned text only models are optimized for multilingual dialogue use cases and outperform many of the available open source and closed chat models on common industry benchmarks. /// - public static readonly AIFoundryModel MetaLlama318BInstruct = new() { Name = "Meta-Llama-3.1-8B-Instruct", Version = "5", Format = "Meta" }; + public static readonly AIFoundryModel MetaLlama318BInstruct = new() { Name = "Meta-Llama-3.1-8B-Instruct", Version = "6", Format = "Meta" }; } /// From 27eaa9d0c717de4a04216ad0e23050e67748be66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Fri, 13 Feb 2026 08:50:40 -0800 Subject: [PATCH 099/256] Handle polyglot settings package versions on read path (#14446) * Handle polyglot settings package versions on read path * Address review feedback for polyglot settings handling * Encapsulate project-reference mode in apphost projects * Prevent directory trasversal for release builds * Fix build --- .../ValidationAppHost/.aspire/settings.json | 6 +- .../ValidationAppHost/.aspire/settings.json | 6 +- .../.aspire/settings.json | 6 +- src/Aspire.Cli/Commands/AddCommand.cs | 9 +- src/Aspire.Cli/Commands/UpdateCommand.cs | 13 +- .../Configuration/AspireJsonConfiguration.cs | 58 ++++++--- .../Projects/AppHostServerProject.cs | 39 +----- .../Projects/DotNetAppHostProject.cs | 6 + .../Projects/GuestAppHostProject.cs | 58 +++++---- src/Aspire.Cli/Projects/IAppHostProject.cs | 7 ++ src/Aspire.Cli/Projects/ProjectLocator.cs | 2 +- .../Scaffolding/ScaffoldingService.cs | 13 +- .../Utils/AspireRepositoryDetector.cs | 114 ++++++++++++++++++ .../Projects/GuestAppHostProjectTests.cs | 58 +++++++++ .../TestServices/TestAppHostProjectFactory.cs | 5 + .../Utils/AspireRepositoryDetectorTests.cs | 85 +++++++++++++ tests/Shared/TemporaryRepo.cs | 6 + 17 files changed, 392 insertions(+), 99 deletions(-) create mode 100644 src/Aspire.Cli/Utils/AspireRepositoryDetector.cs create mode 100644 tests/Aspire.Cli.Tests/Utils/AspireRepositoryDetectorTests.cs diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json index 6f25239fe75..13a7ec9205e 100644 --- a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.Storage/ValidationAppHost/.aspire/settings.json @@ -1,9 +1,7 @@ { "appHostPath": "../apphost.ts", "language": "typescript/nodejs", - "channel": "pr-13970", - "sdkVersion": "13.2.0-pr.13970.g9fb24263", "packages": { - "Aspire.Hosting.Azure.Storage": "13.2.0-pr.13970.g9fb24263" + "Aspire.Hosting.Azure.Storage": "" } -} \ No newline at end of file +} diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json index 90e901beee5..780d67e6150 100644 --- a/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json +++ b/playground/polyglot/TypeScript/Aspire.Hosting.RabbitMQ/ValidationAppHost/.aspire/settings.json @@ -1,9 +1,7 @@ { "appHostPath": "../apphost.ts", "language": "typescript/nodejs", - "channel": "local", - "sdkVersion": "13.2.0-preview.1.26081.1", "packages": { - "Aspire.Hosting.RabbitMQ": "13.2.0-preview.1.26081.1" + "Aspire.Hosting.RabbitMQ": "" } -} \ No newline at end of file +} diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json index 32bf312c1cb..640997ec58b 100644 --- a/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json +++ b/playground/polyglot/TypeScript/Aspire.Hosting.SqlServer/.aspire/settings.json @@ -1,9 +1,7 @@ { "appHostPath": "../apphost.ts", "language": "typescript/nodejs", - "channel": "pr-13970", - "sdkVersion": "13.1.0", "packages": { - "Aspire.Hosting.SqlServer": "13.2.0-pr.13970.g0575147c" + "Aspire.Hosting.SqlServer": "" } -} \ No newline at end of file +} diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index a27bdebac36..7a7e1288f69 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -100,8 +100,13 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell string? configuredChannel = null; if (project.LanguageId != KnownLanguageId.CSharp) { - var settings = AspireJsonConfiguration.Load(effectiveAppHostProjectFile.Directory!.FullName); - configuredChannel = settings?.Channel; + var appHostDirectory = effectiveAppHostProjectFile.Directory!.FullName; + var isProjectReferenceMode = AspireRepositoryDetector.DetectRepositoryRoot(appHostDirectory) is not null; + if (!isProjectReferenceMode) + { + var settings = AspireJsonConfiguration.Load(appHostDirectory); + configuredChannel = settings?.Channel; + } } var packagesWithChannels = await InteractionService.ShowStatusAsync( diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 7427721146e..c75e943e38b 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -146,18 +146,26 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.FailedToFindProject; } - var allChannels = await _packagingService.GetChannelsAsync(cancellationToken); + var project = _projectFactory.GetProject(projectFile); + var isProjectReferenceMode = project.IsUsingProjectReferences(projectFile); // Check if channel or quality option was provided (channel takes precedence) var channelName = parseResult.GetValue(_channelOption) ?? parseResult.GetValue(_qualityOption); PackageChannel channel; + var allChannels = await _packagingService.GetChannelsAsync(cancellationToken); + if (!string.IsNullOrEmpty(channelName)) { // Try to find a channel matching the provided channel/quality channel = allChannels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)) ?? throw new ChannelNotFoundException($"No channel found matching '{channelName}'. Valid options are: {string.Join(", ", allChannels.Select(c => c.Name))}"); } + else if (isProjectReferenceMode) + { + channel = allChannels.FirstOrDefault(c => c.Type is PackageChannelType.Implicit) + ?? allChannels.First(); + } else { // If there are hives (PR build directories), prompt for channel selection. @@ -181,8 +189,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } } - // Get the appropriate project handler and update packages - var project = _projectFactory.GetProject(projectFile); + // Update packages using the appropriate project handler var updateContext = new UpdatePackagesContext { AppHostFile = projectFile, diff --git a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs index 3afeaca8846..3b25cc8785d 100644 --- a/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs +++ b/src/Aspire.Cli/Configuration/AspireJsonConfiguration.cs @@ -163,32 +163,56 @@ public bool RemovePackage(string packageId) } /// - /// Gets all package references including the base Aspire.Hosting packages. - /// Uses the SdkVersion for base packages. - /// Note: Aspire.Hosting.AppHost is an SDK package (not a runtime DLL) and is excluded. + /// Gets the effective SDK version for package-based AppHost preparation. + /// Falls back to when no SDK version is configured. + /// + public string GetEffectiveSdkVersion(string defaultSdkVersion) + { + return string.IsNullOrWhiteSpace(SdkVersion) ? defaultSdkVersion : SdkVersion; + } + + /// + /// Gets all package references including the base Aspire.Hosting package. + /// Empty package versions in settings are resolved to the effective SDK version. /// + /// Default SDK version to use when not configured. /// Enumerable of (PackageName, Version) tuples. - public IEnumerable<(string Name, string Version)> GetAllPackages() + public IEnumerable<(string Name, string Version)> GetAllPackages(string defaultSdkVersion) { - var sdkVersion = SdkVersion ?? throw new InvalidOperationException("SdkVersion must be set before calling GetAllPackages. Use LoadOrCreate to ensure it's set."); + var sdkVersion = GetEffectiveSdkVersion(defaultSdkVersion); - // Base packages always included (Aspire.Hosting.AppHost is an SDK, not a runtime DLL) + // Base package always included (Aspire.Hosting.AppHost is an SDK, not a runtime DLL) yield return ("Aspire.Hosting", sdkVersion); - // Additional packages from settings - if (Packages is not null) + if (Packages is null) + { + yield break; + } + + foreach (var (packageName, version) in Packages) { - foreach (var (packageName, version) in Packages) + // Skip base packages and SDK-only packages + if (string.Equals(packageName, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase) || + string.Equals(packageName, "Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase)) { - // Skip base packages and SDK-only packages - if (string.Equals(packageName, "Aspire.Hosting", StringComparison.OrdinalIgnoreCase) || - string.Equals(packageName, "Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - yield return (packageName, version); + continue; } + + yield return (packageName, string.IsNullOrWhiteSpace(version) ? sdkVersion : version); } } + + /// + /// Gets all package references including the base Aspire.Hosting packages. + /// Uses the SdkVersion for base packages. + /// Note: Aspire.Hosting.AppHost is an SDK package (not a runtime DLL) and is excluded. + /// + /// Enumerable of (PackageName, Version) tuples. + public IEnumerable<(string Name, string Version)> GetAllPackages() + { + var sdkVersion = !string.IsNullOrWhiteSpace(SdkVersion) + ? SdkVersion + : throw new InvalidOperationException("SdkVersion must be set to a non-empty value before calling GetAllPackages. Use LoadOrCreate to ensure it's set."); + return GetAllPackages(sdkVersion); + } } diff --git a/src/Aspire.Cli/Projects/AppHostServerProject.cs b/src/Aspire.Cli/Projects/AppHostServerProject.cs index df3784ea968..c864bfb0835 100644 --- a/src/Aspire.Cli/Projects/AppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/AppHostServerProject.cs @@ -8,6 +8,7 @@ using Aspire.Cli.DotNet; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; +using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Projects; @@ -58,7 +59,7 @@ public async Task CreateAsync(string appPath, Cancellatio } // Priority 1: Check for dev mode (ASPIRE_REPO_ROOT or running from Aspire source repo) - var repoRoot = DetectAspireRepoRoot(); + var repoRoot = AspireRepositoryDetector.DetectRepositoryRoot(appPath); if (repoRoot is not null) { return new DotNetBasedAppHostServerProject( @@ -91,40 +92,4 @@ public async Task CreateAsync(string appPath, Cancellatio "No Aspire AppHost server is available. Ensure the Aspire CLI is installed " + "with a valid bundle layout, or reinstall using 'aspire setup --force'."); } - - /// - /// Detects the Aspire repository root for dev mode. - /// Checks ASPIRE_REPO_ROOT env var first, then walks up from the CLI executable - /// looking for a git repo containing Aspire.slnx. - /// - private static string? DetectAspireRepoRoot() - { - // Check explicit environment variable - var envRoot = Environment.GetEnvironmentVariable("ASPIRE_REPO_ROOT"); - if (!string.IsNullOrEmpty(envRoot) && Directory.Exists(envRoot)) - { - return envRoot; - } - - // Auto-detect: walk up from the CLI executable looking for .git + Aspire.slnx - var cliPath = Environment.ProcessPath; - if (string.IsNullOrEmpty(cliPath)) - { - return null; - } - - var dir = Path.GetDirectoryName(cliPath); - while (dir is not null) - { - if (Directory.Exists(Path.Combine(dir, ".git")) && - File.Exists(Path.Combine(dir, "Aspire.slnx"))) - { - return dir; - } - - dir = Path.GetDirectoryName(dir); - } - - return null; - } } diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index 7ee32718103..ea56782b2b0 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -139,6 +139,12 @@ private static bool IsValidSingleFileAppHost(FileInfo candidateFile) /// public string? AppHostFileName => "apphost.cs"; + /// + public bool IsUsingProjectReferences(FileInfo appHostFile) + { + return false; + } + // ═══════════════════════════════════════════════════════════════ // EXECUTION // ═══════════════════════════════════════════════════════════════ diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 1ce14fd6e12..4fa1af18f8f 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -128,6 +128,12 @@ public bool CanHandle(FileInfo appHostFile) /// public string? AppHostFileName => _resolvedLanguage.DetectionPatterns.FirstOrDefault(); + /// + public bool IsUsingProjectReferences(FileInfo appHostFile) + { + return AspireRepositoryDetector.DetectRepositoryRoot(appHostFile.Directory?.FullName) is not null; + } + /// /// Gets all packages including the code generation package for the current language. /// @@ -135,15 +141,28 @@ public bool CanHandle(FileInfo appHostFile) AspireJsonConfiguration config, CancellationToken cancellationToken) { - var packages = config.GetAllPackages().ToList(); + var defaultSdkVersion = GetEffectiveSdkVersion(); + var packages = config.GetAllPackages(defaultSdkVersion).ToList(); var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(_resolvedLanguage.LanguageId, cancellationToken); if (codeGenPackage is not null) { - packages.Add((codeGenPackage, config.SdkVersion!)); + var codeGenVersion = config.GetEffectiveSdkVersion(defaultSdkVersion); + packages.Add((codeGenPackage, codeGenVersion)); } return packages; } + private AspireJsonConfiguration LoadConfiguration(DirectoryInfo directory) + { + var effectiveSdkVersion = GetEffectiveSdkVersion(); + return AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); + } + + private string GetPrepareSdkVersion(AspireJsonConfiguration config) + { + return config.GetEffectiveSdkVersion(GetEffectiveSdkVersion()); + } + /// /// Prepares the AppHost server (creates files and builds for dev mode, restores packages for prebuilt mode). /// @@ -162,14 +181,14 @@ public bool CanHandle(FileInfo appHostFile) /// private async Task BuildAndGenerateSdkAsync(DirectoryInfo directory, CancellationToken cancellationToken) { + var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); + // Step 1: Load config - source of truth for SDK version and packages - var effectiveSdkVersion = GetEffectiveSdkVersion(); - var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); + var config = LoadConfiguration(directory); var packages = await GetAllPackagesAsync(config, cancellationToken); + var sdkVersion = GetPrepareSdkVersion(config); - var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); - - var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); + var (buildSuccess, buildOutput, _, _) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); if (!buildSuccess) { if (buildOutput is not null) @@ -269,19 +288,19 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken } // Build phase: build AppHost server (dependency install happens after server starts) + var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); + // Load config - source of truth for SDK version and packages - var effectiveSdkVersion = GetEffectiveSdkVersion(); - var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); + var config = LoadConfiguration(directory); var packages = await GetAllPackagesAsync(config, cancellationToken); - - var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); + var sdkVersion = GetPrepareSdkVersion(config); var buildResult = await _interactionService.ShowStatusAsync( ":gear: Preparing Aspire server...", async () => { // Prepare the AppHost server (build for dev mode, restore for prebuilt) - var (prepareSuccess, prepareOutput, channelName, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); + var (prepareSuccess, prepareOutput, channelName, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); if (!prepareSuccess) { return (Success: false, Output: prepareOutput, Error: "Failed to prepare app host.", ChannelName: (string?)null, NeedsCodeGen: false); @@ -557,14 +576,13 @@ public async Task PublishAsync(PublishContext context, CancellationToken ca try { // Step 1: Load config - source of truth for SDK version and packages - var effectiveSdkVersion = GetEffectiveSdkVersion(); - var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); - var packages = await GetAllPackagesAsync(config, cancellationToken); - var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); + var config = LoadConfiguration(directory); + var packages = await GetAllPackagesAsync(config, cancellationToken); + var sdkVersion = GetPrepareSdkVersion(config); // Prepare the AppHost server (build for dev mode, restore for prebuilt) - var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, config.SdkVersion!, packages, cancellationToken); + var (prepareSuccess, prepareOutput, _, needsCodeGen) = await PrepareAppHostServerAsync(appHostServerProject, sdkVersion, packages, cancellationToken); if (!prepareSuccess) { // Set OutputCollector so PipelineCommandBase can display errors @@ -802,8 +820,7 @@ public async Task AddPackageAsync(AddPackageContext context, CancellationT } // Load config - source of truth for SDK version and packages - var effectiveSdkVersion = GetEffectiveSdkVersion(); - var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); + var config = LoadConfiguration(directory); // Update .aspire/settings.json with the new package config.AddOrUpdatePackage(context.PackageId, context.PackageVersion); @@ -825,8 +842,7 @@ public async Task UpdatePackagesAsync(UpdatePackagesContex } // Load config - source of truth for SDK version and packages - var effectiveSdkVersion = GetEffectiveSdkVersion(); - var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, effectiveSdkVersion); + var config = LoadConfiguration(directory); // Find updates for SDK version and packages string? newSdkVersion = null; diff --git a/src/Aspire.Cli/Projects/IAppHostProject.cs b/src/Aspire.Cli/Projects/IAppHostProject.cs index 5a747b65b4a..f2d7b59a1f8 100644 --- a/src/Aspire.Cli/Projects/IAppHostProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostProject.cs @@ -166,6 +166,13 @@ internal interface IAppHostProject /// string? AppHostFileName { get; } + /// + /// Determines whether this AppHost should use project references instead of package references. + /// + /// The AppHost file being operated on. + /// when project-reference mode should be used; otherwise . + bool IsUsingProjectReferences(FileInfo appHostFile); + /// /// Runs the AppHost project. /// diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 6d68a96a1a7..43d4f455a8e 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -314,7 +314,7 @@ private async Task CreateSettingsFileAsync(FileInfo projectFile, CancellationTok if (language is not null && !language.LanguageId.Value.Equals(KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase)) { await configurationService.SetConfigurationAsync("language", language.LanguageId.Value, isGlobal: false, cancellationToken); - + // Inherit SDK version from parent/global config if available var inheritedSdkVersion = await configurationService.GetConfigurationAsync("sdkVersion", cancellationToken); if (!string.IsNullOrEmpty(inheritedSdkVersion)) diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index 4dfcb1fe116..35c05a11d96 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -55,25 +55,25 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Cancellat var directory = context.TargetDirectory; var language = context.Language; - // Step 1: Resolve SDK version from channel (if configured) or use default + // Step 1: Resolve SDK and package strategy var sdkVersion = await ResolveSdkVersionAsync(cancellationToken); - - // Load or create config with resolved SDK version var config = AspireJsonConfiguration.LoadOrCreate(directory.FullName, sdkVersion); // Include the code generation package for scaffolding and code gen var codeGenPackage = await _languageDiscovery.GetPackageForLanguageAsync(language.LanguageId, cancellationToken); - var packages = config.GetAllPackages().ToList(); + var packages = config.GetAllPackages(sdkVersion).ToList(); if (codeGenPackage is not null) { - packages.Add((codeGenPackage, config.SdkVersion!)); + var codeGenVersion = config.GetEffectiveSdkVersion(sdkVersion); + packages.Add((codeGenPackage, codeGenVersion)); } var appHostServerProject = await _appHostServerProjectFactory.CreateAsync(directory.FullName, cancellationToken); + var prepareSdkVersion = config.GetEffectiveSdkVersion(sdkVersion); var prepareResult = await _interactionService.ShowStatusAsync( ":gear: Preparing Aspire server...", - () => appHostServerProject.PrepareAsync(config.SdkVersion!, packages, cancellationToken)); + () => appHostServerProject.PrepareAsync(prepareSdkVersion, packages, cancellationToken)); if (!prepareResult.Success) { if (prepareResult.Output is not null) @@ -134,6 +134,7 @@ await GenerateCodeViaRpcAsync( { config.Channel = prepareResult.ChannelName; } + config.Language = language.LanguageId; config.Save(directory.FullName); } diff --git a/src/Aspire.Cli/Utils/AspireRepositoryDetector.cs b/src/Aspire.Cli/Utils/AspireRepositoryDetector.cs new file mode 100644 index 00000000000..5f27b54d333 --- /dev/null +++ b/src/Aspire.Cli/Utils/AspireRepositoryDetector.cs @@ -0,0 +1,114 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Shared; + +namespace Aspire.Cli.Utils; + +internal static class AspireRepositoryDetector +{ +#if DEBUG + private const string AspireSolutionFileName = "Aspire.slnx"; + + private static string? s_cachedRepoRoot; + private static bool s_cacheInitialized; +#endif + + public static string? DetectRepositoryRoot(string? startPath = null) + { +#if !DEBUG + // In release builds, only check the environment variable to avoid + // filesystem walking on every call in production scenarios. + var envRoot = Environment.GetEnvironmentVariable(BundleDiscovery.RepoRootEnvVar); + if (!string.IsNullOrEmpty(envRoot) && Directory.Exists(envRoot)) + { + return Path.GetFullPath(envRoot); + } + + return null; +#else + if (s_cacheInitialized) + { + return s_cachedRepoRoot; + } + + s_cachedRepoRoot = DetectRepositoryRootCore(startPath); + s_cacheInitialized = true; + return s_cachedRepoRoot; +#endif + } + +#if DEBUG + internal static void ResetCache() + { + s_cachedRepoRoot = null; + s_cacheInitialized = false; + } + + private static string? DetectRepositoryRootCore(string? startPath) + { + var repoRoot = FindRepositoryRoot(startPath); + if (!string.IsNullOrEmpty(repoRoot)) + { + return repoRoot; + } + + var envRoot = Environment.GetEnvironmentVariable(BundleDiscovery.RepoRootEnvVar); + if (!string.IsNullOrEmpty(envRoot) && Directory.Exists(envRoot)) + { + return Path.GetFullPath(envRoot); + } + + var processPath = Environment.ProcessPath; + if (!string.IsNullOrEmpty(processPath)) + { + repoRoot = FindRepositoryRoot(Path.GetDirectoryName(processPath)); + if (!string.IsNullOrEmpty(repoRoot)) + { + return repoRoot; + } + } + + return null; + } + + private static string? FindRepositoryRoot(string? startPath) + { + if (string.IsNullOrEmpty(startPath)) + { + return null; + } + + var currentDirectory = ResolveSearchDirectory(startPath); + while (!string.IsNullOrEmpty(currentDirectory)) + { + if (File.Exists(Path.Combine(currentDirectory, AspireSolutionFileName))) + { + return currentDirectory; + } + + currentDirectory = Directory.GetParent(currentDirectory)?.FullName; + } + + return null; + } + + private static string ResolveSearchDirectory(string path) + { + var fullPath = Path.GetFullPath(path); + + if (Directory.Exists(fullPath)) + { + return fullPath; + } + + if (File.Exists(fullPath)) + { + return Path.GetDirectoryName(fullPath)!; + } + + var parentDirectory = Path.GetDirectoryName(fullPath); + return string.IsNullOrEmpty(parentDirectory) ? fullPath : parentDirectory; + } +#endif +} diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 36db392a5ba..876be8d1451 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -165,6 +165,64 @@ public void AspireJsonConfiguration_GetAllPackages_WithNoExplicitPackages_Return Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); } + [Fact] + public void AspireJsonConfiguration_GetAllPackages_WithWhitespaceSdkVersion_Throws() + { + var config = new AspireJsonConfiguration + { + SdkVersion = " ", + Language = "typescript" + }; + + var exception = Assert.Throws(() => config.GetAllPackages().ToList()); + + Assert.Contains("non-empty", exception.Message); + } + + [Fact] + public void AspireJsonConfiguration_GetAllPackages_WithDefaultSdkVersion_UsesFallbackVersion() + { + // Arrange + var config = new AspireJsonConfiguration + { + Language = "typescript", + Packages = new Dictionary + { + ["Aspire.Hosting.Redis"] = string.Empty + } + }; + + // Act + var packages = config.GetAllPackages("13.1.0").ToList(); + + // Assert + Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); + Assert.Contains(packages, p => p.Name == "Aspire.Hosting.Redis" && p.Version == "13.1.0"); + } + + [Fact] + public void AspireJsonConfiguration_GetAllPackages_WithConfiguredSdkVersion_ReturnsConfiguredVersions() + { + // Arrange + var config = new AspireJsonConfiguration + { + SdkVersion = "13.1.0", + Language = "typescript", + Channel = "daily", + Packages = new Dictionary + { + ["Aspire.Hosting.Redis"] = "13.1.0" + } + }; + + // Act + var packages = config.GetAllPackages("13.1.0").ToList(); + + // Assert + Assert.Contains(packages, p => p.Name == "Aspire.Hosting" && p.Version == "13.1.0"); + Assert.Contains(packages, p => p.Name == "Aspire.Hosting.Redis" && p.Version == "13.1.0"); + } + [Fact] public void AspireJsonConfiguration_Save_PreservesExtensionData() { diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs index bc0b7d20918..3c99f10bc7c 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs @@ -117,6 +117,11 @@ public TestAppHostProject(TestAppHostProjectFactory factory) public string DisplayName => "C# (.NET)"; public string? AppHostFileName => "AppHost.csproj"; + public bool IsUsingProjectReferences(FileInfo appHostFile) + { + return false; + } + public Task GetDetectionPatternsAsync(CancellationToken cancellationToken) => Task.FromResult(s_detectionPatterns); diff --git a/tests/Aspire.Cli.Tests/Utils/AspireRepositoryDetectorTests.cs b/tests/Aspire.Cli.Tests/Utils/AspireRepositoryDetectorTests.cs new file mode 100644 index 00000000000..ed8e40806cc --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/AspireRepositoryDetectorTests.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if DEBUG + +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Tests.Utils; + +public class AspireRepositoryDetectorTests : IDisposable +{ + private const string RepoRootEnvironmentVariableName = "ASPIRE_REPO_ROOT"; + private readonly List _directoriesToDelete = []; + private readonly string? _originalRepoRoot = Environment.GetEnvironmentVariable(RepoRootEnvironmentVariableName); + + public AspireRepositoryDetectorTests() + { + AspireRepositoryDetector.ResetCache(); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(RepoRootEnvironmentVariableName, _originalRepoRoot); + AspireRepositoryDetector.ResetCache(); + + foreach (var directory in _directoriesToDelete) + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + } + + [Fact] + public void DetectRepositoryRoot_ReturnsDirectoryContainingAspireSolution() + { + var repoRoot = CreateTempDirectory(); + File.WriteAllText(Path.Combine(repoRoot, "Aspire.slnx"), string.Empty); + + var nestedDirectory = Directory.CreateDirectory(Path.Combine(repoRoot, "src", "Project")).FullName; + + var detectedRoot = AspireRepositoryDetector.DetectRepositoryRoot(nestedDirectory); + + Assert.Equal(repoRoot, detectedRoot); + } + + [Fact] + public void DetectRepositoryRoot_UsesEnvironmentVariable_WhenNoSolutionFound() + { + var repoRoot = CreateTempDirectory(); + var workingDirectory = CreateTempDirectory(); + + Environment.SetEnvironmentVariable(RepoRootEnvironmentVariableName, repoRoot); + + var detectedRoot = AspireRepositoryDetector.DetectRepositoryRoot(workingDirectory); + + Assert.Equal(repoRoot, detectedRoot); + } + + [Fact] + public void DetectRepositoryRoot_PrefersSolutionSearchOverEnvironmentVariable() + { + var repoRoot = CreateTempDirectory(); + File.WriteAllText(Path.Combine(repoRoot, "Aspire.slnx"), string.Empty); + + var envRoot = CreateTempDirectory(); + Environment.SetEnvironmentVariable(RepoRootEnvironmentVariableName, envRoot); + + var nestedDirectory = Directory.CreateDirectory(Path.Combine(repoRoot, "playground", "polyglot")).FullName; + + var detectedRoot = AspireRepositoryDetector.DetectRepositoryRoot(nestedDirectory); + + Assert.Equal(repoRoot, detectedRoot); + } + + private string CreateTempDirectory() + { + var directory = Directory.CreateTempSubdirectory("aspire-repo-detector-tests-").FullName; + _directoriesToDelete.Add(directory); + return directory; + } +} + +#endif diff --git a/tests/Shared/TemporaryRepo.cs b/tests/Shared/TemporaryRepo.cs index ffe7c0d9352..e61ff2d4c48 100644 --- a/tests/Shared/TemporaryRepo.cs +++ b/tests/Shared/TemporaryRepo.cs @@ -62,6 +62,12 @@ internal static TemporaryWorkspace Create(ITestOutputHelper outputHelper) var repoDirectory = Directory.CreateDirectory(path); outputHelper.WriteLine($"Temporary workspace created at: {repoDirectory.FullName}"); + // Create an empty settings file so directory-walking searches + // (ConfigurationHelper, ConfigurationService) stop here instead + // of finding the user's actual ~/.aspire/settings.json. + var aspireDir = Directory.CreateDirectory(Path.Combine(path, ".aspire")); + File.WriteAllText(Path.Combine(aspireDir.FullName, "settings.json"), "{}"); + return new TemporaryWorkspace(outputHelper, repoDirectory); } } From 8df3c85b3b6086bde09bfdaf1fab044674b03d34 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 13 Feb 2026 09:24:06 -0800 Subject: [PATCH 100/256] Remove auto-merge step from backmerge workflow (#14481) * Remove auto-merge step from backmerge workflow * Update PR body to request merge commit instead of auto-merge --- .github/workflows/backmerge-release.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/backmerge-release.yml b/.github/workflows/backmerge-release.yml index b8c92f0d4fc..0e530c49dfc 100644 --- a/.github/workflows/backmerge-release.yml +++ b/.github/workflows/backmerge-release.yml @@ -77,7 +77,7 @@ jobs: **Commits to merge:** ${{ steps.check.outputs.behind_count }} This PR was created automatically to keep \`main\` up-to-date with release branch changes. - Once approved, it will auto-merge. + Once approved, please merge using a **merge commit** (not squash or rebase). --- *This PR was generated by the [backmerge-release](${{ github.server_url }}/${{ github.repository }}/actions/workflows/backmerge-release.yml) workflow.*" @@ -102,13 +102,6 @@ jobs: echo "Created PR #$PR_NUMBER" fi - - name: Enable auto-merge - if: steps.create-pr.outputs.pull_request_number - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh pr merge ${{ steps.create-pr.outputs.pull_request_number }} --auto --merge - - name: Create issue for merge conflicts if: steps.check.outputs.changes == 'true' && steps.merge.outputs.merge_success == 'false' uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 From e758ef2c2553690e2f4e5413678d3b9eef13b3b5 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 13 Feb 2026 09:27:18 -0800 Subject: [PATCH 101/256] Use pinned CLI version for staging channel in shared feed mode (#14469) * Use --exact-match in dotnet package search when stagingVersionPrefix is set dotnet package search only returns the latest version per package ID, so when the shared dotnet9 feed has both 13.2 and 13.3 prerelease packages, only 13.3 is returned. The stagingVersionPrefix filter then discards it, resulting in "no templates found". When VersionPrefix is set on a channel, pass --exact-match to get all versions from the feed, enabling the prefix filter to find the correct version line. Also update ParsePackageSearchResults to handle the "version" JSON field used by --exact-match (vs "latestVersion" in normal search). * Use pinned CLI version for staging channel in shared feed mode When the staging channel is configured with Prerelease quality and no explicit feed override, packages are now pinned to the CLI's own version instead of searching NuGet. This avoids the dotnet package search limitation where only the latest version per package ID is returned, which caused version mismatches when the shared feed contains packages from multiple version lines (e.g. 13.2.x and 13.3.x). New config flag stagingPinToCliVersion (boolean) controls this behavior. When enabled alongside overrideStagingQuality=Prerelease: - Templates (aspire new): synthetic result with CLI version - Integrations (aspire add): discovers packages then overrides version - Specific packages (aspire update): synthetic result with CLI version Also cleans up reverted exact-match parameter plumbing from interfaces and test fakes. * Mark staging override settings as internal in JSON schemas * Add E2E test for staging channel config and channel switching * Fix E2E test: use correct global settings path (~/.aspire/globalsettings.json) * Fix E2E test: config get doesn't support -g flag --- .../aspire-global-settings.schema.json | 14 +- extension/schemas/aspire-settings.schema.json | 14 +- src/Aspire.Cli/Packaging/PackageChannel.cs | 43 ++-- src/Aspire.Cli/Packaging/PackagingService.cs | 23 +- src/Shared/PackageUpdateHelpers.cs | 4 +- .../StagingChannelTests.cs | 172 ++++++++++++++ .../Packaging/PackagingServiceTests.cs | 209 ++++++++++++++++-- .../CliUpdateNotificationServiceTests.cs | 2 +- 8 files changed, 425 insertions(+), 56 deletions(-) create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs diff --git a/extension/schemas/aspire-global-settings.schema.json b/extension/schemas/aspire-global-settings.schema.json index 8f7a8db0414..bf1ec2494b1 100644 --- a/extension/schemas/aspire-global-settings.schema.json +++ b/extension/schemas/aspire-global-settings.schema.json @@ -296,11 +296,11 @@ "type": "string" }, "overrideStagingFeed": { - "description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", + "description": "[Internal] Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", "type": "string" }, "overrideStagingQuality": { - "description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", + "description": "[Internal] Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", "type": "string", "enum": [ "Stable", @@ -308,9 +308,13 @@ "Both" ] }, - "stagingVersionPrefix": { - "description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.", - "type": "string" + "stagingPinToCliVersion": { + "description": "[Internal] When set to \"true\" and using the staging channel with Prerelease quality on the shared feed, all template and integration packages are pinned to the exact version of the installed CLI. This bypasses NuGet search entirely, ensuring version consistency.", + "type": "string", + "enum": [ + "true", + "false" + ] } }, "additionalProperties": false diff --git a/extension/schemas/aspire-settings.schema.json b/extension/schemas/aspire-settings.schema.json index c2da807d981..bd71628f932 100644 --- a/extension/schemas/aspire-settings.schema.json +++ b/extension/schemas/aspire-settings.schema.json @@ -300,11 +300,11 @@ "type": "string" }, "overrideStagingFeed": { - "description": "Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", + "description": "[Internal] Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", "type": "string" }, "overrideStagingQuality": { - "description": "Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", + "description": "[Internal] Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", "type": "string", "enum": [ "Stable", @@ -312,9 +312,13 @@ "Both" ] }, - "stagingVersionPrefix": { - "description": "Filter staging channel packages to a specific Major.Minor version (e.g., \"13.2\"). When set, only packages matching this version prefix are shown, preventing newer daily versions from being selected.", - "type": "string" + "stagingPinToCliVersion": { + "description": "[Internal] When set to \"true\" and using the staging channel with Prerelease quality on the shared feed, all template and integration packages are pinned to the exact version of the installed CLI. This bypasses NuGet search entirely, ensuring version consistency.", + "type": "string", + "enum": [ + "true", + "false" + ] } }, "additionalProperties": false diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 8152e83ca9c..5bcd3bb2071 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -8,7 +8,7 @@ namespace Aspire.Cli.Packaging; -internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null) +internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null) { public string Name { get; } = name; public PackageChannelQuality Quality { get; } = quality; @@ -16,20 +16,10 @@ internal class PackageChannel(string name, PackageChannelQuality quality, Packag public PackageChannelType Type { get; } = mappings is null ? PackageChannelType.Implicit : PackageChannelType.Explicit; public bool ConfigureGlobalPackagesFolder { get; } = configureGlobalPackagesFolder; public string? CliDownloadBaseUrl { get; } = cliDownloadBaseUrl; - public SemVersion? VersionPrefix { get; } = versionPrefix; + public string? PinnedVersion { get; } = pinnedVersion; public string SourceDetails { get; } = ComputeSourceDetails(mappings); - private bool MatchesVersionPrefix(SemVersion semVer) - { - if (VersionPrefix is null) - { - return true; - } - - return semVer.Major == VersionPrefix.Major && semVer.Minor == VersionPrefix.Minor; - } - private static string ComputeSourceDetails(PackageMapping[]? mappings) { if (mappings is null) @@ -52,6 +42,11 @@ private static string ComputeSourceDetails(PackageMapping[]? mappings) public async Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken) { + if (PinnedVersion is not null) + { + return [new NuGetPackage { Id = "Aspire.ProjectTemplates", Version = PinnedVersion, Source = SourceDetails }]; + } + var tasks = new List>>(); using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null; @@ -80,7 +75,7 @@ public async Task> GetTemplatePackagesAsync(DirectoryI { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + }); return filteredPackages; } @@ -115,13 +110,25 @@ public async Task> GetIntegrationPackagesAsync(Directo { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + }); + + // When pinned to a specific version, override the version on each discovered package + // so the correct version gets installed regardless of what the feed reports as latest. + if (PinnedVersion is not null) + { + return filteredPackages.Select(p => new NuGetPackage { Id = p.Id, Version = PinnedVersion, Source = p.Source }); + } return filteredPackages; } public async Task> GetPackagesAsync(string packageId, DirectoryInfo workingDirectory, CancellationToken cancellationToken) { + if (PinnedVersion is not null) + { + return [new NuGetPackage { Id = packageId, Version = PinnedVersion, Source = SourceDetails }]; + } + var tasks = new List>>(); using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null; @@ -170,7 +177,7 @@ public async Task> GetPackagesAsync(string packageId, useCache: true, // Enable caching for package channel resolution cancellationToken: cancellationToken); - return packages.Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + return packages; } // When doing a `dotnet package search` the the results may include stable packages even when searching for @@ -181,14 +188,14 @@ public async Task> GetPackagesAsync(string packageId, { Quality: PackageChannelQuality.Stable, SemVer: { IsPrerelease: false } } => true, { Quality: PackageChannelQuality.Prerelease, SemVer: { IsPrerelease: true } } => true, _ => false - }).Where(p => MatchesVersionPrefix(SemVersion.Parse(p.Version))); + }); return filteredPackages; } - public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, SemVersion? versionPrefix = null) + public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null) { - return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, versionPrefix); + return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, pinnedVersion); } public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache) diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index 7bde39025b0..d1e91bc06d4 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -4,7 +4,6 @@ using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; using Microsoft.Extensions.Configuration; -using Semver; using System.Reflection; namespace Aspire.Cli.Packaging; @@ -92,13 +91,13 @@ public Task> GetChannelsAsync(CancellationToken canc return null; } - var versionPrefix = GetStagingVersionPrefix(); + var pinnedVersion = GetStagingPinnedVersion(useSharedFeed); var stagingChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, stagingQuality, new[] { new PackageMapping("Aspire*", stagingFeedUrl), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", versionPrefix: versionPrefix); + }, nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", pinnedVersion: pinnedVersion); return stagingChannel; } @@ -166,20 +165,18 @@ private PackageChannelQuality GetStagingQuality() return PackageChannelQuality.Stable; } - private SemVersion? GetStagingVersionPrefix() + private string? GetStagingPinnedVersion(bool useSharedFeed) { - var versionPrefixValue = configuration["stagingVersionPrefix"]; - if (string.IsNullOrEmpty(versionPrefixValue)) + // Only pin versions when using the shared feed and the config flag is set + var pinToCliVersion = configuration["stagingPinToCliVersion"]; + if (!useSharedFeed || !string.Equals(pinToCliVersion, "true", StringComparison.OrdinalIgnoreCase)) { return null; } - // Parse "Major.Minor" format (e.g., "13.2") as a SemVersion for comparison - if (SemVersion.TryParse($"{versionPrefixValue}.0", SemVersionStyles.Strict, out var semVersion)) - { - return semVersion; - } - - return null; + // Get the CLI's own version and strip build metadata (+hash) + var cliVersion = Utils.VersionHelper.GetDefaultTemplateVersion(); + var plusIndex = cliVersion.IndexOf('+'); + return plusIndex >= 0 ? cliVersion[..plusIndex] : cliVersion; } } diff --git a/src/Shared/PackageUpdateHelpers.cs b/src/Shared/PackageUpdateHelpers.cs index 99ff9181778..d5335b94750 100644 --- a/src/Shared/PackageUpdateHelpers.cs +++ b/src/Shared/PackageUpdateHelpers.cs @@ -144,7 +144,9 @@ public static List ParsePackageSearchResults(string stdout, string { var id = packageResult.GetProperty("id").GetString()!; - var version = packageResult.GetProperty("latestVersion").GetString()!; + var version = packageResult.TryGetProperty("latestVersion", out var latestVersionProp) + ? latestVersionProp.GetString()! + : packageResult.GetProperty("version").GetString()!; if (packageId == null || id == packageId) { diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs new file mode 100644 index 00000000000..14498306c9d --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs @@ -0,0 +1,172 @@ +// 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; +using Aspire.Cli.Tests.Utils; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end tests for staging channel configuration and self-update channel switching. +/// Verifies that staging settings (overrideStagingQuality, stagingPinToCliVersion) are +/// correctly persisted and that aspire update --self saves the channel to global settings. +/// +public sealed class StagingChannelTests(ITestOutputHelper output) +{ + [Fact] + public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Step 1: Configure staging channel settings via aspire config set + // Enable the staging channel feature flag + sequenceBuilder + .Type("aspire config set features.stagingChannelEnabled true -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Set quality to Prerelease (triggers shared feed mode) + sequenceBuilder + .Type("aspire config set overrideStagingQuality Prerelease -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Enable pinned version mode + sequenceBuilder + .Type("aspire config set stagingPinToCliVersion true -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Set channel to staging + sequenceBuilder + .Type("aspire config set channel staging -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 2: Verify the settings were persisted in the global settings file + var settingsFilePattern = new CellPatternSearcher() + .Find("stagingPinToCliVersion"); + + sequenceBuilder + .ClearScreen(counter) + .Type("cat ~/.aspire/globalsettings.json") + .Enter() + .WaitUntil(s => settingsFilePattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Step 3: Verify aspire config get returns the correct values + var stagingChannelPattern = new CellPatternSearcher() + .Find("staging"); + + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get channel") + .Enter() + .WaitUntil(s => stagingChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Step 4: Verify the CLI version is available (basic smoke test that the CLI works with these settings) + sequenceBuilder + .ClearScreen(counter) + .Type("aspire --version") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5: Switch channel to stable via config set (simulating what update --self does) + sequenceBuilder + .Type("aspire config set channel stable -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 6: Verify channel was changed to stable + var stableChannelPattern = new CellPatternSearcher() + .Find("stable"); + + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get channel") + .Enter() + .WaitUntil(s => stableChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Step 7: Switch back to staging + sequenceBuilder + .Type("aspire config set channel staging -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 8: Verify channel is staging again and staging settings are still present + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get channel") + .Enter() + .WaitUntil(s => stagingChannelPattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Verify the staging-specific settings survived the channel switch + var prereleasePattern = new CellPatternSearcher() + .Find("Prerelease"); + + sequenceBuilder + .ClearScreen(counter) + .Type("aspire config get overrideStagingQuality") + .Enter() + .WaitUntil(s => prereleasePattern.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .WaitForSuccessPrompt(counter); + + // Clean up: remove staging settings to avoid polluting other tests + sequenceBuilder + .Type("aspire config delete features.stagingChannelEnabled -g") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("aspire config delete overrideStagingQuality -g") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("aspire config delete stagingPinToCliVersion -g") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("aspire config delete channel -g") + .Enter() + .WaitForSuccessPrompt(counter); + + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 74b71823ea7..51ce56b6907 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -637,7 +637,7 @@ public async Task NuGetConfigMerger_WhenStagingUsesSharedFeed_DoesNotAddGlobalPa } [Fact] - public async Task GetChannelsAsync_WhenStagingVersionPrefixSet_ChannelHasVersionPrefix() + public async Task GetChannelsAsync_WhenStagingPinToCliVersionSet_ChannelHasPinnedVersion() { // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -652,8 +652,8 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixSet_ChannelHasVersion var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json", - ["stagingVersionPrefix"] = "13.2" + ["overrideStagingQuality"] = "Prerelease", + ["stagingPinToCliVersion"] = "true" }) .Build(); @@ -664,13 +664,13 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixSet_ChannelHasVersion // Assert var stagingChannel = channels.First(c => c.Name == "staging"); - Assert.NotNull(stagingChannel.VersionPrefix); - Assert.Equal(13, stagingChannel.VersionPrefix!.Major); - Assert.Equal(2, stagingChannel.VersionPrefix.Minor); + Assert.NotNull(stagingChannel.PinnedVersion); + // Should not contain build metadata (+hash) + Assert.DoesNotContain("+", stagingChannel.PinnedVersion); } [Fact] - public async Task GetChannelsAsync_WhenStagingVersionPrefixNotSet_ChannelHasNoVersionPrefix() + public async Task GetChannelsAsync_WhenStagingPinToCliVersionNotSet_ChannelHasNoPinnedVersion() { // Arrange using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -685,7 +685,8 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixNotSet_ChannelHasNoVe var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json" + ["overrideStagingQuality"] = "Prerelease" + // No stagingPinToCliVersion }) .Build(); @@ -696,13 +697,13 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixNotSet_ChannelHasNoVe // Assert var stagingChannel = channels.First(c => c.Name == "staging"); - Assert.Null(stagingChannel.VersionPrefix); + Assert.Null(stagingChannel.PinnedVersion); } [Fact] - public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoVersionPrefix() + public async Task GetChannelsAsync_WhenStagingPinToCliVersionSetButNotSharedFeed_ChannelHasNoPinnedVersion() { - // Arrange + // Arrange - pin is set but explicit feed override means not using shared feed using var workspace = TemporaryWorkspace.Create(outputHelper); var tempDir = workspace.WorkspaceRoot; var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); @@ -716,7 +717,7 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoV .AddInMemoryCollection(new Dictionary { ["overrideStagingFeed"] = "https://example.com/nuget/v3/index.json", - ["stagingVersionPrefix"] = "not-a-version" + ["stagingPinToCliVersion"] = "true" }) .Build(); @@ -727,6 +728,188 @@ public async Task GetChannelsAsync_WhenStagingVersionPrefixInvalid_ChannelHasNoV // Assert var stagingChannel = channels.First(c => c.Name == "staging"); - Assert.Null(stagingChannel.VersionPrefix); + // With explicit feed override, useSharedFeed is false, so pinning is not activated + Assert.Null(stagingChannel.PinnedVersion); + } + + /// + /// Verifies that when pinned to CLI version, GetTemplatePackagesAsync returns a synthetic result + /// with the pinned version, bypassing actual NuGet search. + /// + [Fact] + public async Task StagingChannel_WithPinnedVersion_ReturnsSyntheticTemplatePackage() + { + // Arrange - simulate a shared feed that has packages from both 13.2 and 13.3 version lines + var fakeCache = new FakeNuGetPackageCacheWithPackages( + [ + new() { Id = "Aspire.ProjectTemplates", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.3.0-preview.1.26200.5", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.2.0-preview.1.26111.6", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.2.0-preview.1.26110.3", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.1.0", Source = "dotnet9" }, + ]); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease", + ["stagingPinToCliVersion"] = "true" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, fakeCache, features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.First(c => c.Name == "staging"); + var templatePackages = await stagingChannel.GetTemplatePackagesAsync(tempDir, CancellationToken.None).DefaultTimeout(); + + // Assert - should return exactly one synthetic package with the CLI's pinned version + var packageList = templatePackages.ToList(); + outputHelper.WriteLine($"Template packages returned: {packageList.Count}"); + foreach (var p in packageList) + { + outputHelper.WriteLine($" {p.Id} {p.Version}"); + } + + Assert.Single(packageList); + Assert.Equal("Aspire.ProjectTemplates", packageList[0].Id); + Assert.Equal(stagingChannel.PinnedVersion, packageList[0].Version); + // Pinned version should not contain build metadata + Assert.DoesNotContain("+", packageList[0].Version!); + } + + /// + /// Verifies that when pinned to CLI version, GetIntegrationPackagesAsync discovers packages + /// from the feed but overrides their version to the pinned version. + /// + [Fact] + public async Task StagingChannel_WithPinnedVersion_OverridesIntegrationPackageVersions() + { + // Arrange - integration packages with various versions + var fakeCache = new FakeNuGetPackageCacheWithPackages( + [ + new() { Id = "Aspire.Hosting.Redis", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + new() { Id = "Aspire.Hosting.PostgreSQL", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + ]); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease", + ["stagingPinToCliVersion"] = "true" + }) + .Build(); + + var packagingService = new PackagingService(executionContext, fakeCache, features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.First(c => c.Name == "staging"); + var integrationPackages = await stagingChannel.GetIntegrationPackagesAsync(tempDir, CancellationToken.None).DefaultTimeout(); + + // Assert - should discover both packages but with pinned version + var packageList = integrationPackages.ToList(); + outputHelper.WriteLine($"Integration packages returned: {packageList.Count}"); + foreach (var p in packageList) + { + outputHelper.WriteLine($" {p.Id} {p.Version}"); + } + + Assert.Equal(2, packageList.Count); + Assert.All(packageList, p => Assert.Equal(stagingChannel.PinnedVersion, p.Version)); + Assert.Contains(packageList, p => p.Id == "Aspire.Hosting.Redis"); + Assert.Contains(packageList, p => p.Id == "Aspire.Hosting.PostgreSQL"); + } + + /// + /// Verifies that without pinning, all prerelease packages from the feed are returned as-is. + /// + [Fact] + public async Task StagingChannel_WithoutPinnedVersion_ReturnsAllPrereleasePackages() + { + // Arrange + var fakeCache = new FakeNuGetPackageCacheWithPackages( + [ + new() { Id = "Aspire.ProjectTemplates", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.2.0-preview.1.26111.6", Source = "dotnet9" }, + new() { Id = "Aspire.ProjectTemplates", Version = "13.1.0", Source = "dotnet9" }, + ]); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); + + var features = new TestFeatures(); + features.SetFeature(KnownFeatures.StagingChannelEnabled, true); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["overrideStagingQuality"] = "Prerelease" + // No stagingPinToCliVersion — should return all prerelease + }) + .Build(); + + var packagingService = new PackagingService(executionContext, fakeCache, features, configuration); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var stagingChannel = channels.First(c => c.Name == "staging"); + var templatePackages = await stagingChannel.GetTemplatePackagesAsync(tempDir, CancellationToken.None).DefaultTimeout(); + + // Assert + var packageList = templatePackages.ToList(); + outputHelper.WriteLine($"Template packages returned: {packageList.Count}"); + foreach (var p in packageList) + { + outputHelper.WriteLine($" {p.Id} {p.Version}"); + } + + // Should return only the prerelease ones (quality filter), but both 13.3 and 13.2 + Assert.Equal(2, packageList.Count); + Assert.Contains(packageList, p => p.Version!.StartsWith("13.3")); + Assert.Contains(packageList, p => p.Version!.StartsWith("13.2")); + } + + private sealed class FakeNuGetPackageCacheWithPackages(List packages) : INuGetPackageCache + { + public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + { + // Simulate what the real cache does: filter by prerelease flag + var filtered = prerelease + ? packages.Where(p => Semver.SemVersion.Parse(p.Version).IsPrerelease) + : packages.Where(p => !Semver.SemVersion.Parse(p.Version).IsPrerelease); + return Task.FromResult>(filtered.ToList()); + } + + public Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => GetTemplatePackagesAsync(workingDirectory, prerelease, nugetConfigFile, cancellationToken); + + public Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken) + => GetTemplatePackagesAsync(workingDirectory, prerelease, nugetConfigFile, cancellationToken); } } diff --git a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs index ec634093d30..77deca3237b 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs @@ -28,7 +28,7 @@ public async Task PrereleaseWillRecommendUpgradeToPrereleaseOnSameVersionFamily( { var cache = new TestNuGetPackageCache(); cache.SetMockCliPackages([ - // Should be ignored because its lower that current prerelease version. + // Should be ignored because it's lower than current prerelease version. new NuGetPackage { Id = "Aspire.Cli", Version = "9.3.1", Source = "nuget.org" }, // Should be selected because it is higher than 9.4.0-dev (dev and preview sort using alphabetical sort). From f431df387086068897f30664841900a47bc80d76 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 13 Feb 2026 11:06:49 -0800 Subject: [PATCH 102/256] Update pipeline images from vs2022preview to vs2026preview.scout (#14493) Replace deprecated windows.vs2022preview.amd64 images with windows.vs2026preview.scout.amd64 in internal pipeline definitions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/azure-pipelines.yml | 4 ++-- eng/pipelines/release-publish-nuget.yml | 10 +++++----- eng/pipelines/templates/build_sign_native.yml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/eng/pipelines/azure-pipelines.yml b/eng/pipelines/azure-pipelines.yml index 3cc62a0c7ea..6977cfaea52 100644 --- a/eng/pipelines/azure-pipelines.yml +++ b/eng/pipelines/azure-pipelines.yml @@ -120,7 +120,7 @@ extends: justificationForDisabling: 'see https://portal.microsofticm.com/imp/v3/incidents/incident/482258316/summary' sourceAnalysisPool: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows tsa: enabled: true @@ -214,7 +214,7 @@ extends: pool: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows variables: diff --git a/eng/pipelines/release-publish-nuget.yml b/eng/pipelines/release-publish-nuget.yml index f2e25aefaa5..dbb6ca7a377 100644 --- a/eng/pipelines/release-publish-nuget.yml +++ b/eng/pipelines/release-publish-nuget.yml @@ -63,7 +63,7 @@ extends: sdl: sourceAnalysisPool: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows stages: @@ -74,7 +74,7 @@ extends: displayName: 'Validate Release Inputs' pool: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows steps: - checkout: none @@ -109,7 +109,7 @@ extends: displayName: 'Extract BAR Build ID from Build Tags' pool: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows steps: - checkout: none @@ -164,7 +164,7 @@ extends: timeoutInMinutes: 60 pool: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows steps: - checkout: self @@ -439,7 +439,7 @@ extends: displayName: 'Print Release Summary' pool: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows steps: - checkout: none diff --git a/eng/pipelines/templates/build_sign_native.yml b/eng/pipelines/templates/build_sign_native.yml index 9b8faab6a2c..e8d869fd433 100644 --- a/eng/pipelines/templates/build_sign_native.yml +++ b/eng/pipelines/templates/build_sign_native.yml @@ -39,7 +39,7 @@ jobs: pool: ${{ if eq(parameters.agentOs, 'windows') }}: name: NetCore1ESPool-Internal - image: windows.vs2022preview.amd64 + image: windows.vs2026preview.scout.amd64 os: windows ${{ if eq(parameters.agentOs, 'linux') }}: name: NetCore1ESPool-Internal From b893fe2fa21b4f89c98caae13202a966a69a55c0 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Fri, 13 Feb 2026 14:58:04 -0600 Subject: [PATCH 103/256] Fix Azure ServiceBus sku when using private endpoints (#14484) Standard SKU doesn't work with private endpoints. It needs to be Premium. --- .../AzureServiceBusExtensions.cs | 2 +- ...Bus_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index 9699d2668d5..f1be92cc640 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -62,7 +62,7 @@ public static IResourceBuilder AddAzureServiceBus(this { var skuParameter = new ProvisioningParameter("sku", typeof(string)) { - Value = "Standard" + Value = hasPrivateEndpoint ? "Premium" : "Standard" }; infrastructure.Add(skuParameter); var resource = new AzureProvisioning.ServiceBusNamespace(infrastructure.AspireResource.GetBicepIdentifier()) diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureServiceBus_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureServiceBus_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep index 83542945cf7..1b773d6afc7 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureServiceBus_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzurePrivateEndpointLockdownTests.AddAzureServiceBus_WithPrivateEndpoint_GeneratesCorrectBicep.verified.bicep @@ -1,7 +1,7 @@ @description('The location for the resource(s) to be deployed.') param location string = resourceGroup().location -param sku string = 'Standard' +param sku string = 'Premium' resource servicebus 'Microsoft.ServiceBus/namespaces@2024-01-01' = { name: take('servicebus-${uniqueString(resourceGroup().id)}', 50) From d193893618bc975abf496269d3187a9222686ec2 Mon Sep 17 00:00:00 2001 From: Karol Zadora-Przylecki Date: Fri, 13 Feb 2026 15:10:46 -0800 Subject: [PATCH 104/256] Fix AllocatedEndpoint API (#14459) * New NetworkEndpointSnapshotList API * Make tests use new AllocatedEndpoint API * Fix AllocatedEndpoint API usage in DcpExecutor * Add test * Fix inconsistent test value * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Do not use hardcoded delay in test * Improve WaitingForAllocatedEndpointWorks test * Make new test simpler * Clean up AllocatedEndpoint API some more * Suppress obsolete member errors * Fix MAUI test * Validate endpoint passed to AddOrUpdateAllocatedEndpoint() --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../DevTunnelResourceBuilderExtensions.cs | 4 + .../ApplicationModel/EndpointAnnotation.cs | 48 +++++++++++ .../ApplicationModel/EndpointReference.cs | 17 +--- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 8 +- .../AzurePostgresExtensionsTests.cs | 4 +- .../ContainerResourceTests.cs | 10 +-- .../MauiPlatformExtensionsTests.cs | 2 +- .../AddMilvusTests.cs | 4 +- .../PostgresMcpBuilderTests.cs | 4 +- .../AddQdrantTests.cs | 8 +- .../AddRedisTests.cs | 16 +--- .../EndpointReferenceTests.cs | 83 ++++++++++++++++++- .../ExpressionResolverTests.cs | 15 +--- .../WithEnvironmentTests.cs | 9 +- 14 files changed, 154 insertions(+), 78 deletions(-) diff --git a/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs b/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs index b2386597920..4bd09c4fbb6 100644 --- a/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs @@ -154,7 +154,9 @@ public static IResourceBuilder AddDevTunnel( var exception = new DistributedApplicationException($"Error trying to create the dev tunnel resource '{tunnelResource.TunnelId}' this port belongs to: {ex.Message}", ex); foreach (var portResource in tunnelResource.Ports) { +#pragma warning disable CS0618 // Type or member is obsolete portResource.TunnelEndpointAnnotation.AllocatedEndpointSnapshot.SetException(exception); +#pragma warning restore CS0618 // Type or member is obsolete } throw; } @@ -209,7 +211,9 @@ await notifications.PublishUpdateAsync(portResource, snapshot => snapshot with catch (Exception ex) { portLogger.LogError(ex, "Error trying to create dev tunnel port '{Port}' on tunnel '{Tunnel}': {Error}", portResource.TargetEndpoint.Port, portResource.DevTunnel.TunnelId, ex.Message); +#pragma warning disable CS0618 // Type or member is obsolete portResource.TunnelEndpointAnnotation.AllocatedEndpointSnapshot.SetException(ex); +#pragma warning restore CS0618 // Type or member is obsolete throw; } diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs index 8ee659e3040..cccc6438924 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs @@ -101,7 +101,9 @@ public EndpointAnnotation( IsExternal = isExternal ?? false; IsProxied = isProxied; _networkID = networkID ?? KnownNetworkIdentifiers.LocalhostNetwork; +#pragma warning disable CS0618 // Type or member is obsolete AllAllocatedEndpoints.TryAdd(_networkID, AllocatedEndpointSnapshot); +#pragma warning restore CS0618 // Type or member is obsolete } /// @@ -202,8 +204,10 @@ public string Transport /// public AllocatedEndpoint? AllocatedEndpoint { +#pragma warning disable CS0618 // Type or member is obsolete (AllocatedEndpointSnapshot) get { + if (!AllocatedEndpointSnapshot.IsValueSet) { return null; @@ -223,14 +227,20 @@ public AllocatedEndpoint? AllocatedEndpoint } else { + 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}'."); + } AllocatedEndpointSnapshot.SetValue(value); } } +#pragma warning restore CS0618 // Type or member is obsolete } /// /// Gets the for the default . /// + [Obsolete("This property will be marked as internal in future Aspire release. Use AllocatedEndpoint and AllAllocatedEndpoints properties to access and change allocated endpoints associated with an EndpointAnnotation.")] public ValueSnapshot AllocatedEndpointSnapshot { get; } = new(); /// @@ -271,6 +281,7 @@ 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) { lock (_snapshots) @@ -283,4 +294,41 @@ public bool TryAdd(NetworkIdentifier networkID, ValueSnapshot return true; } } + + /// + /// Adds or updates an AllocatedEndpoint value associated with a specific network in the snapshot list. + /// + public void AddOrUpdateAllocatedEndpoint(NetworkIdentifier networkID, AllocatedEndpoint endpoint) + { + if (endpoint.NetworkID != networkID) + { + throw new ArgumentException($"AllocatedEndpoint must use the same network as the {nameof(networkID)} parameter", nameof(endpoint)); + } + 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) + { + var nes = GetSnapshotFor(networkID); + return nes.Snapshot.GetValueAsync(cancellationToken); + } + + private NetworkEndpointSnapshot GetSnapshotFor(NetworkIdentifier networkID) + { + lock (_snapshots) + { + var nes = _snapshots.FirstOrDefault(s => s.NetworkID.Equals(networkID)); + if (nes is null) + { + 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 35e758cb74c..08f3fb8b104 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs @@ -141,8 +141,10 @@ public EndpointReferenceExpression Property(EndpointProperty property) /// public string Url => AllocatedEndpoint.UriString; +#pragma warning disable CS0618 // Type or member is obsolete internal ValueSnapshot AllocatedEndpointSnapshot => EndpointAnnotation.AllocatedEndpointSnapshot; +#pragma warning restore CS0618 // Type or member is obsolete internal AllocatedEndpoint AllocatedEndpoint => GetAllocatedEndpoint() @@ -307,21 +309,8 @@ public class EndpointReferenceExpression(EndpointReference endpointReference, En async ValueTask ResolveValueWithAllocatedAddress() { - // We are going to take the first snapshot that matches the context network ID. In general there might be multiple endpoints for a single service, - // and in future we might need some sort of policy to choose between them, but for now we just take the first one. var endpointSnapshots = Endpoint.EndpointAnnotation.AllAllocatedEndpoints; - var nes = endpointSnapshots.Where(nes => nes.NetworkID == networkContext).FirstOrDefault(); - if (nes is null) - { - nes = new NetworkEndpointSnapshot(new ValueSnapshot(), networkContext); - if (!endpointSnapshots.TryAdd(networkContext, nes.Snapshot)) - { - // Someone else added it first, use theirs. - nes = endpointSnapshots.Where(nes => nes.NetworkID == networkContext).First(); - } - } - - var allocatedEndpoint = await nes.Snapshot.GetValueAsync(cancellationToken).ConfigureAwait(false); + var allocatedEndpoint = await endpointSnapshots.GetAllocatedEndpointAsync(networkContext, cancellationToken).ConfigureAwait(false); return Property switch { diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index b2298e56954..4edf0ce335d 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -994,9 +994,7 @@ private void AddAllocatedEndpointInfo(IEnumerable resourc targetPortExpression: $$$"""{{- portForServing "{{{svc.Metadata.Name}}}" -}}""", KnownNetworkIdentifiers.DefaultAspireContainerNetwork ); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(allocatedEndpoint); - sp.EndpointAnnotation.AllAllocatedEndpoints.TryAdd(allocatedEndpoint.NetworkID, snapshot); + sp.EndpointAnnotation.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(allocatedEndpoint.NetworkID, allocatedEndpoint); } } } @@ -1056,9 +1054,7 @@ ts.Service is not null && targetPortExpression: $$$"""{{- portForServing "{{{ts.Service.Name}}}" -}}""", networkID ); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(tunnelAllocatedEndpoint); - endpoint.AllAllocatedEndpoints.TryAdd(networkID, snapshot); + endpoint.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(networkID, tunnelAllocatedEndpoint); } } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs index a7f55cc7a7a..f0b79fa665d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzurePostgresExtensionsTests.cs @@ -371,9 +371,7 @@ public async Task WithPostgresMcpOnAzureDatabaseRunAsContainerAddsMcpResource() c.WithEndpoint("tcp", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5432); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(new AllocatedEndpoint(e, "postgres.dev.internal", 5432, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); - e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + 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.Containers.Tests/ContainerResourceTests.cs b/tests/Aspire.Hosting.Containers.Tests/ContainerResourceTests.cs index 06495623fc2..18791a7fa5c 100644 --- a/tests/Aspire.Hosting.Containers.Tests/ContainerResourceTests.cs +++ b/tests/Aspire.Hosting.Containers.Tests/ContainerResourceTests.cs @@ -102,10 +102,7 @@ public async Task AddContainerWithArgs() e.AllocatedEndpoint = new(e, "localhost", 1234, targetPortExpression: "1234"); // For container-container lookup we need to add an AllocatedEndpoint on the container network side - var ccae = new AllocatedEndpoint(e, "c1.dev.internal", 2234, EndpointBindingMode.SingleAddress, targetPortExpression: "2234", KnownNetworkIdentifiers.DefaultAspireContainerNetwork); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(ccae); - e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "c1.dev.internal", 2234, EndpointBindingMode.SingleAddress, targetPortExpression: "2234", KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }); var c2 = appBuilder.AddContainer("container", "none") @@ -113,10 +110,7 @@ public async Task AddContainerWithArgs() { e.UriScheme = "http"; // We only care about the container-side endpoint for this test - var snapshot = new ValueSnapshot(); - var ae = new AllocatedEndpoint(e, "container.dev.internal", 5678, EndpointBindingMode.SingleAddress, targetPortExpression: "5678", KnownNetworkIdentifiers.DefaultAspireContainerNetwork); - snapshot.SetValue(ae); - e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, "container.dev.internal", 5678, EndpointBindingMode.SingleAddress, targetPortExpression: "5678", KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }) .WithArgs(context => { diff --git a/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs b/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs index 521adacb4ac..3813adb0a2e 100644 --- a/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs +++ b/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs @@ -636,7 +636,7 @@ public async Task WithOtlpDevTunnel_CleansUpIntermediateEnvironmentVariables(Pla foreach (var endpointAnnotation in endpointAnnotations) { - endpointAnnotation.AllocatedEndpointSnapshot.SetValue(new AllocatedEndpoint(endpointAnnotation, "localhost", 1234)); + endpointAnnotation.AllocatedEndpoint = new AllocatedEndpoint(endpointAnnotation, "localhost", 1234); } var envVars = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( diff --git a/tests/Aspire.Hosting.Milvus.Tests/AddMilvusTests.cs b/tests/Aspire.Hosting.Milvus.Tests/AddMilvusTests.cs index 433b15b1a46..6eb39f1c5c0 100644 --- a/tests/Aspire.Hosting.Milvus.Tests/AddMilvusTests.cs +++ b/tests/Aspire.Hosting.Milvus.Tests/AddMilvusTests.cs @@ -99,9 +99,7 @@ public async Task MilvusClientAppWithReferenceContainsConnectionStrings() .WithEndpoint("grpc", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", MilvusPortGrpc); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(new AllocatedEndpoint(e, "my-milvus.dev.internal", MilvusPortGrpc, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); - e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + 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/PostgresMcpBuilderTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresMcpBuilderTests.cs index e2eb3b0de6d..987bc894af0 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresMcpBuilderTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/PostgresMcpBuilderTests.cs @@ -78,9 +78,7 @@ public async Task WithPostgresMcpOnDatabaseSetsDatabaseUriEnvironmentVariable() .WithEndpoint("tcp", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5432); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(new AllocatedEndpoint(e, "postgres.dev.internal", 5432, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); - e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + 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 4d50b6207c1..85e46194301 100644 --- a/tests/Aspire.Hosting.Qdrant.Tests/AddQdrantTests.cs +++ b/tests/Aspire.Hosting.Qdrant.Tests/AddQdrantTests.cs @@ -172,16 +172,12 @@ public async Task QdrantClientAppWithReferenceContainsConnectionStrings() .WithEndpoint("grpc", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6334); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(new AllocatedEndpoint(e, "my-qdrant.dev.internal", 6334, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); - e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + 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); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(new AllocatedEndpoint(e, "my-qdrant.dev.internal", 6333, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); - e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + 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) diff --git a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs index cace9589d26..2d54dc90ef9 100644 --- a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs @@ -304,23 +304,17 @@ public async Task WithRedisInsightProducesCorrectEnvironmentVariables() redis1.WithEndpoint("tcp", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(new AllocatedEndpoint(e, "myredis1.dev.internal", 5001, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); - e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + 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); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(new AllocatedEndpoint(e, "myredis2.dev.internal", 5002, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); - e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + 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); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(new AllocatedEndpoint(e, "myredis3.dev.internal", 5003, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); - e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + 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()); @@ -734,9 +728,7 @@ public async Task RedisInsightEnvironmentCallbackIsIdempotent() .WithEndpoint("tcp", e => { e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 6379); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(new AllocatedEndpoint(e, "redis.dev.internal", 6379, EndpointBindingMode.SingleAddress, targetPortExpression: null, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); - e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + 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 11e3e3d7de6..c5f8367d193 100644 --- a/tests/Aspire.Hosting.Tests/EndpointReferenceTests.cs +++ b/tests/Aspire.Hosting.Tests/EndpointReferenceTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net.Sockets; +using System.Runtime.CompilerServices; namespace Aspire.Hosting.Tests; @@ -285,6 +286,21 @@ public void TargetPort_ReturnsNullWhenNotDefined() Assert.Null(targetPort); } + [Fact] + public void AllocatedEndpoint_ThrowsWhenNetworkIdDoesNotMatch() + { + var annotation = new EndpointAnnotation(ProtocolType.Tcp, KnownNetworkIdentifiers.LocalhostNetwork, uriScheme: "http", name: "http"); + + // Create an AllocatedEndpoint with a different network ID. + var mismatchedEndpoint = new AllocatedEndpoint( + annotation, "localhost", 8080, + EndpointBindingMode.SingleAddress, + targetPortExpression: null, + networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork); + + var ex = Assert.Throws(() => annotation.AllocatedEndpoint = mismatchedEndpoint); + } + [Theory] [InlineData(EndpointProperty.Url, ResourceKind.Host, ResourceKind.Host, "blah://localhost:1234")] [InlineData(EndpointProperty.Url, ResourceKind.Host, ResourceKind.Container, "blah://localhost:1234")] @@ -330,9 +346,7 @@ public async Task PropertyResolutionTest(EndpointProperty property, ResourceKind : ("host.docker.internal", port); var containerEndpoint = new AllocatedEndpoint(annotation, containerHost, containerPort, EndpointBindingMode.SingleAddress, targetPortExpression: targetPort.ToString(), KnownNetworkIdentifiers.DefaultAspireContainerNetwork); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(containerEndpoint); - annotation.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + annotation.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, containerEndpoint); var expression = destination.GetEndpoint(annotation.Name).Property(property); @@ -363,6 +377,36 @@ static IResourceWithEndpoints CreateResource(string name, ResourceKind kind) } } + [Fact] + public async Task WaitingForAllocatedEndpointWorks() + { + var resource = new TestResource("test"); + var annotation = new EndpointAnnotation(ProtocolType.Tcp, uriScheme: "http", name: "http"); + resource.Annotations.Add(annotation); + var endpointRef = new EndpointReference(resource, annotation); + + var waitStarted = new SemaphoreSlim(0, 1); + +#pragma warning disable CA2012 // Use ValueTasks correctly + var consumer = new WithWaitStartedNotification(waitStarted, endpointRef.GetValueAsync(CancellationToken.None).GetAwaiter()); +#pragma warning restore CA2012 // Use ValueTasks correctly + + await Task.WhenAll + ( + Task.Run(async() => + { + await waitStarted.WaitAsync(); + var allocatedEndpoint = new AllocatedEndpoint(annotation, "localhost", 5000); + annotation.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.LocalhostNetwork, allocatedEndpoint); + }), + Task.Run(async () => + { + var url = await consumer; + Assert.Equal("http://localhost:5000", url); + }) + ).WaitAsync(TimeSpan.FromSeconds(10)); + } + public enum ResourceKind { Host, @@ -372,4 +416,37 @@ public enum ResourceKind private sealed class TestResource(string name) : Resource(name), IResourceWithEndpoints { } + + private struct WithWaitStartedNotification + { + private readonly WaitStartedNotificationAwaiter _awaiter; + + public WithWaitStartedNotification(SemaphoreSlim waitStarted, ValueTaskAwaiter inner) + { + _awaiter = new WaitStartedNotificationAwaiter(waitStarted, inner); + } + public WaitStartedNotificationAwaiter GetAwaiter() => _awaiter; + } + + private struct WaitStartedNotificationAwaiter: INotifyCompletion + { + private readonly ValueTaskAwaiter _inner; + private readonly SemaphoreSlim _waitStarted; + + public WaitStartedNotificationAwaiter(SemaphoreSlim waitStarted, ValueTaskAwaiter inner) + { + _waitStarted = waitStarted; + _inner = inner; + } + + public bool IsCompleted => false; // Force continuation + + public void OnCompleted(Action continuation) + { + _waitStarted.Release(); + _inner.OnCompleted(continuation); + } + + public T GetResult() => _inner.GetResult(); + } } diff --git a/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs b/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs index 429757d98a8..af16db77edc 100644 --- a/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs +++ b/tests/Aspire.Hosting.Tests/ExpressionResolverTests.cs @@ -96,10 +96,7 @@ public async Task ExpressionResolverGeneratesCorrectEndpointStrings(string exprN if (sourceIsContainer) { // Note: on the container network side the port and target port are always the same for AllocatedEndpoint. - var ae = new AllocatedEndpoint(e, containerHost, 22345, EndpointBindingMode.SingleAddress, targetPortExpression: "22345", KnownNetworkIdentifiers.DefaultAspireContainerNetwork); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(ae); - e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, containerHost, 22345, EndpointBindingMode.SingleAddress, targetPortExpression: "22345", KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); } }) .WithEndpoint("endpoint2", e => @@ -108,10 +105,7 @@ public async Task ExpressionResolverGeneratesCorrectEndpointStrings(string exprN e.AllocatedEndpoint = new(e, "localhost", 12346, targetPortExpression: "10001"); if (sourceIsContainer) { - var ae = new AllocatedEndpoint(e, containerHost, 22346, EndpointBindingMode.SingleAddress, targetPortExpression: "22346", KnownNetworkIdentifiers.DefaultAspireContainerNetwork); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(ae); - e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, containerHost, 22346, EndpointBindingMode.SingleAddress, targetPortExpression: "22346", KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); } }) .WithEndpoint("endpoint3", e => @@ -120,10 +114,7 @@ public async Task ExpressionResolverGeneratesCorrectEndpointStrings(string exprN e.AllocatedEndpoint = new(e, "host with space", 12347); if (sourceIsContainer) { - var ae = new AllocatedEndpoint(e, containerHost, 22347, EndpointBindingMode.SingleAddress, targetPortExpression: "22346", KnownNetworkIdentifiers.DefaultAspireContainerNetwork); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(ae); - e.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + e.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(e, containerHost, 22347, EndpointBindingMode.SingleAddress, targetPortExpression: "22347", KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); } }); diff --git a/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs b/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs index c6b9b0a1070..18b57208c57 100644 --- a/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs +++ b/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs @@ -229,10 +229,7 @@ public async Task EnvironmentVariableExpressions() { ep.AllocatedEndpoint = new AllocatedEndpoint(ep, "localhost", 17454); - var ae = new AllocatedEndpoint(ep, "container1.dev.internal", 10005, EndpointBindingMode.SingleAddress, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork); - var snapshot = new ValueSnapshot(); - snapshot.SetValue(ae); - ep.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, snapshot); + ep.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint(ep, "container1.dev.internal", 10005, EndpointBindingMode.SingleAddress, networkID: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); }); var endpoint = container.GetEndpoint("primary"); @@ -307,8 +304,7 @@ public async Task EnvironmentVariableWithDynamicTargetPort() .WithHttpEndpoint(name: "primary") .WithEndpoint("primary", ep => { - var endpointSnapshot = new ValueSnapshot(); - endpointSnapshot.SetValue(new AllocatedEndpoint( + ep.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, new AllocatedEndpoint( ep, "localhost", 90, @@ -316,7 +312,6 @@ public async Task EnvironmentVariableWithDynamicTargetPort() """{{- portForServing "container1_primary" -}}""", KnownNetworkIdentifiers.DefaultAspireContainerNetwork )); - ep.AllAllocatedEndpoints.TryAdd(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, endpointSnapshot); }); var endpoint = container.GetEndpoint("primary"); From 97d81bd56e895df9251067d1306300bcf1c0e8b6 Mon Sep 17 00:00:00 2001 From: David Negstad <50252651+danegsta@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:35:27 -0800 Subject: [PATCH 105/256] Manually update DCP to 0.22.5 (#14501) --- eng/Version.Details.xml | 28 ++++++++++++++-------------- eng/Versions.props | 14 +++++++------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index ea697099b37..8d357c4bbad 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,33 +1,33 @@ - + https://github.com/microsoft/dcp - f1dbae0486549c8e25572c82524e533ce40e3bc1 + c59fb51a7d618ee78dcd1b7b2582e89e609fcb76 - + https://github.com/microsoft/dcp - f1dbae0486549c8e25572c82524e533ce40e3bc1 + c59fb51a7d618ee78dcd1b7b2582e89e609fcb76 - + https://github.com/microsoft/dcp - f1dbae0486549c8e25572c82524e533ce40e3bc1 + c59fb51a7d618ee78dcd1b7b2582e89e609fcb76 - + https://github.com/microsoft/dcp - f1dbae0486549c8e25572c82524e533ce40e3bc1 + c59fb51a7d618ee78dcd1b7b2582e89e609fcb76 - + https://github.com/microsoft/dcp - f1dbae0486549c8e25572c82524e533ce40e3bc1 + c59fb51a7d618ee78dcd1b7b2582e89e609fcb76 - + https://github.com/microsoft/dcp - f1dbae0486549c8e25572c82524e533ce40e3bc1 + c59fb51a7d618ee78dcd1b7b2582e89e609fcb76 - + https://github.com/microsoft/dcp - f1dbae0486549c8e25572c82524e533ce40e3bc1 + c59fb51a7d618ee78dcd1b7b2582e89e609fcb76 https://dev.azure.com/dnceng/internal/_git/dotnet-extensions diff --git a/eng/Versions.props b/eng/Versions.props index a2d25ba628a..d7556ef7537 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -30,13 +30,13 @@ 8.0.100-rtm.23512.16 - 0.22.4 - 0.22.4 - 0.22.4 - 0.22.4 - 0.22.4 - 0.22.4 - 0.22.4 + 0.22.5 + 0.22.5 + 0.22.5 + 0.22.5 + 0.22.5 + 0.22.5 + 0.22.5 11.0.0-beta.25610.3 11.0.0-beta.25610.3 From c62d96b629f4804528989094f33411a0ef09b008 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Sun, 15 Feb 2026 14:00:35 -0800 Subject: [PATCH 106/256] Fix Spectre markup rendering in CLI selection prompts (#14497) * Fix Spectre markup rendering in CLI selection prompts The blanket EscapeMarkup() added to the UseConverter in PromptForSelectionAsync/PromptForSelectionsAsync (PR #14422) escaped all markup including the intentional [bold]...[/] used by AddCommand.PackageNameWithFriendlyNameIfAvailable, causing literal '[bold]postgresql[/]' to appear in 'aspire add' output. Fix: remove blanket escaping from the generic converter and instead escape dynamic values at each caller site that formats user-controlled or file-system content (file paths, package names, channel details, publish prompt options), while preserving intentional Spectre markup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Mitch Denny Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Backchannel/AppHostConnectionResolver.cs | 5 +- src/Aspire.Cli/Commands/AddCommand.cs | 9 +-- src/Aspire.Cli/Commands/InitCommand.cs | 4 +- src/Aspire.Cli/Commands/NewCommand.cs | 6 +- .../Commands/PipelineCommandBase.cs | 2 +- src/Aspire.Cli/Commands/PublishCommand.cs | 2 +- src/Aspire.Cli/Commands/UpdateCommand.cs | 2 +- .../Interaction/ConsoleInteractionService.cs | 4 +- src/Aspire.Cli/Projects/ProjectLocator.cs | 5 +- src/Aspire.Cli/Projects/SolutionLocator.cs | 3 +- .../ConsoleInteractionServiceTests.cs | 66 +++++++++++++++++++ 11 files changed, 89 insertions(+), 19 deletions(-) diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index 790f0decaef..651cdb617bb 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs @@ -5,6 +5,7 @@ using Aspire.Cli.Interaction; using Aspire.Cli.Utils; using Microsoft.Extensions.Logging; +using Spectre.Console; namespace Aspire.Cli.Backchannel; @@ -125,7 +126,7 @@ public async Task ResolveConnectionAsync( var selectedDisplay = await interactionService.PromptForSelectionAsync( selectPrompt, choices.Select(c => c.Display).ToArray(), - c => c, + c => c.EscapeMarkup(), cancellationToken); selectedConnection = choices.FirstOrDefault(c => c.Display == selectedDisplay).Connection; @@ -148,7 +149,7 @@ public async Task ResolveConnectionAsync( var selectedDisplay = await interactionService.PromptForSelectionAsync( selectPrompt, choices.Select(c => c.Display).ToArray(), - c => c, + c => c.EscapeMarkup(), cancellationToken); selectedConnection = choices.FirstOrDefault(c => c.Display == selectedDisplay).Connection; diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 7a7e1288f69..c9682c1fb34 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -12,6 +12,7 @@ using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; using Semver; +using Spectre.Console; using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.Commands; @@ -325,7 +326,7 @@ internal class AddCommandPrompter(IInteractionService interactionService) : IAdd // Helper to keep labels consistently formatted: "Version (source)" static string FormatVersionLabel((string FriendlyName, NuGetPackage Package, PackageChannel Channel) item) { - return $"{item.Package.Version} ({item.Channel.SourceDetails})"; + return $"{item.Package.Version.EscapeMarkup()} ({item.Channel.SourceDetails.EscapeMarkup()})"; } async Task<(string FriendlyName, NuGetPackage Package, PackageChannel Channel)> PromptForChannelPackagesAsync( @@ -395,7 +396,7 @@ static string FormatVersionLabel((string FriendlyName, NuGetPackage Package, Pac var item = channelGroup.HighestVersion; rootChoices.Add(( - Label: channel.Name, + Label: channel.Name.EscapeMarkup(), // For explicit channels, we still show submenu but with only the highest version Action: ct => PromptForChannelPackagesAsync(channel, new[] { item }, ct) )); @@ -442,11 +443,11 @@ private static string PackageNameWithFriendlyNameIfAvailable((string FriendlyNam { if (packageWithFriendlyName.FriendlyName is { } friendlyName) { - return $"[bold]{friendlyName}[/] ({packageWithFriendlyName.Package.Id})"; + return $"[bold]{friendlyName.EscapeMarkup()}[/] ({packageWithFriendlyName.Package.Id.EscapeMarkup()})"; } else { - return packageWithFriendlyName.Package.Id; + return packageWithFriendlyName.Package.Id.EscapeMarkup(); } } } diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index db860435fd0..76f6caadbb7 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -245,7 +245,7 @@ later as needed. var selectedProjects = await InteractionService.PromptForSelectionsAsync( "Select projects to add to the AppHost:", initContext.ExecutableProjects, - project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name), + project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name).EscapeMarkup(), cancellationToken); initContext.ExecutableProjectsToAddToAppHost = selectedProjects; @@ -298,7 +298,7 @@ ServiceDefaults project contains helper code to make it easier initContext.ProjectsToAddServiceDefaultsTo = await InteractionService.PromptForSelectionsAsync( "Select projects to add ServiceDefaults reference to:", initContext.ExecutableProjectsToAddToAppHost, - project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name), + project => Path.GetFileNameWithoutExtension(project.ProjectFile.Name).EscapeMarkup(), cancellationToken); break; case "none": diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index 1ce14e647b7..c350f575b93 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -282,7 +282,7 @@ internal class NewCommandPrompter(IInteractionService interactionService) : INew static string FormatPackageLabel((NuGetPackage Package, PackageChannel Channel) item) { // Keep it concise: "Version (source)" - return $"{item.Package.Version} ({item.Channel.SourceDetails})"; + return $"{item.Package.Version.EscapeMarkup()} ({item.Channel.SourceDetails.EscapeMarkup()})"; } async Task<(NuGetPackage Package, PackageChannel Channel)> PromptForChannelPackagesAsync( @@ -330,7 +330,7 @@ static string FormatPackageLabel((NuGetPackage Package, PackageChannel Channel) var items = channelGroup.ToArray(); rootChoices.Add(( - Label: channel.Name, + Label: channel.Name.EscapeMarkup(), Action: ct => PromptForChannelPackagesAsync(channel, items, ct) )); } @@ -380,7 +380,7 @@ public virtual async Task PromptForTemplateAsync(ITemplate[] validTem return await interactionService.PromptForSelectionAsync( NewCommandStrings.SelectAProjectTemplate, validTemplates, - t => t.Description, + t => t.Description.EscapeMarkup(), cancellationToken ); } diff --git a/src/Aspire.Cli/Commands/PipelineCommandBase.cs b/src/Aspire.Cli/Commands/PipelineCommandBase.cs index 1cb2587d6ea..bdc3ce168e9 100644 --- a/src/Aspire.Cli/Commands/PipelineCommandBase.cs +++ b/src/Aspire.Cli/Commands/PipelineCommandBase.cs @@ -804,7 +804,7 @@ private async Task HandlePromptActivityAsync(PublishingActivity activity, IAppHo var (value, displayText) = await InteractionService.PromptForSelectionAsync( promptText, options, - choice => choice.Value, + choice => choice.Value.EscapeMarkup(), cancellationToken); if (value == CustomChoiceValue) diff --git a/src/Aspire.Cli/Commands/PublishCommand.cs b/src/Aspire.Cli/Commands/PublishCommand.cs index f70d336e615..f7b50eb8f3b 100644 --- a/src/Aspire.Cli/Commands/PublishCommand.cs +++ b/src/Aspire.Cli/Commands/PublishCommand.cs @@ -26,7 +26,7 @@ public virtual async Task PromptForPublisherAsync(IEnumerable pu return await interactionService.PromptForSelectionAsync( PublishCommandStrings.SelectAPublisher, publishers, - p => p, + p => p.EscapeMarkup(), cancellationToken ); } diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index c75e943e38b..ee6fb888ca7 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -178,7 +178,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell channel = await InteractionService.PromptForSelectionAsync( UpdateCommandStrings.SelectChannelPrompt, allChannels, - (c) => $"{c.Name} ({c.SourceDetails})", + (c) => $"{c.Name.EscapeMarkup()} ({c.SourceDetails.EscapeMarkup()})", cancellationToken); } else diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index 4c7d8487ebd..a653548422b 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -145,7 +145,7 @@ public async Task PromptForSelectionAsync(string promptText, IEnumerable() .Title(promptText) - .UseConverter(item => choiceFormatter(item).EscapeMarkup()) + .UseConverter(choiceFormatter) .AddChoices(choices) .PageSize(10) .EnableSearch(); @@ -174,7 +174,7 @@ public async Task> PromptForSelectionsAsync(string promptTex var prompt = new MultiSelectionPrompt() .Title(promptText) - .UseConverter(item => choiceFormatter(item).EscapeMarkup()) + .UseConverter(choiceFormatter) .AddChoices(choices) .PageSize(10); diff --git a/src/Aspire.Cli/Projects/ProjectLocator.cs b/src/Aspire.Cli/Projects/ProjectLocator.cs index 43d4f455a8e..48a4c4c088e 100644 --- a/src/Aspire.Cli/Projects/ProjectLocator.cs +++ b/src/Aspire.Cli/Projects/ProjectLocator.cs @@ -10,6 +10,7 @@ using Aspire.Cli.Utils; using Aspire.Hosting.Utils; using Microsoft.Extensions.Logging; +using Spectre.Console; namespace Aspire.Cli.Projects; @@ -199,7 +200,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F projectFile = await interactionService.PromptForSelectionAsync( InteractionServiceStrings.SelectAppHostToUse, appHostProjects, - file => $"{file.Name} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, file.FullName)})", + file => $"{file.Name.EscapeMarkup()} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, file.FullName).EscapeMarkup()})", cancellationToken ); } @@ -277,7 +278,7 @@ public async Task UseOrFindAppHostProjectFileAsync(F selectedAppHost = multipleAppHostProjectsFoundBehavior switch { MultipleAppHostProjectsFoundBehavior.Throw => throw new ProjectLocatorException(ErrorStrings.MultipleProjectFilesFound), - MultipleAppHostProjectsFoundBehavior.Prompt => await interactionService.PromptForSelectionAsync(InteractionServiceStrings.SelectAppHostToUse, results.BuildableAppHost, projectFile => $"{projectFile.Name} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, projectFile.FullName)})", cancellationToken), + MultipleAppHostProjectsFoundBehavior.Prompt => await interactionService.PromptForSelectionAsync(InteractionServiceStrings.SelectAppHostToUse, results.BuildableAppHost, projectFile => $"{projectFile.Name.EscapeMarkup()} ({Path.GetRelativePath(executionContext.WorkingDirectory.FullName, projectFile.FullName).EscapeMarkup()})", cancellationToken), MultipleAppHostProjectsFoundBehavior.None => null, _ => selectedAppHost }; diff --git a/src/Aspire.Cli/Projects/SolutionLocator.cs b/src/Aspire.Cli/Projects/SolutionLocator.cs index 8458fb85cd3..0c95bd66ef1 100644 --- a/src/Aspire.Cli/Projects/SolutionLocator.cs +++ b/src/Aspire.Cli/Projects/SolutionLocator.cs @@ -4,6 +4,7 @@ using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Microsoft.Extensions.Logging; +using Spectre.Console; namespace Aspire.Cli.Projects; @@ -39,7 +40,7 @@ internal sealed class SolutionLocator(ILogger logger, IInteract var selectedSolution = await interactionService.PromptForSelectionAsync( InitCommandStrings.MultipleSolutionsFound, solutionFiles, - solutionFile => $"{solutionFile.Name} ({Path.GetRelativePath(startDirectory.FullName, solutionFile.FullName)})", + solutionFile => $"{solutionFile.Name.EscapeMarkup()} ({Path.GetRelativePath(startDirectory.FullName, solutionFile.FullName).EscapeMarkup()})", cancellationToken); return selectedSolution; diff --git a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs index 090fe5f1f35..bc489a77d07 100644 --- a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs @@ -547,4 +547,70 @@ public void DisplaySubtleMessage_WithMarkupCharacters_EscapesByDefault() var outputString = output.ToString(); Assert.Contains("[Module]", outputString); } + + [Fact] + public void SelectionPrompt_ConverterPreservesIntentionalMarkup() + { + // Arrange - verifies that PromptForSelectionAsync does NOT escape the formatter output, + // allowing callers to include intentional Spectre markup (e.g., [bold]...[/]). + // This is a regression test for https://github.com/dotnet/aspire/pull/14422 where + // blanket EscapeMarkup() in the converter broke [bold] rendering in 'aspire add'. + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.Standard, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + // Build a SelectionPrompt the same way ConsoleInteractionService does, + // using a formatter that returns intentional markup (like AddCommand does). + Func choiceFormatter = item => $"[bold]{item}[/] (Aspire.Hosting.{item})"; + + var prompt = new SelectionPrompt() + .Title("Select an integration:") + .UseConverter(choiceFormatter) + .AddChoices(["PostgreSQL", "Redis"]); + + // Act - verify the converter output preserves the [bold] markup + // by checking that the converter is the formatter itself (not wrapped with EscapeMarkup) + var converterOutput = choiceFormatter("PostgreSQL"); + + // Assert - the formatter should produce raw markup, not escaped markup + Assert.Equal("[bold]PostgreSQL[/] (Aspire.Hosting.PostgreSQL)", converterOutput); + Assert.DoesNotContain("[[bold]]", converterOutput); // Must NOT be escaped + } + + [Fact] + public void SelectionPrompt_ConverterWithBracketsInData_MustBeEscapedByCaller() + { + // Arrange - verifies that callers are responsible for escaping dynamic data + // that may contain bracket characters, while preserving intentional markup. + // This tests the pattern used by AddCommand.PackageNameWithFriendlyNameIfAvailable. + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + // Simulate a package name that contains brackets (e.g., from an external source) + var friendlyName = "Azure Storage [Preview]"; + var packageId = "Aspire.Hosting.Azure.Storage"; + + // The formatter should escape dynamic values but preserve intentional markup + var formattedOutput = $"[bold]{friendlyName.EscapeMarkup()}[/] ({packageId.EscapeMarkup()})"; + + // Assert - intentional markup preserved, dynamic brackets escaped + Assert.Equal("[bold]Azure Storage [[Preview]][/] (Aspire.Hosting.Azure.Storage)", formattedOutput); + + // Verify Spectre can render this without throwing + var exception = Record.Exception(() => console.MarkupLine(formattedOutput)); + Assert.Null(exception); + + var outputString = output.ToString(); + Assert.Contains("Azure Storage [Preview]", outputString); + Assert.Contains("Aspire.Hosting.Azure.Storage", outputString); + } } From d16b523914661745b1517f9d073c3c2d292d0a70 Mon Sep 17 00:00:00 2001 From: Karol Zadora-Przylecki Date: Tue, 17 Feb 2026 08:41:00 -0800 Subject: [PATCH 107/256] Update DCP to version 0.22.6 (#14517) --- eng/Version.Details.xml | 28 ++++++++++++++-------------- eng/Versions.props | 14 +++++++------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 8d357c4bbad..1cdf20f5f5e 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,33 +1,33 @@ - + https://github.com/microsoft/dcp - c59fb51a7d618ee78dcd1b7b2582e89e609fcb76 + 9585d3bbfad8a356770096fcda944349da4145f1 - + https://github.com/microsoft/dcp - c59fb51a7d618ee78dcd1b7b2582e89e609fcb76 + 9585d3bbfad8a356770096fcda944349da4145f1 - + https://github.com/microsoft/dcp - c59fb51a7d618ee78dcd1b7b2582e89e609fcb76 + 9585d3bbfad8a356770096fcda944349da4145f1 - + https://github.com/microsoft/dcp - c59fb51a7d618ee78dcd1b7b2582e89e609fcb76 + 9585d3bbfad8a356770096fcda944349da4145f1 - + https://github.com/microsoft/dcp - c59fb51a7d618ee78dcd1b7b2582e89e609fcb76 + 9585d3bbfad8a356770096fcda944349da4145f1 - + https://github.com/microsoft/dcp - c59fb51a7d618ee78dcd1b7b2582e89e609fcb76 + 9585d3bbfad8a356770096fcda944349da4145f1 - + https://github.com/microsoft/dcp - c59fb51a7d618ee78dcd1b7b2582e89e609fcb76 + 9585d3bbfad8a356770096fcda944349da4145f1 https://dev.azure.com/dnceng/internal/_git/dotnet-extensions diff --git a/eng/Versions.props b/eng/Versions.props index d7556ef7537..194b5e9f25e 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -30,13 +30,13 @@ 8.0.100-rtm.23512.16 - 0.22.5 - 0.22.5 - 0.22.5 - 0.22.5 - 0.22.5 - 0.22.5 - 0.22.5 + 0.22.6 + 0.22.6 + 0.22.6 + 0.22.6 + 0.22.6 + 0.22.6 + 0.22.6 11.0.0-beta.25610.3 11.0.0-beta.25610.3 From 17e330e0665346bd7a59f4de03bb172bb4d59f08 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Tue, 17 Feb 2026 09:52:26 -0800 Subject: [PATCH 108/256] Add agentic workflow daily-repo-status (#14498) --- .gitattributes | 2 + .github/workflows/daily-repo-status.lock.yml | 1032 ++++++++++++++++++ .github/workflows/daily-repo-status.md | 54 + 3 files changed, 1088 insertions(+) create mode 100644 .github/workflows/daily-repo-status.lock.yml create mode 100644 .github/workflows/daily-repo-status.md diff --git a/.gitattributes b/.gitattributes index 594552221cc..4c262a83c4c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -60,3 +60,5 @@ # https://github.com/github/linguist/issues/1626#issuecomment-401442069 # this only affects the repo's language statistics *.h linguist-language=C + +.github/workflows/*.lock.yml linguist-generated=true merge=ours \ No newline at end of file diff --git a/.github/workflows/daily-repo-status.lock.yml b/.github/workflows/daily-repo-status.lock.yml new file mode 100644 index 00000000000..327fe1d0f38 --- /dev/null +++ b/.github/workflows/daily-repo-status.lock.yml @@ -0,0 +1,1032 @@ +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.43.22). DO NOT EDIT. +# +# To update this file, edit githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# This workflow creates daily repo status reports. It gathers recent repository +# activity (issues, PRs, discussions, releases, code changes) and generates +# engaging GitHub issues with productivity insights, community highlights, +# and project recommendations. +# +# Source: githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb +# +# frontmatter-hash: bec92641275aec67119420ff1264936a5fd32ec8a3734c7665ec0659fa174613 + +name: "Daily Repo Status" +"on": + schedule: + - cron: "42 7 * * *" + # Friendly format: daily (scattered) + workflow_dispatch: + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}" + +run-name: "Daily Repo Status" + +jobs: + activation: + runs-on: ubuntu-slim + permissions: + contents: read + outputs: + comment_id: "" + comment_repo: "" + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@fe858c3e14589bf396594a0b106e634d9065823e # v0.43.22 + with: + destination: /opt/gh-aw/actions + - name: Check workflow file timestamps + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_WORKFLOW_FILE: "daily-repo-status.lock.yml" + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + model: ${{ steps.generate_aw_info.outputs.model }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@fe858c3e14589bf396594a0b106e634d9065823e # v0.43.22 + with: + destination: /opt/gh-aw/actions + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Generate agentic run info + id: generate_aw_info + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "copilot", + engine_name: "GitHub Copilot CLI", + model: process.env.GH_AW_MODEL_AGENT_COPILOT || "", + version: "", + agent_version: "0.0.409", + cli_version: "v0.43.22", + workflow_name: "Daily Repo Status", + experimental: false, + supports_tools_allowlist: true, + supports_http_transport: true, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + staged: false, + allowed_domains: ["defaults"], + firewall_enabled: true, + awf_version: "v0.16.4", + awmg_version: "", + steps: { + firewall: "squid" + }, + created_at: new Date().toISOString() + }; + + // Write to /tmp/gh-aw directory to avoid inclusion in PR + const tmpPath = '/tmp/gh-aw/aw_info.json'; + fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); + console.log('Generated aw_info.json at:', tmpPath); + console.log(JSON.stringify(awInfo, null, 2)); + + // Set model as output for reuse in other steps/jobs + core.setOutput('model', awInfo.model); + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Install GitHub Copilot CLI + run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.409 + - name: Install awf binary + run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.16.4 + - name: Download container images + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.16.4 ghcr.io/github/gh-aw-firewall/squid:0.16.4 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 node:lts-alpine + - name: Write Safe Outputs Config + run: | + mkdir -p /opt/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' + {"create_issue":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}} + GH_AW_SAFE_OUTPUTS_CONFIG_EOF + cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' + [ + { + "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[repo-status] \". Labels [report daily-status] will be automatically added.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.", + "type": "string" + }, + "labels": { + "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.", + "items": { + "type": "string" + }, + "type": "array" + }, + "parent": { + "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123', 'aw_Test123') from a previously created issue in the same workflow run.", + "type": [ + "number", + "string" + ] + }, + "temporary_id": { + "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 3 to 8 alphanumeric characters (e.g., 'aw_abc1', 'aw_Test123'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", + "pattern": "^aw_[A-Za-z0-9]{4,8}$", + "type": "string" + }, + "title": { + "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.", + "type": "string" + } + }, + "required": [ + "title", + "body" + ], + "type": "object" + }, + "name": "create_issue" + }, + { + "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "reason": { + "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).", + "type": "string" + }, + "tool": { + "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.", + "type": "string" + } + }, + "required": [ + "reason" + ], + "type": "object" + }, + "name": "missing_tool" + }, + { + "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "name": "noop" + }, + { + "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "context": { + "description": "Additional context about the missing data or where it should come from (max 256 characters).", + "type": "string" + }, + "data_type": { + "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.", + "type": "string" + }, + "reason": { + "description": "Explanation of why this data is needed to complete the task (max 256 characters).", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "missing_data" + } + ] + GH_AW_SAFE_OUTPUTS_TOOLS_EOF + cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + GH_AW_SAFE_OUTPUTS_VALIDATION_EOF + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash /opt/gh-aw/actions/start_safe_outputs_server.sh + + - name: Start MCP gateway + id: start-mcp-gateway + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p /tmp/gh-aw/mcp-config + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.4' + + mkdir -p /home/runner/.copilot + cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v0.30.3", + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + } + }, + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_EOF + - name: Generate workflow overview + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); + await generateWorkflowOverview(core); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + run: | + bash /opt/gh-aw/actions/create_prompt_first.sh + cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + GitHub API Access Instructions + + The gh CLI is NOT authenticated. Do NOT use gh commands for GitHub operations. + + + To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls. + + Temporary IDs: Some safe output tools support a temporary ID field (usually named temporary_id) so you can reference newly-created items elsewhere in the SAME agent output (for example, using #aw_abc1 in a later body). + + **IMPORTANT - temporary_id format rules:** + - If you DON'T need to reference the item later, OMIT the temporary_id field entirely (it will be auto-generated if needed) + - If you DO need cross-references/chaining, you MUST match this EXACT validation regex: /^aw_[A-Za-z0-9]{3,8}$/i + - Format: aw_ prefix followed by 3 to 8 alphanumeric characters (A-Z, a-z, 0-9, case-insensitive) + - Valid alphanumeric characters: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 + - INVALID examples: aw_ab (too short), aw_123456789 (too long), aw_test-id (contains hyphen), aw_id_123 (contains underscore) + - VALID examples: aw_abc, aw_abc1, aw_Test123, aw_A1B2C3D4, aw_12345678 + - To generate valid IDs: use 3-8 random alphanumeric characters or omit the field to let the system auto-generate + + Do NOT invent other aw_* formats — downstream steps will reject them with validation errors matching against /^aw_[A-Za-z0-9]{3,8}$/i. + + Discover available tools from the safeoutputs MCP server. + + **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped. + + **Note**: If you made no other safe output tool calls during this workflow execution, call the "noop" tool to provide a status message indicating completion or that no actions were needed. + + + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + {{#runtime-import .github/workflows/daily-repo-status.md}} + GH_AW_PROMPT_EOF + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + with: + script: | + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE + } + }); + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Clean git credentials + run: bash /opt/gh-aw/actions/clean_git_credentials.sh + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.16.4 --skip-pull \ + -- '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' \ + 2>&1 | tee /tmp/gh-aw/agent-stdio.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + GH_AW_MODEL_AGENT_COPILOT: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} + GITHUB_WORKSPACE: ${{ github.workspace }} + XDG_CONFIG_HOME: /home/runner + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: | + # Copy Copilot session state files to logs folder for artifact collection + # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them + SESSION_STATE_DIR="$HOME/.copilot/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + + if [ -d "$SESSION_STATE_DIR" ]; then + echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" + mkdir -p "$LOGS_DIR" + cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true + echo "Session state files copied successfully" + else + echo "No session-state directory found at $SESSION_STATE_DIR" + fi + - name: Stop MCP gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload Safe Outputs + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: safe-output + path: ${{ env.GH_AW_SAFE_OUTPUTS }} + if-no-files-found: warn + - name: Ingest agent output + id: collect_output + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Upload sanitized agent output + if: always() && env.GH_AW_AGENT_OUTPUT + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: agent-output + path: ${{ env.GH_AW_AGENT_OUTPUT }} + if-no-files-found: warn + - name: Upload engine output files + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: agent_outputs + path: | + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + if-no-files-found: ignore + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP gateway logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: agent-artifacts + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/agent/ + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - detection + - safe_outputs + if: (always()) && (needs.agent.result != 'skipped') + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + outputs: + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@fe858c3e14589bf396594a0b106e634d9065823e # v0.43.22 + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process No-Op Messages + id: noop + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: 1 + GH_AW_WORKFLOW_NAME: "Daily Repo Status" + GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb/workflows/daily-repo-status.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/noop.cjs'); + await main(); + - name: Record Missing Tool + id: missing_tool + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Daily Repo Status" + GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb/workflows/daily-repo-status.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Handle Agent Failure + id: handle_agent_failure + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Daily Repo Status" + GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb/workflows/daily-repo-status.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "daily-repo-status" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + - name: Handle No-Op Message + id: handle_noop_message + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Daily Repo Status" + GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb/workflows/daily-repo-status.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Update reaction comment with completion status + id: conclusion + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} + GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }} + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_WORKFLOW_NAME: "Daily Repo Status" + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.result }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/notify_comment_error.cjs'); + await main(); + + detection: + needs: agent + if: needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true' + runs-on: ubuntu-latest + permissions: {} + timeout-minutes: 10 + outputs: + success: ${{ steps.parse_results.outputs.success }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@fe858c3e14589bf396594a0b106e634d9065823e # v0.43.22 + with: + destination: /opt/gh-aw/actions + - name: Download agent artifacts + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: agent-artifacts + path: /tmp/gh-aw/threat-detection/ + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: agent-output + path: /tmp/gh-aw/threat-detection/ + - name: Echo agent output types + env: + AGENT_OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} + run: | + echo "Agent output-types: $AGENT_OUTPUT_TYPES" + - name: Setup threat detection + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + WORKFLOW_NAME: "Daily Repo Status" + WORKFLOW_DESCRIPTION: "This workflow creates daily repo status reports. It gathers recent repository\nactivity (issues, PRs, discussions, releases, code changes) and generates\nengaging GitHub issues with productivity insights, community highlights,\nand project recommendations." + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Install GitHub Copilot CLI + run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.409 + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + # --allow-tool shell(cat) + # --allow-tool shell(grep) + # --allow-tool shell(head) + # --allow-tool shell(jq) + # --allow-tool shell(ls) + # --allow-tool shell(tail) + # --allow-tool shell(wc) + timeout-minutes: 20 + run: | + set -o pipefail + COPILOT_CLI_INSTRUCTION="$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" + mkdir -p /tmp/ + mkdir -p /tmp/gh-aw/ + mkdir -p /tmp/gh-aw/agent/ + mkdir -p /tmp/gh-aw/sandbox/agent/logs/ + copilot --add-dir /tmp/ --add-dir /tmp/gh-aw/ --add-dir /tmp/gh-aw/agent/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-tool 'shell(cat)' --allow-tool 'shell(grep)' --allow-tool 'shell(head)' --allow-tool 'shell(jq)' --allow-tool 'shell(ls)' --allow-tool 'shell(tail)' --allow-tool 'shell(wc)' --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$COPILOT_CLI_INSTRUCTION"${GH_AW_MODEL_DETECTION_COPILOT:+ --model "$GH_AW_MODEL_DETECTION_COPILOT"} 2>&1 | tee /tmp/gh-aw/threat-detection/detection.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_MODEL_DETECTION_COPILOT: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} + GITHUB_WORKSPACE: ${{ github.workspace }} + XDG_CONFIG_HOME: /home/runner + - name: Parse threat detection results + id: parse_results + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + - name: Upload threat detection log + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: threat-detection.log + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + + safe_outputs: + needs: + - agent + - detection + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.detection.outputs.success == 'true') + runs-on: ubuntu-slim + permissions: + contents: read + issues: write + timeout-minutes: 15 + env: + GH_AW_WORKFLOW_ID: "daily-repo-status" + GH_AW_WORKFLOW_NAME: "Daily Repo Status" + GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb/workflows/daily-repo-status.md" + outputs: + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@fe858c3e14589bf396594a0b106e634d9065823e # v0.43.22 + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"labels\":[\"report\",\"daily-status\"],\"max\":1,\"title_prefix\":\"[repo-status] \"},\"missing_data\":{},\"missing_tool\":{}}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + diff --git a/.github/workflows/daily-repo-status.md b/.github/workflows/daily-repo-status.md new file mode 100644 index 00000000000..8d91504c61d --- /dev/null +++ b/.github/workflows/daily-repo-status.md @@ -0,0 +1,54 @@ +--- +description: | + This workflow creates daily repo status reports. It gathers recent repository + activity (issues, PRs, discussions, releases, code changes) and generates + engaging GitHub issues with productivity insights, community highlights, + and project recommendations. + +on: + schedule: daily + workflow_dispatch: + +permissions: + contents: read + issues: read + pull-requests: read + +network: defaults + +tools: + github: + # If in a public repo, setting `lockdown: false` allows + # reading issues, pull requests and comments from 3rd-parties + # If in a private repo this has no particular effect. + lockdown: false + +safe-outputs: + create-issue: + title-prefix: "[repo-status] " + labels: [report, daily-status] +source: githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb +--- + +# Daily Repo Status + +Create an upbeat daily status report for the repo as a GitHub issue. + +## What to include + +- Recent repository activity (issues, PRs, discussions, releases, code changes) +- Progress tracking, goal reminders and highlights +- Project status and recommendations +- Actionable next steps for maintainers + +## Style + +- Be positive, encouraging, and helpful 🌟 +- Use emojis moderately for engagement +- Keep it concise - adjust length based on actual activity + +## Process + +1. Gather recent activity from the repository +2. Study the repository, its issues and its pull requests +3. Create a new GitHub issue with your findings and insights From e5b355e575e1b0a57e4e30b086ee9fd4072f32c6 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Tue, 17 Feb 2026 10:23:42 -0800 Subject: [PATCH 109/256] Fix Windows pipeline image to use windows.vs2022.amd64.open (#14492) * Fix Windows pipeline image to use windows.vs2022.amd64.open * Use windows.vs2026preview.scout.amd64 for public pipeline Windows pool Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/templates/public-pipeline-template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/pipelines/templates/public-pipeline-template.yml b/eng/pipelines/templates/public-pipeline-template.yml index 5c394939d03..e560f93ed19 100644 --- a/eng/pipelines/templates/public-pipeline-template.yml +++ b/eng/pipelines/templates/public-pipeline-template.yml @@ -86,7 +86,7 @@ stages: pool: name: $(DncEngPublicBuildPool) - demands: ImageOverride -equals 1es-windows-2022-open + demands: ImageOverride -equals windows.vs2026preview.scout.amd64.open variables: - name: _buildScript From 24e33c914b9cb07037a50a9b89ee7ad60ab5681a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:02:24 +0000 Subject: [PATCH 110/256] Add Azure portal link for Resource Group in deploy pipeline summary (#14434) * Add Azure portal link for Resource Group in pipeline summary When printing the Resource Group in the pipeline summary of `aspire deploy`, include a clickable link to the Azure portal resource group page. The link uses the format: https://portal.azure.com/#@{tenantId}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview Changes: - AzureEnvironmentResource.AddToPipelineSummary: construct markdown link for resource group - ConsoleActivityLogger.FormatPipelineSummaryKvp: convert markdown to Spectre markup for clickable links - Add ConsoleActivityLoggerTests for the new markdown rendering behavior Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> * Clean up the code * Fix tests * More test fixups * Refactor code * Update src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add test for color-enabled non-interactive rendering path Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> * fix test --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> Co-authored-by: Eric Erhardt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Utils/ConsoleActivityLogger.cs | 9 +- .../Utils/MarkdownToSpectreConverter.cs | 10 ++ .../AzureEnvironmentResource.cs | 15 +- .../Utils/ConsoleActivityLoggerTests.cs | 141 ++++++++++++++++++ 4 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs diff --git a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs index 5a723f60c2a..5c1153ddd55 100644 --- a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs +++ b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs @@ -304,18 +304,21 @@ public void WriteSummary() /// /// Formats a single key-value pair for the pipeline summary display. + /// Values may contain markdown links which are converted to clickable links when supported. /// private string FormatPipelineSummaryKvp(string key, string value) { if (_enableColor) { var escapedKey = key.EscapeMarkup(); - var escapedValue = value.EscapeMarkup(); - return $" [blue]{escapedKey}[/]: {escapedValue}"; + var convertedValue = MarkdownToSpectreConverter.ConvertToSpectre(value); + convertedValue = HighlightMessage(convertedValue); + return $" [blue]{escapedKey}[/]: {convertedValue}"; } else { - return $" {key}: {value}"; + var plainValue = MarkdownToSpectreConverter.ConvertLinksToPlainText(value); + return $" {key}: {plainValue}"; } } diff --git a/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs b/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs index bc8d3d5c6fd..000fdfcc437 100644 --- a/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs +++ b/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs @@ -61,6 +61,16 @@ public static string ConvertToSpectre(string markdown) return result; } + /// + /// Converts markdown links to plain text. + /// + /// The markdown text to convert. + /// The text with markdown links converted to the plain text format text (url). + public static string ConvertLinksToPlainText(string markdown) + { + return LinkRegex().Replace(markdown, "$1 ($2)"); + } + private static string ConvertHeaders(string text) { // Convert ###### Header 6 (most specific first) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 19a62a9e3e5..7325e690d4b 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -133,18 +133,19 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet /// The Azure provisioning context. private static void AddToPipelineSummary(PipelineStepContext ctx, ProvisioningContext provisioningContext) { - // Safely access the nested properties with null checks for reference types - // AzureLocation is a struct so it cannot be null - var resourceGroupName = provisioningContext.ResourceGroup?.Name ?? "unknown"; - var subscriptionId = provisioningContext.Subscription?.Id.Name ?? "unknown"; + var resourceGroupName = provisioningContext.ResourceGroup.Name; + var subscriptionId = provisioningContext.Subscription.Id.Name; var location = provisioningContext.Location.Name; -#pragma warning disable ASPIREPIPELINES001 // PipelineSummary is experimental + var tenantId = provisioningContext.Tenant.TenantId; + var tenantSegment = tenantId.HasValue ? $"#@{tenantId.Value}" : "#"; + var portalUrl = $"https://portal.azure.com/{tenantSegment}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview"; + var resourceGroupValue = $"[{resourceGroupName}]({portalUrl})"; + ctx.Summary.Add("☁️ Target", "Azure"); - ctx.Summary.Add("📦 Resource Group", resourceGroupName); + ctx.Summary.Add("📦 Resource Group", resourceGroupValue); ctx.Summary.Add("🔑 Subscription", subscriptionId); ctx.Summary.Add("🌐 Location", location); -#pragma warning restore ASPIREPIPELINES001 } private Task PublishAsync(PipelineStepContext context) diff --git a/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs new file mode 100644 index 00000000000..9410d688b19 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs @@ -0,0 +1,141 @@ +// 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; +using Aspire.Cli.Utils; +using Spectre.Console; + +namespace Aspire.Cli.Tests.Utils; + +public class ConsoleActivityLoggerTests +{ + [Fact] + public void WriteSummary_WithMarkdownLinkInPipelineSummary_RendersClickableLink() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var hostEnvironment = TestHelpers.CreateInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: true); + + var summary = new List> + { + new("☁️ Target", "Azure"), + new("📦 Resource Group", "VNetTest5 [link](https://portal.azure.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview)"), + new("🔑 Subscription", "sub-id"), + new("🌐 Location", "eastus"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + // Verify the markdown link was converted to a Spectre link + Assert.Contains("VNetTest5", result); + + const string expectedUrl = + @"https://portal\.azure\.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview"; + string hyperlinkPattern = + $@"\u001b\]8;[^;]*;{expectedUrl}\u001b\\.*link.*\u001b\]8;;\u001b\\"; + Assert.Matches(hyperlinkPattern, result); + } + + [Fact] + public void WriteSummary_WithMarkdownLinkInPipelineSummary_NoColor_RendersPlainTextWithUrl() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var hostEnvironment = TestHelpers.CreateNonInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: false); + + var portalUrl = "https://portal.azure.com/"; + var summary = new List> + { + new("📦 Resource Group", $"VNetTest5 [link]({portalUrl})"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + // When color is disabled, markdown links should be converted to plain text: text (url) + Assert.Contains($"VNetTest5 link ({portalUrl})", result); + } + + [Fact] + public void WriteSummary_WithMarkdownLinkInPipelineSummary_ColorWithoutInteractive_RendersPlainUrl() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + // Non-interactive host but color enabled (e.g., CI environments with ANSI support) + var hostEnvironment = TestHelpers.CreateNonInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: true); + + var portalUrl = "https://portal.azure.com/"; + var summary = new List> + { + new("📦 Resource Group", $"VNetTest5 [link]({portalUrl})"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + // When color is enabled but interactive output is not supported, + // HighlightMessage converts Spectre link markup to plain URLs + Assert.Contains("VNetTest5", result); + Assert.Contains(portalUrl, result); + + // Should NOT contain the OSC 8 hyperlink escape sequence since we're non-interactive + Assert.DoesNotContain("\u001b]8;", result); + } + + [Fact] + public void WriteSummary_WithPlainTextPipelineSummary_RendersCorrectly() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var hostEnvironment = TestHelpers.CreateInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: true); + + var summary = new List> + { + new("☁️ Target", "Azure"), + new("🌐 Location", "eastus"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + Assert.Contains("Azure", result); + Assert.Contains("eastus", result); + } +} From 6905510377d941187ff6e0ac7f3e57b62ec3110c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Tue, 17 Feb 2026 16:39:22 -0800 Subject: [PATCH 111/256] Fix MCP server tools/list infinite loop caused by notification race condition (#14494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix tools/list infinite loop by removing notification from ListTools handler and adding change detection The MCP server was entering an infinite tools/list loop because: 1. HandleListToolsAsync sent tools/list_changed notification after refreshing 2. The client responded with another tools/list request 3. This created a feedback loop: list → changed → list → changed Fix: - Remove SendToolsListChangedNotificationAsync from HandleListToolsAsync (the client already gets the fresh list since it requested it) - Add change detection to RefreshResourceToolMapAsync (returns bool Changed) - Only send tools/list_changed in HandleCallToolAsync when tools actually changed - RefreshToolsTool always sends notification (explicit user action) Co-authored-by: maddymontaquila <12660687+maddymontaquila@users.noreply.github.com> * Use HashSet.SetEquals for more efficient tool change detection Co-authored-by: maddymontaquila <12660687+maddymontaquila@users.noreply.github.com> * Address review feedback: use no-allocation key comparison and bounded channel wait in test - Replace HashSet.SetEquals with count check + iterate keys + ContainsKey to avoid allocation under lock in McpResourceToolRefreshService. - Replace Task.Delay(200) in test with Channel.ReadAsync + CancellationTokenSource timeout for more deterministic negative assertion. Co-authored-by: sebastienros <1165805+sebastienros@users.noreply.github.com> * Check for both new and deleted tools in change detection The previous change detection only iterated old→new keys, missing the case where tools are swapped (same count but different keys). Now also checks new→old to detect newly added tools. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clean up comments in AgentMcpCommandTests Removed comments about using TryRead for notifications. * Fix resource name mapping --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: maddymontaquila <12660687+maddymontaquila@users.noreply.github.com> Co-authored-by: sebastienros <1165805+sebastienros@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dependency-reduced-pom.xml | 39 +++++ src/Aspire.Cli/Commands/AgentMcpCommand.cs | 16 +- .../Mcp/IMcpResourceToolRefreshService.cs | 4 +- .../Mcp/McpResourceToolRefreshService.cs | 41 ++++- src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs | 2 +- .../Commands/AgentMcpCommandTests.cs | 161 +++++++++++++++++- 6 files changed, 246 insertions(+), 17 deletions(-) diff --git a/playground/PostgresEndToEnd/PostgresEndToEnd.JavaService/dependency-reduced-pom.xml b/playground/PostgresEndToEnd/PostgresEndToEnd.JavaService/dependency-reduced-pom.xml index ceb176d9460..75ebb7ec18a 100644 --- a/playground/PostgresEndToEnd/PostgresEndToEnd.JavaService/dependency-reduced-pom.xml +++ b/playground/PostgresEndToEnd/PostgresEndToEnd.JavaService/dependency-reduced-pom.xml @@ -43,6 +43,45 @@ + + + + org.eclipse.jetty + jetty-server + 9.4.57.v20241219 + + + org.postgresql + postgresql + 42.7.2 + + + io.netty + netty-codec-http2 + 4.1.124.Final + + + com.nimbusds + nimbus-jose-jwt + 9.37.2 + + + io.projectreactor.netty + reactor-netty-core + 1.0.39 + + + io.projectreactor.netty + reactor-netty-http + 1.0.39 + + + io.netty + netty-handler + 4.1.118.Final + + + 17 17 diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index bb239d4ee48..3aff0911c2a 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -147,8 +147,12 @@ private async ValueTask HandleListToolsAsync(RequestContext new Tool @@ -193,8 +197,12 @@ private async ValueTask HandleCallToolAsync(RequestContext /// The cancellation token. - /// The refreshed resource tool map. - Task> RefreshResourceToolMapAsync(CancellationToken cancellationToken); + /// A tuple containing the refreshed resource tool map and a flag indicating whether the tool set changed. + Task<(IReadOnlyDictionary ToolMap, bool Changed)> RefreshResourceToolMapAsync(CancellationToken cancellationToken); /// /// Sends a tools list changed notification to connected MCP clients. diff --git a/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs b/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs index 95ecf656214..e3d3c368333 100644 --- a/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs +++ b/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs @@ -71,7 +71,7 @@ public async Task SendToolsListChangedNotificationAsync(CancellationToken cancel } /// - public async Task> RefreshResourceToolMapAsync(CancellationToken cancellationToken) + public async Task<(IReadOnlyDictionary ToolMap, bool Changed)> RefreshResourceToolMapAsync(CancellationToken cancellationToken) { _logger.LogDebug("Refreshing resource tool map."); @@ -95,10 +95,15 @@ public async Task> RefreshResourc { Debug.Assert(resource.McpServer is not null); + // Use DisplayName (the app-model name, e.g. "db1-mcp") rather than Name + // (the DCP runtime ID, e.g. "db1-mcp-ypnvhwvw") because the AppHost resolves + // resources by their app-model name in CallResourceMcpToolAsync. + var routedResourceName = resource.DisplayName ?? resource.Name; + foreach (var tool in resource.McpServer.Tools) { - var exposedName = $"{resource.Name.Replace("-", "_")}_{tool.Name}"; - refreshedMap[exposedName] = new ResourceToolEntry(resource.Name, tool); + var exposedName = $"{routedResourceName.Replace("-", "_")}_{tool.Name}"; + refreshedMap[exposedName] = new ResourceToolEntry(routedResourceName, tool); _logger.LogDebug("{Tool}: {Description}", exposedName, tool.Description); } @@ -117,10 +122,38 @@ public async Task> RefreshResourc lock (_lock) { + var changed = _resourceToolMap.Count != refreshedMap.Count; + if (!changed) + { + // Check for deleted tools (in old but not in new). + foreach (var key in _resourceToolMap.Keys) + { + if (!refreshedMap.ContainsKey(key)) + { + changed = true; + break; + } + } + + // Check for new tools (in new but not in old). + if (!changed) + { + foreach (var key in refreshedMap.Keys) + { + if (!_resourceToolMap.ContainsKey(key)) + { + changed = true; + break; + } + } + } + } + _resourceToolMap = refreshedMap; _selectedAppHostPath = selectedAppHostPath; _invalidated = false; - return _resourceToolMap; + return (_resourceToolMap, changed); } } + } diff --git a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs index c8d3d5c4fef..e0a919c7333 100644 --- a/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/RefreshToolsTool.cs @@ -19,7 +19,7 @@ public override JsonElement GetInputSchema() public override async ValueTask CallToolAsync(CallToolContext context, CancellationToken cancellationToken) { - var resourceToolMap = await refreshService.RefreshResourceToolMapAsync(cancellationToken).ConfigureAwait(false); + var (resourceToolMap, _) = await refreshService.RefreshResourceToolMapAsync(cancellationToken).ConfigureAwait(false); await refreshService.SendToolsListChangedNotificationAsync(cancellationToken).ConfigureAwait(false); var totalToolCount = KnownMcpTools.All.Count + resourceToolMap.Count; diff --git a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs index dd7cb3e3587..a4110121431 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentMcpCommandTests.cs @@ -165,8 +165,8 @@ public async Task McpServer_ListTools_IncludesResourceMcpTools() [ new ResourceSnapshot { - Name = "test-resource", - DisplayName = "Test Resource", + Name = "test-resource-abcd1234", + DisplayName = "test-resource", ResourceType = "Container", State = "Running", McpServer = new ResourceSnapshotMcpServer @@ -202,8 +202,8 @@ public async Task McpServer_ListTools_IncludesResourceMcpTools() // Assert - Verify resource tools are included Assert.NotNull(tools); - // The resource tools should be exposed with a prefixed name: {resource_name}_{tool_name} - // Resource name "test-resource" becomes "test_resource" (dashes replaced with underscores) + // The resource tools should be exposed with a prefixed name using the DisplayName (app-model name): + // DisplayName "test-resource" becomes "test_resource" (dashes replaced with underscores) var resourceToolOne = tools.FirstOrDefault(t => t.Name == "test_resource_resource_tool_one"); var resourceToolTwo = tools.FirstOrDefault(t => t.Name == "test_resource_resource_tool_two"); @@ -235,8 +235,8 @@ public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult() [ new ResourceSnapshot { - Name = "my-resource", - DisplayName = "My Resource", + Name = "my-resource-abcd1234", + DisplayName = "my-resource", ResourceType = "Container", State = "Running", McpServer = new ResourceSnapshotMcpServer @@ -291,6 +291,69 @@ public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult() Assert.Equal("do_something", callToolName); } + [Fact] + public async Task McpServer_CallTool_ResourceMcpTool_UsesDisplayNameForRouting() + { + // Arrange - Simulate resource snapshots that use a unique resource id and a logical display name. + var expectedToolResult = "List schemas completed"; + string? callResourceName = null; + string? callToolName = null; + + var mockBackchannel = new TestAppHostAuxiliaryBackchannel + { + Hash = "test-apphost-hash", + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + ProcessId = 12345 + }, + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = "db1-mcp-ypnvhwvw", + DisplayName = "db1-mcp", + ResourceType = "Container", + State = "Running", + McpServer = new ResourceSnapshotMcpServer + { + EndpointUrl = "http://localhost:8080/mcp", + Tools = + [ + new Tool + { + Name = "list_schemas", + Description = "Lists database schemas" + } + ] + } + } + ], + CallResourceMcpToolHandler = (resourceName, toolName, arguments, ct) => + { + callResourceName = resourceName; + callToolName = toolName; + return Task.FromResult(new CallToolResult + { + Content = [new TextContentBlock { Text = expectedToolResult }] + }); + } + }; + + _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); + await _mcpClient.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: _cts.Token).DefaultTimeout(); + + // Act + var result = await _mcpClient.CallToolAsync("db1_mcp_list_schemas", cancellationToken: _cts.Token).DefaultTimeout(); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}"); + Assert.Equal("db1-mcp", callResourceName); + Assert.Equal("list_schemas", callToolName); + } + [Fact] public async Task McpServer_CallTool_ListAppHosts_ReturnsResult() { @@ -347,6 +410,92 @@ public async Task McpServer_CallTool_RefreshTools_ReturnsResult() Assert.Equal(NotificationMethods.ToolListChangedNotification, notification.Method); } + [Fact] + public async Task McpServer_ListTools_DoesNotSendToolsListChangedNotification() + { + // Arrange - Create a mock backchannel with a resource that has MCP tools + // This simulates the db-mcp scenario where resource tools become available + var mockBackchannel = new TestAppHostAuxiliaryBackchannel + { + Hash = "test-apphost-hash", + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + ProcessId = 12345 + }, + ResourceSnapshots = + [ + new ResourceSnapshot + { + Name = "db-mcp-abcd1234", + DisplayName = "db-mcp", + ResourceType = "Container", + State = "Running", + McpServer = new ResourceSnapshotMcpServer + { + EndpointUrl = "http://localhost:8080/mcp", + Tools = + [ + new Tool + { + Name = "query_database", + Description = "Query a database" + } + ] + } + } + ] + }; + + // Register the mock backchannel so resource tools will be discovered + _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); + + // Set up a channel to detect any tools/list_changed notifications + var notificationCount = 0; + await using var notificationHandler = _mcpClient.RegisterNotificationHandler( + NotificationMethods.ToolListChangedNotification, + (notification, cancellationToken) => + { + Interlocked.Increment(ref notificationCount); + return default; + }); + + // Act - Call ListTools which should discover the resource tools via refresh + // but should NOT send a tools/list_changed notification (that would cause an infinite loop) + var tools = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout(); + + // Assert - tools should include the resource tool + Assert.NotNull(tools); + var dbMcpTool = tools.FirstOrDefault(t => t.Name == "db_mcp_query_database"); + Assert.NotNull(dbMcpTool); + + // Assert - no tools/list_changed notification should have been sent. + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + var notificationChannel = Channel.CreateUnbounded(); + await using var channelHandler = _mcpClient.RegisterNotificationHandler( + NotificationMethods.ToolListChangedNotification, + (notification, _) => + { + notificationChannel.Writer.TryWrite(notification); + return default; + }); + + var received = false; + try + { + await notificationChannel.Reader.ReadAsync(timeoutCts.Token); + received = true; + } + catch (OperationCanceledException) + { + // Expected — no notification arrived within the timeout + } + + Assert.False(received, "tools/list_changed notification should not be sent during tools/list handling"); + Assert.Equal(0, notificationCount); + } + [Fact] public async Task McpServer_CallTool_UnknownTool_ReturnsError() { From edad21be8c631382d5b579e31dc5d0324c6c2df2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:51:17 -0500 Subject: [PATCH 112/256] [Automated] Backmerge release/13.2 to main (#14536) * Fix Windows pipeline image to use windows.vs2022.amd64.open (#14492) * Fix Windows pipeline image to use windows.vs2022.amd64.open * Use windows.vs2026preview.scout.amd64 for public pipeline Windows pool Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Azure portal link for Resource Group in deploy pipeline summary (#14434) * Add Azure portal link for Resource Group in pipeline summary When printing the Resource Group in the pipeline summary of `aspire deploy`, include a clickable link to the Azure portal resource group page. The link uses the format: https://portal.azure.com/#@{tenantId}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview Changes: - AzureEnvironmentResource.AddToPipelineSummary: construct markdown link for resource group - ConsoleActivityLogger.FormatPipelineSummaryKvp: convert markdown to Spectre markup for clickable links - Add ConsoleActivityLoggerTests for the new markdown rendering behavior Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> * Clean up the code * Fix tests * More test fixups * Refactor code * Update src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add test for color-enabled non-interactive rendering path Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> * fix test --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> Co-authored-by: Eric Erhardt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Jose Perez Rodriguez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> Co-authored-by: Eric Erhardt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .../templates/public-pipeline-template.yml | 2 +- src/Aspire.Cli/Utils/ConsoleActivityLogger.cs | 9 +- .../Utils/MarkdownToSpectreConverter.cs | 10 ++ .../AzureEnvironmentResource.cs | 15 +- .../Utils/ConsoleActivityLoggerTests.cs | 141 ++++++++++++++++++ 5 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs diff --git a/eng/pipelines/templates/public-pipeline-template.yml b/eng/pipelines/templates/public-pipeline-template.yml index 5c394939d03..e560f93ed19 100644 --- a/eng/pipelines/templates/public-pipeline-template.yml +++ b/eng/pipelines/templates/public-pipeline-template.yml @@ -86,7 +86,7 @@ stages: pool: name: $(DncEngPublicBuildPool) - demands: ImageOverride -equals 1es-windows-2022-open + demands: ImageOverride -equals windows.vs2026preview.scout.amd64.open variables: - name: _buildScript diff --git a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs index 5a723f60c2a..5c1153ddd55 100644 --- a/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs +++ b/src/Aspire.Cli/Utils/ConsoleActivityLogger.cs @@ -304,18 +304,21 @@ public void WriteSummary() /// /// Formats a single key-value pair for the pipeline summary display. + /// Values may contain markdown links which are converted to clickable links when supported. /// private string FormatPipelineSummaryKvp(string key, string value) { if (_enableColor) { var escapedKey = key.EscapeMarkup(); - var escapedValue = value.EscapeMarkup(); - return $" [blue]{escapedKey}[/]: {escapedValue}"; + var convertedValue = MarkdownToSpectreConverter.ConvertToSpectre(value); + convertedValue = HighlightMessage(convertedValue); + return $" [blue]{escapedKey}[/]: {convertedValue}"; } else { - return $" {key}: {value}"; + var plainValue = MarkdownToSpectreConverter.ConvertLinksToPlainText(value); + return $" {key}: {plainValue}"; } } diff --git a/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs b/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs index bc8d3d5c6fd..000fdfcc437 100644 --- a/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs +++ b/src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs @@ -61,6 +61,16 @@ public static string ConvertToSpectre(string markdown) return result; } + /// + /// Converts markdown links to plain text. + /// + /// The markdown text to convert. + /// The text with markdown links converted to the plain text format text (url). + public static string ConvertLinksToPlainText(string markdown) + { + return LinkRegex().Replace(markdown, "$1 ($2)"); + } + private static string ConvertHeaders(string text) { // Convert ###### Header 6 (most specific first) diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 19a62a9e3e5..7325e690d4b 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -133,18 +133,19 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet /// The Azure provisioning context. private static void AddToPipelineSummary(PipelineStepContext ctx, ProvisioningContext provisioningContext) { - // Safely access the nested properties with null checks for reference types - // AzureLocation is a struct so it cannot be null - var resourceGroupName = provisioningContext.ResourceGroup?.Name ?? "unknown"; - var subscriptionId = provisioningContext.Subscription?.Id.Name ?? "unknown"; + var resourceGroupName = provisioningContext.ResourceGroup.Name; + var subscriptionId = provisioningContext.Subscription.Id.Name; var location = provisioningContext.Location.Name; -#pragma warning disable ASPIREPIPELINES001 // PipelineSummary is experimental + var tenantId = provisioningContext.Tenant.TenantId; + var tenantSegment = tenantId.HasValue ? $"#@{tenantId.Value}" : "#"; + var portalUrl = $"https://portal.azure.com/{tenantSegment}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview"; + var resourceGroupValue = $"[{resourceGroupName}]({portalUrl})"; + ctx.Summary.Add("☁️ Target", "Azure"); - ctx.Summary.Add("📦 Resource Group", resourceGroupName); + ctx.Summary.Add("📦 Resource Group", resourceGroupValue); ctx.Summary.Add("🔑 Subscription", subscriptionId); ctx.Summary.Add("🌐 Location", location); -#pragma warning restore ASPIREPIPELINES001 } private Task PublishAsync(PipelineStepContext context) diff --git a/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs new file mode 100644 index 00000000000..9410d688b19 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/ConsoleActivityLoggerTests.cs @@ -0,0 +1,141 @@ +// 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; +using Aspire.Cli.Utils; +using Spectre.Console; + +namespace Aspire.Cli.Tests.Utils; + +public class ConsoleActivityLoggerTests +{ + [Fact] + public void WriteSummary_WithMarkdownLinkInPipelineSummary_RendersClickableLink() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var hostEnvironment = TestHelpers.CreateInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: true); + + var summary = new List> + { + new("☁️ Target", "Azure"), + new("📦 Resource Group", "VNetTest5 [link](https://portal.azure.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview)"), + new("🔑 Subscription", "sub-id"), + new("🌐 Location", "eastus"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + // Verify the markdown link was converted to a Spectre link + Assert.Contains("VNetTest5", result); + + const string expectedUrl = + @"https://portal\.azure\.com/#/resource/subscriptions/sub-id/resourceGroups/VNetTest5/overview"; + string hyperlinkPattern = + $@"\u001b\]8;[^;]*;{expectedUrl}\u001b\\.*link.*\u001b\]8;;\u001b\\"; + Assert.Matches(hyperlinkPattern, result); + } + + [Fact] + public void WriteSummary_WithMarkdownLinkInPipelineSummary_NoColor_RendersPlainTextWithUrl() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var hostEnvironment = TestHelpers.CreateNonInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: false); + + var portalUrl = "https://portal.azure.com/"; + var summary = new List> + { + new("📦 Resource Group", $"VNetTest5 [link]({portalUrl})"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + // When color is disabled, markdown links should be converted to plain text: text (url) + Assert.Contains($"VNetTest5 link ({portalUrl})", result); + } + + [Fact] + public void WriteSummary_WithMarkdownLinkInPipelineSummary_ColorWithoutInteractive_RendersPlainUrl() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + // Non-interactive host but color enabled (e.g., CI environments with ANSI support) + var hostEnvironment = TestHelpers.CreateNonInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: true); + + var portalUrl = "https://portal.azure.com/"; + var summary = new List> + { + new("📦 Resource Group", $"VNetTest5 [link]({portalUrl})"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + // When color is enabled but interactive output is not supported, + // HighlightMessage converts Spectre link markup to plain URLs + Assert.Contains("VNetTest5", result); + Assert.Contains(portalUrl, result); + + // Should NOT contain the OSC 8 hyperlink escape sequence since we're non-interactive + Assert.DoesNotContain("\u001b]8;", result); + } + + [Fact] + public void WriteSummary_WithPlainTextPipelineSummary_RendersCorrectly() + { + var output = new StringBuilder(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.TrueColor, + Out = new AnsiConsoleOutput(new StringWriter(output)) + }); + + var hostEnvironment = TestHelpers.CreateInteractiveHostEnvironment(); + var logger = new ConsoleActivityLogger(console, hostEnvironment, forceColor: true); + + var summary = new List> + { + new("☁️ Target", "Azure"), + new("🌐 Location", "eastus"), + }; + + logger.SetFinalResult(true, summary); + logger.WriteSummary(); + + var result = output.ToString(); + + Assert.Contains("Azure", result); + Assert.Contains("eastus", result); + } +} From 10705f2269c73500a98d5fa1199ceeb54f0d73d7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:32:21 +0000 Subject: [PATCH 113/256] [Automated] Update AI Foundry Models (#14541) Co-authored-by: sebastienros --- .../AIFoundryModel.Generated.cs | 10 +++++ .../AIFoundryModel.Local.Generated.cs | 44 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs b/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs index fe76d906051..d5a4f25785b 100644 --- a/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs +++ b/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs @@ -52,6 +52,11 @@ public static partial class Anthropic /// Claude Sonnet 4.5 is Anthropic's most capable model for complex agents and an industry leader for coding and computer use. /// public static readonly AIFoundryModel ClaudeSonnet45 = new() { Name = "claude-sonnet-4-5", Version = "20250929", Format = "Anthropic" }; + + /// + /// Claude Sonnet 4.6 delivers frontier intelligence at scale—built for coding, agents, and enterprise workflows. With a 1M token context window (beta) and 128K max output, Sonnet 4.6 is ideal for coding, agents, office tasks, financial analysis, cybersecurity + /// + public static readonly AIFoundryModel ClaudeSonnet46 = new() { Name = "claude-sonnet-4-6", Version = "1", Format = "Anthropic" }; } /// @@ -1400,6 +1405,11 @@ public static partial class MistralAI /// public static readonly AIFoundryModel MistralDocumentAi2505 = new() { Name = "mistral-document-ai-2505", Version = "1", Format = "Mistral AI" }; + /// + /// Document conversion to markdown with interleaved images and text + /// + public static readonly AIFoundryModel MistralDocumentAi2512 = new() { Name = "mistral-document-ai-2512", Version = "1", Format = "Mistral AI" }; + /// /// Mistral Large (2407) is an advanced Large Language Model (LLM) with state-of-the-art reasoning, knowledge and coding capabilities. /// diff --git a/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Local.Generated.cs b/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Local.Generated.cs index 021b2e79d97..fa3df930c11 100644 --- a/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Local.Generated.cs +++ b/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Local.Generated.cs @@ -853,5 +853,49 @@ public static partial class Local /// See Hugging Face model Qwen2.5-Coder-7B-Instruct for details. /// public static readonly AIFoundryModel Qwen25Coder7b = new() { Name = "qwen2.5-coder-7b", Version = "4", Format = "Microsoft" }; + + /// + /// This model is an optimized version of Qwen3-0.6B to enable local inference. This model uses KLD Gradient quantization. + /// + /// Model Description + /// + /// + /// + /// + /// + /// Developed by: Microsoft + /// + /// + /// + /// + /// + /// Model type: ONNX + /// + /// + /// + /// + /// + /// License: apache-2.0 + /// + /// + /// + /// + /// + /// Model Description: This is a conversion of the Qwen3-0.6B for local inference. + /// + /// + /// + /// + /// + /// Disclaimer: Model is only an optimization of the base model, any risk associated with the model is the responsibility of the user of the model. Please verify and test for your scenarios. There may be a slight difference in output from the base model with the optimizations applied. Note that optimizations applied are distinct from fine tuning and thus do not alter the intended uses or capabilities of the model. + /// + /// + /// + /// + /// Base Model Information + /// + /// See Hugging Face model Qwen3-0.6B for details. + /// + public static readonly AIFoundryModel Qwen306b = new() { Name = "qwen3-0.6b", Version = "1", Format = "Microsoft" }; } } From 4ba065e930a12cbd78b74194df3520c0f9880a6d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:09:18 +0000 Subject: [PATCH 114/256] [Automated] Update AI Foundry Models (#14546) Co-authored-by: sebastienros --- .../AIFoundryModel.Generated.cs | 10 +++++ .../AIFoundryModel.Local.Generated.cs | 44 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs b/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs index fe76d906051..d5a4f25785b 100644 --- a/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs +++ b/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Generated.cs @@ -52,6 +52,11 @@ public static partial class Anthropic /// Claude Sonnet 4.5 is Anthropic's most capable model for complex agents and an industry leader for coding and computer use. /// public static readonly AIFoundryModel ClaudeSonnet45 = new() { Name = "claude-sonnet-4-5", Version = "20250929", Format = "Anthropic" }; + + /// + /// Claude Sonnet 4.6 delivers frontier intelligence at scale—built for coding, agents, and enterprise workflows. With a 1M token context window (beta) and 128K max output, Sonnet 4.6 is ideal for coding, agents, office tasks, financial analysis, cybersecurity + /// + public static readonly AIFoundryModel ClaudeSonnet46 = new() { Name = "claude-sonnet-4-6", Version = "1", Format = "Anthropic" }; } /// @@ -1400,6 +1405,11 @@ public static partial class MistralAI /// public static readonly AIFoundryModel MistralDocumentAi2505 = new() { Name = "mistral-document-ai-2505", Version = "1", Format = "Mistral AI" }; + /// + /// Document conversion to markdown with interleaved images and text + /// + public static readonly AIFoundryModel MistralDocumentAi2512 = new() { Name = "mistral-document-ai-2512", Version = "1", Format = "Mistral AI" }; + /// /// Mistral Large (2407) is an advanced Large Language Model (LLM) with state-of-the-art reasoning, knowledge and coding capabilities. /// diff --git a/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Local.Generated.cs b/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Local.Generated.cs index 021b2e79d97..fa3df930c11 100644 --- a/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Local.Generated.cs +++ b/src/Aspire.Hosting.Azure.AIFoundry/AIFoundryModel.Local.Generated.cs @@ -853,5 +853,49 @@ public static partial class Local /// See Hugging Face model Qwen2.5-Coder-7B-Instruct for details. /// public static readonly AIFoundryModel Qwen25Coder7b = new() { Name = "qwen2.5-coder-7b", Version = "4", Format = "Microsoft" }; + + /// + /// This model is an optimized version of Qwen3-0.6B to enable local inference. This model uses KLD Gradient quantization. + /// + /// Model Description + /// + /// + /// + /// + /// + /// Developed by: Microsoft + /// + /// + /// + /// + /// + /// Model type: ONNX + /// + /// + /// + /// + /// + /// License: apache-2.0 + /// + /// + /// + /// + /// + /// Model Description: This is a conversion of the Qwen3-0.6B for local inference. + /// + /// + /// + /// + /// + /// Disclaimer: Model is only an optimization of the base model, any risk associated with the model is the responsibility of the user of the model. Please verify and test for your scenarios. There may be a slight difference in output from the base model with the optimizations applied. Note that optimizations applied are distinct from fine tuning and thus do not alter the intended uses or capabilities of the model. + /// + /// + /// + /// + /// Base Model Information + /// + /// See Hugging Face model Qwen3-0.6B for details. + /// + public static readonly AIFoundryModel Qwen306b = new() { Name = "qwen3-0.6b", Version = "1", Format = "Microsoft" }; } } From e89bba56c9c6708676d3860b63a2565652c13dd9 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 18 Feb 2026 12:19:44 -0600 Subject: [PATCH 115/256] Detect CLI at default install paths when not on PATH (#14545) Check default installation directories (~/.aspire/bin, ~/.dotnet/tools) when the Aspire CLI is not found on the system PATH. If found at a default location, the VS Code setting is auto-updated. If later found on PATH, the setting is cleared. Resolution order: configured custom path > system PATH > default install paths. Fixes #14235 --- extension/loc/xlf/aspire-vscode.xlf | 3 + extension/package.nls.json | 1 + extension/src/commands/add.ts | 2 +- extension/src/commands/deploy.ts | 2 +- extension/src/commands/init.ts | 2 +- extension/src/commands/new.ts | 2 +- extension/src/commands/publish.ts | 2 +- extension/src/commands/update.ts | 2 +- .../AspireDebugConfigurationProvider.ts | 12 +- extension/src/debugger/AspireDebugSession.ts | 8 +- extension/src/extension.ts | 7 +- extension/src/loc/strings.ts | 1 + .../src/test/aspireTerminalProvider.test.ts | 76 ++----- extension/src/test/cliPath.test.ts | 211 ++++++++++++++++++ extension/src/utils/AspireTerminalProvider.ts | 17 +- extension/src/utils/cliPath.ts | 194 ++++++++++++++++ extension/src/utils/configInfoProvider.ts | 4 +- extension/src/utils/workspace.ts | 71 +++--- 18 files changed, 487 insertions(+), 130 deletions(-) create mode 100644 extension/src/test/cliPath.test.ts create mode 100644 extension/src/utils/cliPath.ts diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 73088ed32fb..bbe98c2cc15 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -10,6 +10,9 @@ Aspire CLI Version: {0}. + + Aspire CLI found at {0}. The extension will use this path. + Aspire CLI is not available on PATH. Please install it and restart VS Code. diff --git a/extension/package.nls.json b/extension/package.nls.json index 75e0719f912..03c1794715e 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -93,6 +93,7 @@ "aspire-vscode.strings.lookingForDevkitBuildTask": "C# Dev Kit is installed, looking for C# Dev Kit build task...", "aspire-vscode.strings.csharpDevKitNotInstalled": "C# Dev Kit is not installed, building using dotnet CLI...", "aspire-vscode.strings.cliNotAvailable": "Aspire CLI is not available on PATH. Please install it and restart VS Code.", + "aspire-vscode.strings.cliFoundAtDefaultPath": "Aspire CLI found at {0}. The extension will use this path.", "aspire-vscode.strings.openCliInstallInstructions": "See CLI installation instructions", "aspire-vscode.strings.dismissLabel": "Dismiss" } diff --git a/extension/src/commands/add.ts b/extension/src/commands/add.ts index 5d8bd3307a7..e1e158d7b4b 100644 --- a/extension/src/commands/add.ts +++ b/extension/src/commands/add.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function addCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('add'); + await terminalProvider.sendAspireCommandToAspireTerminal('add'); } diff --git a/extension/src/commands/deploy.ts b/extension/src/commands/deploy.ts index a40590e1891..057d419f6ca 100644 --- a/extension/src/commands/deploy.ts +++ b/extension/src/commands/deploy.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function deployCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('deploy'); + await terminalProvider.sendAspireCommandToAspireTerminal('deploy'); } diff --git a/extension/src/commands/init.ts b/extension/src/commands/init.ts index 642bfa23aa3..3d6c60e25d9 100644 --- a/extension/src/commands/init.ts +++ b/extension/src/commands/init.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from "../utils/AspireTerminalProvider"; export async function initCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('init'); + await terminalProvider.sendAspireCommandToAspireTerminal('init'); }; \ No newline at end of file diff --git a/extension/src/commands/new.ts b/extension/src/commands/new.ts index d8a26eab433..ab2936e0af3 100644 --- a/extension/src/commands/new.ts +++ b/extension/src/commands/new.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from "../utils/AspireTerminalProvider"; export async function newCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('new'); + await terminalProvider.sendAspireCommandToAspireTerminal('new'); }; diff --git a/extension/src/commands/publish.ts b/extension/src/commands/publish.ts index 181d590337a..276ea03a7a8 100644 --- a/extension/src/commands/publish.ts +++ b/extension/src/commands/publish.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function publishCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('publish'); + await terminalProvider.sendAspireCommandToAspireTerminal('publish'); } diff --git a/extension/src/commands/update.ts b/extension/src/commands/update.ts index 31ab5b9f89e..23e8070920e 100644 --- a/extension/src/commands/update.ts +++ b/extension/src/commands/update.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function updateCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('update'); + await terminalProvider.sendAspireCommandToAspireTerminal('update'); } diff --git a/extension/src/debugger/AspireDebugConfigurationProvider.ts b/extension/src/debugger/AspireDebugConfigurationProvider.ts index ba4c8d98c14..643db6ed958 100644 --- a/extension/src/debugger/AspireDebugConfigurationProvider.ts +++ b/extension/src/debugger/AspireDebugConfigurationProvider.ts @@ -1,15 +1,8 @@ import * as vscode from 'vscode'; import { defaultConfigurationName } from '../loc/strings'; -import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { checkCliAvailableOrRedirect } from '../utils/workspace'; export class AspireDebugConfigurationProvider implements vscode.DebugConfigurationProvider { - private _terminalProvider: AspireTerminalProvider; - - constructor(terminalProvider: AspireTerminalProvider) { - this._terminalProvider = terminalProvider; - } - async provideDebugConfigurations(folder: vscode.WorkspaceFolder | undefined, token?: vscode.CancellationToken): Promise { if (folder === undefined) { return []; @@ -28,9 +21,8 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise { // Check if CLI is available before starting debug session - const cliPath = this._terminalProvider.getAspireCliExecutablePath(); - const isCliAvailable = await checkCliAvailableOrRedirect(cliPath); - if (!isCliAvailable) { + const result = await checkCliAvailableOrRedirect(); + if (!result.available) { return undefined; // Cancel the debug session } diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index bc35aceeb6c..293beade0d7 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -93,14 +93,14 @@ export class AspireDebugSession implements vscode.DebugAdapter { if (isDirectory(appHostPath)) { this.sendMessageWithEmoji("📁", launchingWithDirectory(appHostPath)); - this.spawnRunCommand(args, appHostPath, noDebug); + void this.spawnRunCommand(args, appHostPath, noDebug); } else { this.sendMessageWithEmoji("📂", launchingWithAppHost(appHostPath)); const workspaceFolder = path.dirname(appHostPath); args.push('--project', appHostPath); - this.spawnRunCommand(args, workspaceFolder, noDebug); + void this.spawnRunCommand(args, workspaceFolder, noDebug); } } else if (message.command === 'disconnect' || message.command === 'terminate') { @@ -133,7 +133,7 @@ export class AspireDebugSession implements vscode.DebugAdapter { } } - spawnRunCommand(args: string[], workingDirectory: string | undefined, noDebug: boolean) { + async spawnRunCommand(args: string[], workingDirectory: string | undefined, noDebug: boolean) { const disposable = this._rpcServer.onNewConnection((client: ICliRpcClient) => { if (client.debugSessionId === this.debugSessionId) { this._rpcClient = client; @@ -143,7 +143,7 @@ export class AspireDebugSession implements vscode.DebugAdapter { spawnCliProcess( this._terminalProvider, - this._terminalProvider.getAspireCliExecutablePath(), + await this._terminalProvider.getAspireCliExecutablePath(), args, { stdoutCallback: (data) => { diff --git a/extension/src/extension.ts b/extension/src/extension.ts index f2e2c44f8eb..de001575696 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -67,7 +67,7 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(cliAddCommandRegistration, cliNewCommandRegistration, cliInitCommandRegistration, cliDeployCommandRegistration, cliPublishCommandRegistration, openTerminalCommandRegistration, configureLaunchJsonCommandRegistration); context.subscriptions.push(cliUpdateCommandRegistration, settingsCommandRegistration, openLocalSettingsCommandRegistration, openGlobalSettingsCommandRegistration, runAppHostCommandRegistration, debugAppHostCommandRegistration); - const debugConfigProvider = new AspireDebugConfigurationProvider(terminalProvider); + const debugConfigProvider = new AspireDebugConfigurationProvider(); context.subscriptions.push( vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Dynamic) ); @@ -114,9 +114,8 @@ async function tryExecuteCommand(commandName: string, terminalProvider: AspireTe const cliCheckExcludedCommands: string[] = ["aspire-vscode.settings", "aspire-vscode.configureLaunchJson"]; if (!cliCheckExcludedCommands.includes(commandName)) { - const cliPath = terminalProvider.getAspireCliExecutablePath(); - const isCliAvailable = await checkCliAvailableOrRedirect(cliPath); - if (!isCliAvailable) { + const result = await checkCliAvailableOrRedirect(); + if (!result.available) { return; } } diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 484ca92ec30..1b02e953ff7 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -71,3 +71,4 @@ export const csharpDevKitNotInstalled = vscode.l10n.t('C# Dev Kit is not install export const dismissLabel = vscode.l10n.t('Dismiss'); export const openCliInstallInstructions = vscode.l10n.t('See CLI installation instructions'); export const cliNotAvailable = vscode.l10n.t('Aspire CLI is not available on PATH. Please install it and restart VS Code.'); +export const cliFoundAtDefaultPath = (path: string) => vscode.l10n.t('Aspire CLI found at {0}. The extension will use this path.', path); diff --git a/extension/src/test/aspireTerminalProvider.test.ts b/extension/src/test/aspireTerminalProvider.test.ts index dc70ca4c3fb..fa139b51715 100644 --- a/extension/src/test/aspireTerminalProvider.test.ts +++ b/extension/src/test/aspireTerminalProvider.test.ts @@ -2,94 +2,58 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import * as sinon from 'sinon'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import * as cliPathModule from '../utils/cliPath'; suite('AspireTerminalProvider tests', () => { let terminalProvider: AspireTerminalProvider; - let configStub: sinon.SinonStub; + let resolveCliPathStub: sinon.SinonStub; let subscriptions: vscode.Disposable[]; setup(() => { subscriptions = []; terminalProvider = new AspireTerminalProvider(subscriptions); - configStub = sinon.stub(vscode.workspace, 'getConfiguration'); + resolveCliPathStub = sinon.stub(cliPathModule, 'resolveCliPath'); }); teardown(() => { - configStub.restore(); + resolveCliPathStub.restore(); subscriptions.forEach(s => s.dispose()); }); suite('getAspireCliExecutablePath', () => { - test('returns "aspire" when no custom path is configured', () => { - configStub.returns({ - get: sinon.stub().returns('') - }); + test('returns "aspire" when CLI is on PATH', async () => { + resolveCliPathStub.resolves({ cliPath: 'aspire', available: true, source: 'path' }); - const result = terminalProvider.getAspireCliExecutablePath(); + const result = await terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, 'aspire'); }); - test('returns custom path when configured', () => { - configStub.returns({ - get: sinon.stub().returns('/usr/local/bin/aspire') - }); + test('returns resolved path when CLI found at default install location', async () => { + resolveCliPathStub.resolves({ cliPath: '/home/user/.aspire/bin/aspire', available: true, source: 'default-install' }); - const result = terminalProvider.getAspireCliExecutablePath(); - assert.strictEqual(result, '/usr/local/bin/aspire'); + const result = await terminalProvider.getAspireCliExecutablePath(); + assert.strictEqual(result, '/home/user/.aspire/bin/aspire'); }); - test('returns custom path with spaces', () => { - configStub.returns({ - get: sinon.stub().returns('/my path/with spaces/aspire') - }); - - const result = terminalProvider.getAspireCliExecutablePath(); - assert.strictEqual(result, '/my path/with spaces/aspire'); - }); + test('returns configured custom path', async () => { + resolveCliPathStub.resolves({ cliPath: '/usr/local/bin/aspire', available: true, source: 'configured' }); - test('trims whitespace from configured path', () => { - configStub.returns({ - get: sinon.stub().returns(' /usr/local/bin/aspire ') - }); - - const result = terminalProvider.getAspireCliExecutablePath(); + const result = await terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, '/usr/local/bin/aspire'); }); - test('returns "aspire" when configured path is only whitespace', () => { - configStub.returns({ - get: sinon.stub().returns(' ') - }); + test('returns "aspire" when CLI is not found', async () => { + resolveCliPathStub.resolves({ cliPath: 'aspire', available: false, source: 'not-found' }); - const result = terminalProvider.getAspireCliExecutablePath(); + const result = await terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, 'aspire'); }); - test('handles Windows-style paths', () => { - configStub.returns({ - get: sinon.stub().returns('C:\\Program Files\\Aspire\\aspire.exe') - }); + test('handles Windows-style paths', async () => { + resolveCliPathStub.resolves({ cliPath: 'C:\\Program Files\\Aspire\\aspire.exe', available: true, source: 'configured' }); - const result = terminalProvider.getAspireCliExecutablePath(); + const result = await terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, 'C:\\Program Files\\Aspire\\aspire.exe'); }); - - test('handles Windows-style paths without spaces', () => { - configStub.returns({ - get: sinon.stub().returns('C:\\aspire\\aspire.exe') - }); - - const result = terminalProvider.getAspireCliExecutablePath(); - assert.strictEqual(result, 'C:\\aspire\\aspire.exe'); - }); - - test('handles paths with special characters', () => { - configStub.returns({ - get: sinon.stub().returns('/path/with$dollar/aspire') - }); - - const result = terminalProvider.getAspireCliExecutablePath(); - assert.strictEqual(result, '/path/with$dollar/aspire'); - }); }); }); diff --git a/extension/src/test/cliPath.test.ts b/extension/src/test/cliPath.test.ts new file mode 100644 index 00000000000..e70519b3ebe --- /dev/null +++ b/extension/src/test/cliPath.test.ts @@ -0,0 +1,211 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as os from 'os'; +import * as path from 'path'; +import { getDefaultCliInstallPaths, resolveCliPath, CliPathDependencies } from '../utils/cliPath'; + +const bundlePath = '/home/user/.aspire/bin/aspire'; +const globalToolPath = '/home/user/.dotnet/tools/aspire'; +const defaultPaths = [bundlePath, globalToolPath]; + +function createMockDeps(overrides: Partial = {}): CliPathDependencies { + return { + getConfiguredPath: () => '', + getDefaultPaths: () => defaultPaths, + isOnPath: async () => false, + findAtDefaultPath: async () => undefined, + tryExecute: async () => false, + setConfiguredPath: async () => {}, + ...overrides, + }; +} + +suite('utils/cliPath tests', () => { + + suite('getDefaultCliInstallPaths', () => { + test('returns bundle path (~/.aspire/bin) as first entry', () => { + const paths = getDefaultCliInstallPaths(); + const homeDir = os.homedir(); + + assert.ok(paths.length >= 2, 'Should return at least 2 default paths'); + assert.ok(paths[0].startsWith(path.join(homeDir, '.aspire', 'bin')), `First path should be bundle install: ${paths[0]}`); + }); + + test('returns global tool path (~/.dotnet/tools) as second entry', () => { + const paths = getDefaultCliInstallPaths(); + const homeDir = os.homedir(); + + assert.ok(paths[1].startsWith(path.join(homeDir, '.dotnet', 'tools')), `Second path should be global tool: ${paths[1]}`); + }); + + test('uses correct executable name for current platform', () => { + const paths = getDefaultCliInstallPaths(); + + for (const p of paths) { + const basename = path.basename(p); + if (process.platform === 'win32') { + assert.strictEqual(basename, 'aspire.exe'); + } else { + assert.strictEqual(basename, 'aspire'); + } + } + }); + }); + + suite('resolveCliPath', () => { + test('falls back to default install path when CLI is not on PATH', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + isOnPath: async () => false, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.available, true); + assert.strictEqual(result.source, 'default-install'); + assert.strictEqual(result.cliPath, bundlePath); + assert.ok(setConfiguredPath.calledOnceWith(bundlePath), 'should update the VS Code setting to the found path'); + }); + + test('updates VS Code setting when CLI found at default path but not on PATH', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + getConfiguredPath: () => '', + isOnPath: async () => false, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + }); + + await resolveCliPath(deps); + + assert.ok(setConfiguredPath.calledOnce, 'setConfiguredPath should be called once'); + assert.strictEqual(setConfiguredPath.firstCall.args[0], bundlePath, 'should set the path to the found install location'); + }); + + test('prefers PATH over default install path', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + isOnPath: async () => true, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.available, true); + assert.strictEqual(result.source, 'path'); + assert.strictEqual(result.cliPath, 'aspire'); + assert.ok(setConfiguredPath.notCalled, 'should not update settings when CLI is on PATH'); + }); + + test('clears setting when CLI is on PATH and setting was previously set to a default path', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + getConfiguredPath: () => bundlePath, + isOnPath: async () => true, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.source, 'path'); + assert.ok(setConfiguredPath.calledOnceWith(''), 'should clear the setting'); + }); + + test('clears setting when CLI is on PATH and setting was previously set to global tool path', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + getConfiguredPath: () => globalToolPath, + isOnPath: async () => true, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.source, 'path'); + assert.ok(setConfiguredPath.calledOnceWith(''), 'should clear the setting'); + }); + + test('returns not-found when CLI is not on PATH and not at any default path', async () => { + const deps = createMockDeps({ + isOnPath: async () => false, + findAtDefaultPath: async () => undefined, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.available, false); + assert.strictEqual(result.source, 'not-found'); + }); + + test('uses custom configured path when valid and not a default', async () => { + const customPath = '/custom/path/aspire'; + + const deps = createMockDeps({ + getConfiguredPath: () => customPath, + tryExecute: async (p) => p === customPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.available, true); + assert.strictEqual(result.source, 'configured'); + assert.strictEqual(result.cliPath, customPath); + }); + + test('falls through to PATH check when custom configured path is invalid', async () => { + const deps = createMockDeps({ + getConfiguredPath: () => '/bad/path/aspire', + tryExecute: async () => false, + isOnPath: async () => true, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.source, 'path'); + assert.strictEqual(result.available, true); + }); + + test('falls through to default path when custom configured path is invalid and not on PATH', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + getConfiguredPath: () => '/bad/path/aspire', + tryExecute: async () => false, + isOnPath: async () => false, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.source, 'default-install'); + assert.strictEqual(result.cliPath, bundlePath); + assert.ok(setConfiguredPath.calledOnceWith(bundlePath)); + }); + + test('does not update setting when already set to the found default path', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + getConfiguredPath: () => bundlePath, + isOnPath: async () => false, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.source, 'default-install'); + assert.ok(setConfiguredPath.notCalled, 'should not re-set the path if it already matches'); + }); + }); +}); + diff --git a/extension/src/utils/AspireTerminalProvider.ts b/extension/src/utils/AspireTerminalProvider.ts index 35762287729..95ed6bf5426 100644 --- a/extension/src/utils/AspireTerminalProvider.ts +++ b/extension/src/utils/AspireTerminalProvider.ts @@ -5,6 +5,7 @@ import { RpcServerConnectionInfo } from '../server/AspireRpcServer'; import { DcpServerConnectionInfo } from '../dcp/types'; import { getRunSessionInfo, getSupportedCapabilities } from '../capabilities'; import { EnvironmentVariables } from './environment'; +import { resolveCliPath } from './cliPath'; import path from 'path'; export const enum AnsiColors { @@ -57,8 +58,8 @@ export class AspireTerminalProvider implements vscode.Disposable { this._dcpServerConnectionInfo = value; } - sendAspireCommandToAspireTerminal(subcommand: string, showTerminal: boolean = true) { - const cliPath = this.getAspireCliExecutablePath(); + async sendAspireCommandToAspireTerminal(subcommand: string, showTerminal: boolean = true) { + const cliPath = await this.getAspireCliExecutablePath(); // On Windows, use & to execute paths, especially those with special characters // On Unix, just use the path directly @@ -200,15 +201,9 @@ export class AspireTerminalProvider implements vscode.Disposable { } - getAspireCliExecutablePath(): string { - const aspireCliPath = vscode.workspace.getConfiguration('aspire').get('aspireCliExecutablePath', ''); - if (aspireCliPath && aspireCliPath.trim().length > 0) { - extensionLogOutputChannel.debug(`Using user-configured Aspire CLI path: ${aspireCliPath}`); - return aspireCliPath.trim(); - } - - extensionLogOutputChannel.debug('No user-configured Aspire CLI path found'); - return "aspire"; + async getAspireCliExecutablePath(): Promise { + const result = await resolveCliPath(); + return result.cliPath; } isCliDebugLoggingEnabled(): boolean { diff --git a/extension/src/utils/cliPath.ts b/extension/src/utils/cliPath.ts new file mode 100644 index 00000000000..6290ac6d945 --- /dev/null +++ b/extension/src/utils/cliPath.ts @@ -0,0 +1,194 @@ +import * as vscode from 'vscode'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { extensionLogOutputChannel } from './logging'; + +const execFileAsync = promisify(execFile); +const fsAccessAsync = promisify(fs.access); + +/** + * Gets the default installation paths for the Aspire CLI, in priority order. + * + * The CLI can be installed in two ways: + * 1. Bundle install (recommended): ~/.aspire/bin/aspire + * 2. .NET global tool: ~/.dotnet/tools/aspire + * + * @returns An array of default CLI paths to check, ordered by priority + */ +export function getDefaultCliInstallPaths(): string[] { + const homeDir = os.homedir(); + const exeName = process.platform === 'win32' ? 'aspire.exe' : 'aspire'; + + return [ + // Bundle install (recommended): ~/.aspire/bin/aspire + path.join(homeDir, '.aspire', 'bin', exeName), + // .NET global tool: ~/.dotnet/tools/aspire + path.join(homeDir, '.dotnet', 'tools', exeName), + ]; +} + +/** + * Checks if a file exists and is accessible. + */ +async function fileExists(filePath: string): Promise { + try { + await fsAccessAsync(filePath, fs.constants.F_OK); + return true; + } + catch { + return false; + } +} + +/** + * Tries to execute the CLI at the given path to verify it works. + */ +async function tryExecuteCli(cliPath: string): Promise { + try { + await execFileAsync(cliPath, ['--version'], { timeout: 5000 }); + return true; + } + catch { + return false; + } +} + +/** + * Checks if the Aspire CLI is available on the system PATH. + */ +export async function isCliOnPath(): Promise { + return await tryExecuteCli('aspire'); +} + +/** + * Finds the first default installation path where the Aspire CLI exists and is executable. + * + * @returns The path where CLI was found, or undefined if not found at any default location + */ +export async function findCliAtDefaultPath(): Promise { + for (const defaultPath of getDefaultCliInstallPaths()) { + if (await fileExists(defaultPath) && await tryExecuteCli(defaultPath)) { + return defaultPath; + } + } + + return undefined; +} + +/** + * Gets the VS Code configuration setting for the Aspire CLI path. + */ +export function getConfiguredCliPath(): string { + return vscode.workspace.getConfiguration('aspire').get('aspireCliExecutablePath', '').trim(); +} + +/** + * Updates the VS Code configuration setting for the Aspire CLI path. + * Uses ConfigurationTarget.Global to set it at the user level. + */ +export async function setConfiguredCliPath(cliPath: string): Promise { + extensionLogOutputChannel.info(`Setting aspire.aspireCliExecutablePath to: ${cliPath || '(empty)'}`); + await vscode.workspace.getConfiguration('aspire').update( + 'aspireCliExecutablePath', + cliPath || undefined, // Use undefined to remove the setting + vscode.ConfigurationTarget.Global + ); +} + +/** + * Result of checking CLI availability. + */ +export interface CliPathResolutionResult { + /** The resolved CLI path to use */ + cliPath: string; + /** Whether the CLI is available */ + available: boolean; + /** Where the CLI was found */ + source: 'path' | 'default-install' | 'configured' | 'not-found'; +} + +/** + * Dependencies for resolveCliPath that can be overridden for testing. + */ +export interface CliPathDependencies { + getConfiguredPath: () => string; + getDefaultPaths: () => string[]; + isOnPath: () => Promise; + findAtDefaultPath: () => Promise; + tryExecute: (cliPath: string) => Promise; + setConfiguredPath: (cliPath: string) => Promise; +} + +const defaultDependencies: CliPathDependencies = { + getConfiguredPath: getConfiguredCliPath, + getDefaultPaths: getDefaultCliInstallPaths, + isOnPath: isCliOnPath, + findAtDefaultPath: findCliAtDefaultPath, + tryExecute: tryExecuteCli, + setConfiguredPath: setConfiguredCliPath, +}; + +/** + * Resolves the Aspire CLI path, checking multiple locations in order: + * 1. User-configured path in VS Code settings + * 2. System PATH + * 3. Default installation directories (~/.aspire/bin, ~/.dotnet/tools) + * + * If the CLI is found at a default installation path but not on PATH, + * the VS Code setting is updated to use that path. + * + * If the CLI is on PATH and a setting was previously auto-configured to a default path, + * the setting is cleared to prefer PATH. + */ +export async function resolveCliPath(deps: CliPathDependencies = defaultDependencies): Promise { + const configuredPath = deps.getConfiguredPath(); + const defaultPaths = deps.getDefaultPaths(); + + // 1. Check if user has configured a custom path (not one of the defaults) + if (configuredPath && !defaultPaths.includes(configuredPath)) { + const isValid = await deps.tryExecute(configuredPath); + if (isValid) { + extensionLogOutputChannel.info(`Using user-configured Aspire CLI path: ${configuredPath}`); + return { cliPath: configuredPath, available: true, source: 'configured' }; + } + + extensionLogOutputChannel.warn(`Configured CLI path is invalid: ${configuredPath}`); + // Continue to check other locations + } + + // 2. Check if CLI is on PATH + const onPath = await deps.isOnPath(); + if (onPath) { + extensionLogOutputChannel.info('Aspire CLI found on system PATH'); + + // If we previously auto-set the path to a default install location, clear it + // since PATH is now working + if (defaultPaths.includes(configuredPath)) { + extensionLogOutputChannel.info('Clearing aspireCliExecutablePath setting since CLI is on PATH'); + await deps.setConfiguredPath(''); + } + + return { cliPath: 'aspire', available: true, source: 'path' }; + } + + // 3. Check default installation paths (~/.aspire/bin first, then ~/.dotnet/tools) + const foundPath = await deps.findAtDefaultPath(); + if (foundPath) { + extensionLogOutputChannel.info(`Aspire CLI found at default install location: ${foundPath}`); + + // Update the setting so future invocations use this path + if (configuredPath !== foundPath) { + extensionLogOutputChannel.info('Updating aspireCliExecutablePath setting to use default install location'); + await deps.setConfiguredPath(foundPath); + } + + return { cliPath: foundPath, available: true, source: 'default-install' }; + } + + // 4. CLI not found anywhere + extensionLogOutputChannel.warn('Aspire CLI not found on PATH or at default install locations'); + return { cliPath: 'aspire', available: false, source: 'not-found' }; +} diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index ca9f4ea3c64..bd342a5feb5 100644 --- a/extension/src/utils/configInfoProvider.ts +++ b/extension/src/utils/configInfoProvider.ts @@ -9,11 +9,13 @@ import * as strings from '../loc/strings'; * Gets configuration information from the Aspire CLI. */ export async function getConfigInfo(terminalProvider: AspireTerminalProvider): Promise { + const cliPath = await terminalProvider.getAspireCliExecutablePath(); + return new Promise((resolve) => { const args = ['config', 'info', '--json']; let output = ''; - spawnCliProcess(terminalProvider, terminalProvider.getAspireCliExecutablePath(), args, { + spawnCliProcess(terminalProvider, cliPath, args, { stdoutCallback: (data) => { output += data; }, diff --git a/extension/src/utils/workspace.ts b/extension/src/utils/workspace.ts index 302b11dc716..f1335aa87d4 100644 --- a/extension/src/utils/workspace.ts +++ b/extension/src/utils/workspace.ts @@ -1,13 +1,13 @@ import * as vscode from 'vscode'; -import { cliNotAvailable, dismissLabel, dontShowAgainLabel, doYouWantToSetDefaultApphost, noLabel, noWorkspaceOpen, openCliInstallInstructions, selectDefaultLaunchApphost, yesLabel } from '../loc/strings'; +import { 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, execFile } from 'child_process'; +import { ChildProcessWithoutNullStreams } from 'child_process'; import { AspireSettingsFile } from './cliTypes'; import { extensionLogOutputChannel } from './logging'; import { EnvironmentVariables } from './environment'; -import { promisify } from 'util'; +import { resolveCliPath } from './cliPath'; /** * Common file patterns to exclude from workspace file searches. @@ -158,13 +158,14 @@ export async function checkForExistingAppHostPathInWorkspace(terminalProvider: A extensionLogOutputChannel.info('Searching for AppHost projects using CLI command: aspire extension get-apphosts'); let proc: ChildProcessWithoutNullStreams; + const cliPath = await terminalProvider.getAspireCliExecutablePath(); new Promise((resolve, reject) => { const args = ['extension', 'get-apphosts']; if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') { args.push('--cli-wait-for-debugger'); } - proc = spawnCliProcess(terminalProvider, terminalProvider.getAspireCliExecutablePath(), args, { + proc = spawnCliProcess(terminalProvider, cliPath, args, { errorCallback: error => { extensionLogOutputChannel.error(`Error executing get-apphosts command: ${error}`); reject(); @@ -268,44 +269,38 @@ async function promptToAddAppHostPathToSettingsFile(result: AppHostProjectSearch extensionLogOutputChannel.info(`Successfully set appHostPath to: ${appHostToUse} in ${settingsFileLocation.fsPath}`); } -const execFileAsync = promisify(execFile); - -let cliAvailableOnPath: boolean | undefined = undefined; - /** - * Checks if the Aspire CLI is available. If not, shows a message prompting to open Aspire CLI installation steps on the repo. - * @param cliPath The path to the Aspire CLI executable - * @returns true if CLI is available, false otherwise + * Checks if the Aspire CLI is available. If not found on PATH, it checks the default + * installation directory and updates the VS Code setting accordingly. + * + * If not available, shows a message prompting to open Aspire CLI installation steps. + * @returns An object containing the CLI path to use and whether CLI is available */ -export async function checkCliAvailableOrRedirect(cliPath: string): Promise { - if (cliAvailableOnPath === true) { - // Assume, for now, that CLI availability does not change during the session if it was previously confirmed - return Promise.resolve(true); +export async function checkCliAvailableOrRedirect(): Promise<{ cliPath: string; available: boolean }> { + // Resolve CLI path fresh each time — settings or PATH may have changed + const result = await resolveCliPath(); + + if (result.available) { + // Show informational message if CLI was found at default path (not on PATH) + if (result.source === 'default-install') { + extensionLogOutputChannel.info(`Using Aspire CLI from default install location: ${result.cliPath}`); + vscode.window.showInformationMessage(cliFoundAtDefaultPath(result.cliPath)); + } + + return { cliPath: result.cliPath, available: true }; } - try { - // Remove surrounding quotes if present (both single and double quotes) - let cleanPath = cliPath.trim(); - if ((cleanPath.startsWith("'") && cleanPath.endsWith("'")) || - (cleanPath.startsWith('"') && cleanPath.endsWith('"'))) { - cleanPath = cleanPath.slice(1, -1); + // CLI not found - show error message with install instructions + vscode.window.showErrorMessage( + cliNotAvailable, + openCliInstallInstructions, + dismissLabel + ).then(selection => { + if (selection === openCliInstallInstructions) { + // Go to Aspire CLI installation instruction page in external browser + vscode.env.openExternal(vscode.Uri.parse('https://aspire.dev/get-started/install-cli/')); } - await execFileAsync(cleanPath, ['--version'], { timeout: 5000 }); - cliAvailableOnPath = true; - return true; - } catch (error) { - cliAvailableOnPath = false; - vscode.window.showErrorMessage( - cliNotAvailable, - openCliInstallInstructions, - dismissLabel - ).then(selection => { - if (selection === openCliInstallInstructions) { - // Go to Aspire CLI installation instruction page in external browser - vscode.env.openExternal(vscode.Uri.parse('https://aspire.dev/get-started/install-cli/')); - } - }); + }); - return false; - } + return { cliPath: result.cliPath, available: false }; } From 4ada2fc1bd901cf89d69fd395f4ea957bb29b799 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:18:04 -0800 Subject: [PATCH 116/256] [automated] Unquarantine stable tests with 25+ days zero failures (#14531) * Initial plan * [automated] Unquarantine stable tests - Unquarantined: DeployCommandIncludesDeployFlagInArguments - Unquarantined: GetAppHostsCommand_WithMultipleProjects_ReturnsSuccessWithAllCandidates - Unquarantined: GetAppHostsCommand_WithSingleProject_ReturnsSuccessWithValidJson - Unquarantined: PushImageToRegistry_WithRemoteRegistry_PushesImage - Unquarantined: ProcessParametersStep_ValidatesBehavior - Unquarantined: WithHttpCommand_EnablesCommandOnceResourceIsRunning These tests are being unquarantined as they have had 25+ days of quarantined run data with zero failures. Co-authored-by: radical <1472+radical@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: radical <1472+radical@users.noreply.github.com> --- tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs | 4 +--- .../Commands/ExtensionInternalCommandTests.cs | 5 +---- tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs | 1 - .../Pipelines/DistributedApplicationPipelineTests.cs | 4 +--- tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs | 2 -- 5 files changed, 3 insertions(+), 13 deletions(-) diff --git a/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs index fee7f2ccda8..e18fa6d9492 100644 --- a/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.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 System.Runtime.CompilerServices; @@ -9,7 +9,6 @@ using Aspire.Cli.Tests.TestServices; using Microsoft.Extensions.DependencyInjection; using Aspire.Cli.Utils; -using Aspire.TestUtilities; using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -269,7 +268,6 @@ public async Task DeployCommandSucceedsEndToEnd() } [Fact] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/11217")] public async Task DeployCommandIncludesDeployFlagInArguments() { using var tempRepo = TemporaryWorkspace.Create(outputHelper); diff --git a/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs index 85dc01e97f3..e46e24a6026 100644 --- a/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.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 System.Text.Json; @@ -7,7 +7,6 @@ using Aspire.Cli.Projects; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; -using Aspire.TestUtilities; using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -55,7 +54,6 @@ public async Task ExtensionInternalCommand_WithNoSubcommand_ReturnsZero() } [Fact] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/12304")] public async Task GetAppHostsCommand_WithSingleProject_ReturnsSuccessWithValidJson() { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -97,7 +95,6 @@ public async Task GetAppHostsCommand_WithSingleProject_ReturnsSuccessWithValidJs } [Fact] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/12300")] public async Task GetAppHostsCommand_WithMultipleProjects_ReturnsSuccessWithAllCandidates() { using var workspace = TemporaryWorkspace.Create(outputHelper); diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs index 15034bb96c0..ddfe4aae3b4 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs @@ -652,7 +652,6 @@ public async Task PushImageToRegistry_WithLocalRegistry_OnlyTagsImage() } [Fact] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/13878")] public async Task PushImageToRegistry_WithRemoteRegistry_PushesImage() { using var tempDir = new TestTempDirectory(); diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs index edfc9542a61..5af54367a6d 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.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. #pragma warning disable CS0618 // Type or member is obsolete @@ -13,7 +13,6 @@ using Aspire.Hosting.Pipelines; using Aspire.Hosting.Tests.Publishing; using Aspire.Hosting.Utils; -using Aspire.TestUtilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -2027,7 +2026,6 @@ public async Task FilterStepsForExecution_WithRequiredBy_IncludesTransitiveDepen } [Fact] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/13083")] public async Task ProcessParametersStep_ValidatesBehavior() { // Arrange diff --git a/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs b/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs index e0659ee6e79..daca5107af6 100644 --- a/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs +++ b/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs @@ -4,7 +4,6 @@ using System.Net; using Aspire.Hosting.Testing; using Aspire.Hosting.Utils; -using Aspire.TestUtilities; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http.Resilience; @@ -401,7 +400,6 @@ public async Task WithHttpCommand_CallsGetResponseCallback_AfterSendingRequest() } [Fact] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/8101")] public async Task WithHttpCommand_EnablesCommandOnceResourceIsRunning() { // Arrange From fa2336fa09332efb9765a1dc9ec25e81d038e5cc Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:25:54 +0000 Subject: [PATCH 117/256] Partially fix quarantined test: Update stale snapshot for DeployAsync_WithMultipleComputeEnvironments_Works (#14551) * Initial plan * Update snapshot for DeployAsync_WithMultipleComputeEnvironments_Works test Co-authored-by: radical <1472+radical@users.noreply.github.com> * Remove quarantine attribute from DeployAsync_WithMultipleComputeEnvironments_Works test Co-authored-by: radical <1472+radical@users.noreply.github.com> * Restore quarantine attribute - step="deploy" case still fails Co-authored-by: radical <1472+radical@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: radical <1472+radical@users.noreply.github.com> --- ...nments_Works_step=diagnostics.verified.txt | 100 +++++++++--------- 1 file changed, 49 insertions(+), 51 deletions(-) diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt index 0214785534c..04e5588f32e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt @@ -29,26 +29,26 @@ Steps with no dependencies run first, followed by steps that depend on them. 13. login-to-acr-aca-env-acr 14. push-prereq 15. push-api-service - 16. update-api-service-provisionable-resource - 17. provision-api-service-website - 18. print-api-service-summary - 19. provision-aca-env - 20. provision-cache-containerapp - 21. print-cache-summary - 22. push-python-app - 23. provision-python-app-containerapp - 24. provision-storage - 25. provision-azure-bicep-resources - 26. print-dashboard-url-aas-env - 27. print-dashboard-url-aca-env - 28. print-python-app-summary - 29. deploy - 30. deploy-api-service - 31. deploy-cache - 32. deploy-python-app - 33. diagnostics - 34. publish-prereq - 35. publish-azure634f9 + 16. provision-api-service-website + 17. print-api-service-summary + 18. provision-aca-env + 19. provision-cache-containerapp + 20. print-cache-summary + 21. push-python-app + 22. provision-python-app-containerapp + 23. provision-storage + 24. provision-azure-bicep-resources + 25. print-dashboard-url-aas-env + 26. print-dashboard-url-aca-env + 27. print-python-app-summary + 28. deploy + 29. deploy-api-service + 30. deploy-cache + 31. deploy-python-app + 32. diagnostics + 33. publish-prereq + 34. publish-azure634f9 + 35. validate-appservice-config-aas-env 36. publish 37. publish-manifest 38. push @@ -182,7 +182,7 @@ Step: provision-aca-env-acr Step: provision-api-service-website Description: Provisions the Azure Bicep resource api-service-website using Azure infrastructure. - Dependencies: ✓ create-provisioning-context, ✓ provision-aas-env, ✓ push-api-service, ✓ update-api-service-provisionable-resource + Dependencies: ✓ create-provisioning-context, ✓ provision-aas-env, ✓ push-api-service Resource: api-service-website (AzureAppServiceWebSiteResource) Tags: provision-infra @@ -212,7 +212,7 @@ Step: provision-storage Step: publish Description: Aggregation step for all publish operations. All publish steps should be required by this step. - Dependencies: ✓ publish-azure634f9 + Dependencies: ✓ publish-azure634f9, ✓ validate-appservice-config-aas-env Step: publish-azure634f9 Description: Publishes the Azure environment configuration for azure634f9. @@ -245,10 +245,10 @@ Step: push-python-app Resource: python-app (ContainerResource) Tags: push-container-image -Step: update-api-service-provisionable-resource - Dependencies: ✓ create-provisioning-context - Resource: api-service-website (AzureAppServiceWebSiteResource) - Tags: update-website-provisionable-resource +Step: validate-appservice-config-aas-env + Description: Validates Azure App Service configuration for aas-env. + Dependencies: ✓ publish-prereq + Resource: aas-env (AzureAppServiceEnvironmentResource) Step: validate-azure-login Description: Validates Azure CLI authentication before deployment. @@ -309,13 +309,13 @@ If targeting 'create-provisioning-context': If targeting 'deploy': Direct dependencies: build-api-service, build-python-app, create-provisioning-context, print-api-service-summary, print-cache-summary, print-dashboard-url-aas-env, print-dashboard-url-aca-env, print-python-app-summary, provision-azure-bicep-resources, validate-azure-login - Total steps: 28 + Total steps: 27 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | build-python-app | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage | update-api-service-provisionable-resource (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env | provision-aca-env (parallel) [6] provision-cache-containerapp | push-prereq (parallel) [7] print-cache-summary | push-api-service | push-python-app (parallel) @@ -326,13 +326,13 @@ If targeting 'deploy': If targeting 'deploy-api-service': Direct dependencies: print-api-service-summary - Total steps: 17 + Total steps: 16 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | update-api-service-provisionable-resource (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env (parallel) [6] push-prereq [7] push-api-service @@ -407,13 +407,13 @@ If targeting 'login-to-acr-aca-env-acr': If targeting 'print-api-service-summary': Direct dependencies: provision-api-service-website - Total steps: 16 + Total steps: 15 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | update-api-service-provisionable-resource (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env (parallel) [6] push-prereq [7] push-api-service @@ -435,13 +435,13 @@ If targeting 'print-cache-summary': If targeting 'print-dashboard-url-aas-env': Direct dependencies: provision-aas-env, provision-azure-bicep-resources - Total steps: 23 + Total steps: 22 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | build-python-app | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage | update-api-service-provisionable-resource (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env | provision-aca-env (parallel) [6] provision-cache-containerapp | push-prereq (parallel) [7] push-api-service | push-python-app (parallel) @@ -451,13 +451,13 @@ If targeting 'print-dashboard-url-aas-env': If targeting 'print-dashboard-url-aca-env': Direct dependencies: provision-aca-env, provision-azure-bicep-resources - Total steps: 23 + Total steps: 22 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | build-python-app | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage | update-api-service-provisionable-resource (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env | provision-aca-env (parallel) [6] provision-cache-containerapp | push-prereq (parallel) [7] push-api-service | push-python-app (parallel) @@ -529,14 +529,14 @@ If targeting 'provision-aca-env-acr': [4] provision-aca-env-acr If targeting 'provision-api-service-website': - Direct dependencies: create-provisioning-context, provision-aas-env, push-api-service, update-api-service-provisionable-resource - Total steps: 15 + Direct dependencies: create-provisioning-context, provision-aas-env, push-api-service + Total steps: 14 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | update-api-service-provisionable-resource (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env (parallel) [6] push-prereq [7] push-api-service @@ -544,13 +544,13 @@ If targeting 'provision-api-service-website': If targeting 'provision-azure-bicep-resources': Direct dependencies: create-provisioning-context, deploy-prereq, provision-aas-env, provision-aas-env-acr, provision-aca-env, provision-aca-env-acr, provision-api-service-website, provision-cache-containerapp, provision-python-app-containerapp, provision-storage - Total steps: 22 + Total steps: 21 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | build-python-app | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage | update-api-service-provisionable-resource (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env | provision-aca-env (parallel) [6] provision-cache-containerapp | push-prereq (parallel) [7] push-api-service | push-python-app (parallel) @@ -594,12 +594,12 @@ If targeting 'provision-storage': [4] provision-storage If targeting 'publish': - Direct dependencies: publish-azure634f9 - Total steps: 4 + Direct dependencies: publish-azure634f9, validate-appservice-config-aas-env + Total steps: 5 Execution order: [0] process-parameters [1] publish-prereq - [2] publish-azure634f9 + [2] publish-azure634f9 | validate-appservice-config-aas-env (parallel) [3] publish If targeting 'publish-azure634f9': @@ -675,15 +675,13 @@ If targeting 'push-python-app': [6] push-prereq [7] push-python-app -If targeting 'update-api-service-provisionable-resource': - Direct dependencies: create-provisioning-context - Total steps: 5 +If targeting 'validate-appservice-config-aas-env': + Direct dependencies: publish-prereq + Total steps: 3 Execution order: [0] process-parameters - [1] deploy-prereq - [2] validate-azure-login - [3] create-provisioning-context - [4] update-api-service-provisionable-resource + [1] publish-prereq + [2] validate-appservice-config-aas-env If targeting 'validate-azure-login': Direct dependencies: deploy-prereq From 2497f1e640470ad1374e336c79b629d9c087ab2b Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 18 Feb 2026 18:18:28 -0600 Subject: [PATCH 118/256] Fix permission denied error in Azure Pipelines (#14534) * Fix permission denied error in Azure Pipelines When we generate temporary dockerfiles, we are generating them directly under the TEMP directory. This can cause issues in some environments because docker build will walk all the files and folders next to the dockerfile as context to the build. For example, in AzDO pipelines, we can get an error like "ERROR: error from sender: lstat /tmp/.mount_azsec-KdAJKO: permission denied". To fix this, we generate the Dockerfile in a subdirectory of TEMP, so it is the only file passed as context to docker build. Fix #14523 --- src/Aspire.Hosting/ApplicationModel/ProjectResource.cs | 7 +++++-- src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs index a52c3c2d56e..1e960005d69 100644 --- a/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs +++ b/src/Aspire.Hosting/ApplicationModel/ProjectResource.cs @@ -163,10 +163,13 @@ private async Task BuildProjectImage(PipelineStepContext ctx) // Add COPY --from: statements for each source stage.AddContainerFiles(this, containerWorkingDir, logger); - // Get the directory service to create temp Dockerfile var projectDir = Path.GetDirectoryName(projectMetadata.ProjectPath)!; + + // Create a unique temporary Dockerfile path for this resource using the directory service. + // Passing a file name causes CreateTempFile to create the file in a new, empty subdirectory, + // which avoids Docker/buildx scanning the entire temporary directory. var directoryService = ctx.Services.GetRequiredService(); - var tempDockerfilePath = directoryService.TempDirectory.CreateTempFile().Path; + var tempDockerfilePath = directoryService.TempDirectory.CreateTempFile("Dockerfile").Path; var builtSuccessfully = false; try diff --git a/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs b/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs index 4d44532128b..b1e75a75be2 100644 --- a/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ContainerResourceBuilderExtensions.cs @@ -718,9 +718,11 @@ public static IResourceBuilder WithDockerfileFactory(this IResourceBuilder var fullyQualifiedContextPath = Path.GetFullPath(contextPath, builder.ApplicationBuilder.AppHostDirectory) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - // Create a unique temporary Dockerfile path for this resource using the directory service + // Create a unique temporary Dockerfile path for this resource using the directory service. + // Passing a file name causes CreateTempFile to create the file in a new, empty subdirectory, + // which avoids Docker/buildx scanning the entire temporary directory. var directoryService = builder.ApplicationBuilder.FileSystemService; - var tempDockerfilePath = directoryService.TempDirectory.CreateTempFile().Path; + var tempDockerfilePath = directoryService.TempDirectory.CreateTempFile("Dockerfile").Path; var imageName = ImageNameGenerator.GenerateImageName(builder); var imageTag = ImageNameGenerator.GenerateImageTag(builder); From 112b08cbf9861d2912002801ee9e4ee3ccec277b Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 19 Feb 2026 11:21:46 +1100 Subject: [PATCH 119/256] Add WithCompactResourceNaming() to fix storage name collisions (#14442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add WithCompactResourceNaming() to fix storage name collisions Fixes #14427. When Azure Container App environment names are long, the uniqueString suffix gets truncated in storage account names, causing naming collisions across deployments. WithCompactResourceNaming() is an opt-in method that shortens storage account and managed storage names to preserve the full 13-char uniqueString while keeping names within Azure's length limits. - Storage accounts: take('{prefix}sv{resourceToken}', 24) - Managed storage: take('{name}-{volume}-{resourceToken}', 32) - File shares: take('{name}-{volume}', 60) Includes unit tests with snapshot verification and E2E deployment tests covering both the fix and upgrade safety scenarios. * Fix upgrade test: handle version prompt and backup/restore dev CLI - Add version selection prompt handling for 'aspire add' (same as passing test) - Back up dev CLI before GA install, restore after GA phase - Update package to dev version after CLI restoration - Set channel back to local after restore * Remove the manifest from verify tests. It is not necessary. * Remove unnecessary suppression * Fix upgrade test: use 'aspire update' to actually upgrade project packages The upgrade test was only swapping the CLI binary but the apphost.cs still had #:package directives pointing to GA 13.1.0 packages. The deployment logic comes from the NuGet packages, not the CLI, so the test was actually redeploying with the old GA naming code both times. Now uses 'aspire update --channel local' to update the #:package directives in apphost.cs from GA → dev version, ensuring the dev naming code is exercised during the second deployment. * Fix upgrade test: handle 'Perform updates?' confirmation prompt aspire update shows a y/n confirmation before applying package updates. The test was waiting for 'Update successful' but the command was stuck at the confirmation prompt. * Fix upgrade test: handle NuGet.config directory prompt from aspire update aspire update shows two prompts when switching channels: 1. 'Perform updates? [y/n]' - package confirmation 2. 'Which directory for NuGet.config file?' - NuGet config placement Both need Enter to accept defaults. * Fix upgrade test: use timed Enter presses for aspire update prompts aspire update has multiple sequential prompts (confirm, NuGet.config dir, NuGet.config apply, potential CLI self-update). Use Wait+Enter pattern to accept all defaults without needing to track each prompt individually. * Fix upgrade test: use explicit WaitUntil for each aspire update prompt Timed Enter presses were unreliable — if prompts appeared at different speeds, extra Enters would leak to the shell and corrupt subsequent commands. Now explicitly waits for each of the 3 prompts: 1. 'Perform updates? [y/n]' 2. 'Which directory for NuGet.config file?' 3. 'Apply these changes to NuGet.config? [y/n]' * Increase deploy WaitForSuccessPrompt timeout from 2 to 5 minutes --------- Co-authored-by: Mitch Denny Co-authored-by: Eric Erhardt --- .../AzureContainerAppEnvironmentResource.cs | 2 + .../AzureContainerAppExtensions.cs | 75 +++- .../AcaCompactNamingDeploymentTests.cs | 239 ++++++++++++ .../AcaCompactNamingUpgradeDeploymentTests.cs | 363 ++++++++++++++++++ .../Helpers/DeploymentE2ETestHelpers.cs | 15 + .../AzureContainerAppsTests.cs | 57 +++ ...NamingPreservesUniqueString.verified.bicep | 129 +++++++ ...tipleVolumesHaveUniqueNames.verified.bicep | 177 +++++++++ 8 files changed, 1056 insertions(+), 1 deletion(-) create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.bicep create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.bicep diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs index 99f2124b0ca..7809daaa638 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppEnvironmentResource.cs @@ -141,6 +141,8 @@ await context.ReportingStep.CompleteAsync( } internal bool UseAzdNamingConvention { get; set; } + internal bool UseCompactResourceNaming { get; set; } + /// /// Gets or sets a value indicating whether the Aspire dashboard should be included in the container app environment. /// Default is true. diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs index b86febc57ec..92710731082 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs @@ -75,7 +75,7 @@ public static IResourceBuilder AddAzureCon infra.Add(tags); ProvisioningVariable? resourceToken = null; - if (appEnvResource.UseAzdNamingConvention) + if (appEnvResource.UseAzdNamingConvention || appEnvResource.UseCompactResourceNaming) { resourceToken = new ProvisioningVariable("resourceToken", typeof(string)) { @@ -256,6 +256,30 @@ public static IResourceBuilder AddAzureCon $"{BicepFunction.ToLower(output.resource.Name)}-{BicepFunction.ToLower(volumeName)}"), 32); } + else if (appEnvResource.UseCompactResourceNaming) + { + Debug.Assert(resourceToken is not null); + + var volumeName = output.volume.Type switch + { + ContainerMountType.BindMount => $"bm{output.index}", + ContainerMountType.Volume => output.volume.Source ?? $"v{output.index}", + _ => throw new NotSupportedException() + }; + + // Remove '.' and '-' characters from volumeName + volumeName = volumeName.Replace(".", "").Replace("-", ""); + + share.Name = BicepFunction.Take( + BicepFunction.Interpolate( + $"{BicepFunction.ToLower(output.resource.Name)}-{BicepFunction.ToLower(volumeName)}"), + 60); + + containerAppStorage.Name = BicepFunction.Take( + BicepFunction.Interpolate( + $"{BicepFunction.ToLower(output.resource.Name)}-{BicepFunction.ToLower(volumeName)}-{resourceToken}"), + 32); + } } } @@ -292,6 +316,26 @@ public static IResourceBuilder AddAzureCon storageVolume.Name = BicepFunction.Interpolate($"vol{resourceToken}"); } } + else if (appEnvResource.UseCompactResourceNaming) + { + Debug.Assert(resourceToken is not null); + + if (storageVolume is not null) + { + // Sanitize env name for storage accounts: lowercase alphanumeric only. + // Reserve 2 chars for "sv" prefix + 13 for uniqueString = 15, leaving 9 for the env name. + var sanitizedPrefix = new string(appEnvResource.Name.ToLowerInvariant() + .Where(c => char.IsLetterOrDigit(c)).ToArray()); + if (sanitizedPrefix.Length > 9) + { + sanitizedPrefix = sanitizedPrefix[..9]; + } + + storageVolume.Name = BicepFunction.Take( + BicepFunction.Interpolate($"{sanitizedPrefix}sv{resourceToken}"), + 24); + } + } // Exposed so that callers reference the LA workspace in other bicep modules infra.Add(new ProvisioningOutput("AZURE_LOG_ANALYTICS_WORKSPACE_NAME", typeof(string)) @@ -370,6 +414,35 @@ public static IResourceBuilder WithAzdReso return builder; } + /// + /// Configures the container app environment to use compact resource naming that maximally preserves + /// the uniqueString suffix for length-constrained Azure resources such as storage accounts. + /// + /// The to configure. + /// A reference to the for chaining. + /// + /// + /// By default, the generated Azure resource names use long static suffixes (e.g. storageVolume, + /// managedStorage) that can consume most of the 24-character storage account name limit, truncating + /// the uniqueString(resourceGroup().id) portion that provides cross-deployment uniqueness. + /// + /// + /// When enabled, this method shortens the static portions of generated names so the full 13-character + /// uniqueString is preserved. This prevents naming collisions when deploying multiple environments + /// to different resource groups. + /// + /// + /// This option only affects volume-related storage resources. It does not change the naming of the + /// container app environment, container registry, log analytics workspace, or managed identity. + /// Use to change those names as well. + /// + /// + public static IResourceBuilder WithCompactResourceNaming(this IResourceBuilder builder) + { + builder.Resource.UseCompactResourceNaming = true; + return builder; + } + /// /// Configures whether the Aspire dashboard should be included in the container app environment. /// diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs new file mode 100644 index 00000000000..b8945597d64 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs @@ -0,0 +1,239 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for compact resource naming with Azure Container App Environments. +/// Validates that WithCompactResourceNaming() fixes storage account naming collisions +/// caused by long environment names, and that the default naming is unchanged on upgrade. +/// +public sealed class AcaCompactNamingDeploymentTests(ITestOutputHelper output) +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); + + /// + /// Verifies that deploying with a long ACA environment name and a volume + /// succeeds when WithCompactResourceNaming() is used. + /// The storage account name would otherwise exceed 24 chars and truncate the uniqueString. + /// + [Fact] + public async Task DeployWithCompactNamingFixesStorageCollision() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + + await DeployWithCompactNamingFixesStorageCollisionCore(linkedCts.Token); + } + + private async Task DeployWithCompactNamingFixesStorageCollisionCore(CancellationToken cancellationToken) + { + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployWithCompactNamingFixesStorageCollision)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("compact"); + + output.WriteLine($"Test: {nameof(DeployWithCompactNamingFixesStorageCollision)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost + output.WriteLine("Step 3: Creating single-file AppHost..."); + sequenceBuilder.Type("aspire init") + .Enter() + .Wait(TimeSpan.FromSeconds(5)) + .Enter() + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4: Add required packages + output.WriteLine("Step 4: Adding Azure Container Apps package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs with a long environment name and a container with volume. + // Use WithCompactResourceNaming() so the storage account name preserves the uniqueString. + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Long env name (16 chars) would truncate uniqueString without compact naming +builder.AddAzureContainerAppEnvironment("my-long-env-name") + .WithCompactResourceNaming(); + +// Container with a volume triggers storage account creation +builder.AddContainer("worker", "mcr.microsoft.com/dotnet/samples", "aspnetapp") + .WithVolume("data", "/app/data"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs with long env name + compact naming + volume"); + }); + + // Step 6: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy + output.WriteLine("Step 7: Deploying with compact naming..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Verify storage account was created and name contains uniqueString + output.WriteLine("Step 8: Verifying storage account naming..."); + sequenceBuilder + .Type($"STORAGE_NAMES=$(az storage account list -g \"{resourceGroupName}\" --query \"[].name\" -o tsv) && " + + "echo \"Storage accounts: $STORAGE_NAMES\" && " + + "STORAGE_COUNT=$(echo \"$STORAGE_NAMES\" | wc -l) && " + + "echo \"Count: $STORAGE_COUNT\" && " + + // Verify each storage name contains 'sv' (compact naming marker) + "for name in $STORAGE_NAMES; do " + + "if echo \"$name\" | grep -q 'sv'; then echo \"✅ $name uses compact naming\"; " + + "else echo \"⚠️ $name does not use compact naming (may be ACR storage)\"; fi; " + + "done") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 9: Exit + sequenceBuilder.Type("exit").Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"✅ Test completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployWithCompactNamingFixesStorageCollision), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"❌ Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployWithCompactNamingFixesStorageCollision), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + output.WriteLine(process.ExitCode == 0 + ? $"Resource group deletion initiated: {resourceGroupName}" + : $"Resource group deletion may have failed (exit code {process.ExitCode})"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs new file mode 100644 index 00000000000..c0f39d3175c --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs @@ -0,0 +1,363 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// Upgrade safety test: deploys with the GA Aspire CLI, then upgrades to the dev (PR) CLI +/// and redeploys WITHOUT enabling compact naming. Verifies that the default naming behavior +/// is unchanged — no duplicate storage accounts are created on upgrade. +/// +public sealed class AcaCompactNamingUpgradeDeploymentTests(ITestOutputHelper output) +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(60); + + /// + /// Deploys with GA CLI → upgrades to dev CLI → redeploys same apphost → verifies + /// no duplicate storage accounts were created (default naming unchanged). + /// + [Fact] + public async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccounts() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + + await UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(linkedCts.Token); + } + + private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(CancellationToken cancellationToken) + { + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(UpgradeFromGaToDevDoesNotDuplicateStorageAccounts)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("upgrade"); + + output.WriteLine($"Test: {nameof(UpgradeFromGaToDevDoesNotDuplicateStorageAccounts)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + var waitingForUpdateSuccessful = new CellPatternSearcher() + .Find("Update successful"); + + // aspire update prompts (used in Phase 2) + var waitingForPerformUpdates = new CellPatternSearcher().Find("Perform updates?"); + var waitingForNugetConfigDir = new CellPatternSearcher().Find("NuGet.config file?"); + var waitingForApplyNugetConfig = new CellPatternSearcher().Find("Apply these changes"); + + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // ============================================================ + // Phase 1: Install GA CLI and deploy + // ============================================================ + + // Step 2: Back up the dev CLI (pre-installed by CI), then install the GA CLI + output.WriteLine("Step 2: Backing up dev CLI and installing GA Aspire CLI..."); + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .Type("cp ~/.aspire/bin/aspire /tmp/aspire-dev-backup && cp -r ~/.aspire/hives /tmp/aspire-hives-backup 2>/dev/null; echo 'dev CLI backed up'") + .Enter() + .WaitForSuccessPrompt(counter); + } + sequenceBuilder.InstallAspireCliRelease(counter); + + // Step 3: Source CLI environment + output.WriteLine("Step 3: Configuring CLI environment..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + + // Step 4: Log the GA CLI version + output.WriteLine("Step 4: Logging GA CLI version..."); + sequenceBuilder.Type("aspire --version") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5: Create single-file AppHost with GA CLI + output.WriteLine("Step 5: Creating single-file AppHost with GA CLI..."); + sequenceBuilder.Type("aspire init") + .Enter() + .Wait(TimeSpan.FromSeconds(5)) + .Enter() + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 6: Add ACA package using GA CLI (uses GA NuGet packages) + output.WriteLine("Step 6: Adding Azure Container Apps package (GA)..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter() + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 7: Modify apphost.cs with a short env name (fits within 24 chars with default naming) + // and a container with volume to trigger storage account creation + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + // Use short name "env" (3 chars) so default naming works: "envstoragevolume" (16) + uniqueString fits in 24 + var replacement = """ +builder.AddAzureContainerAppEnvironment("env"); + +builder.AddContainer("worker", "mcr.microsoft.com/dotnet/samples", "aspnetapp") + .WithVolume("data", "/app/data"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine("Modified apphost.cs with short env name + volume (GA-compatible)"); + }); + + // Step 8: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 9: Deploy with GA CLI + output.WriteLine("Step 9: First deployment with GA CLI..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 10: Record the storage account count after first deploy + output.WriteLine("Step 10: Recording storage account count after GA deploy..."); + sequenceBuilder + .Type($"GA_STORAGE_COUNT=$(az storage account list -g \"{resourceGroupName}\" --query \"length([])\" -o tsv) && " + + "echo \"GA deploy storage count: $GA_STORAGE_COUNT\"") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // ============================================================ + // Phase 2: Upgrade to dev CLI and redeploy + // ============================================================ + + // Step 11: Install the dev (PR) CLI, overwriting the GA installation + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 11: Restoring dev CLI from backup..."); + // Restore the dev CLI and hive that we backed up before GA install + sequenceBuilder + .Type("cp -f /tmp/aspire-dev-backup ~/.aspire/bin/aspire && cp -rf /tmp/aspire-hives-backup/* ~/.aspire/hives/ 2>/dev/null; echo 'dev CLI restored'") + .Enter() + .WaitForSuccessPrompt(counter); + + // Ensure the dev CLI uses the local channel (GA install may have changed it) + sequenceBuilder + .Type("aspire config set channel local --global 2>/dev/null; echo 'channel set'") + .Enter() + .WaitForSuccessPrompt(counter); + + // Re-source environment to pick up the dev CLI + sequenceBuilder.SourceAspireCliEnvironment(counter); + + // Run aspire update to upgrade the #:package directives in apphost.cs + // from the GA version to the dev build version. This ensures the actual + // deployment logic (naming, bicep generation) comes from the dev packages. + // aspire update shows 3 interactive prompts — handle each explicitly. + output.WriteLine("Step 11b: Updating project packages to dev version..."); + sequenceBuilder.Type("aspire update --channel local") + .Enter() + .WaitUntil(s => waitingForPerformUpdates.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForNugetConfigDir.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForApplyNugetConfig.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + } + else + { + // For local testing, use the PR install script if GITHUB_PR_NUMBER is set + var prNumber = DeploymentE2ETestHelpers.GetPrNumber(); + if (prNumber > 0) + { + output.WriteLine($"Step 11: Upgrading to dev CLI from PR #{prNumber}..."); + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + + // Update project packages to the PR version + output.WriteLine("Step 11b: Updating project packages to dev version..."); + sequenceBuilder.Type($"aspire update --channel pr-{prNumber}") + .Enter() + .WaitUntil(s => waitingForPerformUpdates.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForNugetConfigDir.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForApplyNugetConfig.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + } + else + { + output.WriteLine("Step 11: No PR number available, using current CLI as 'dev'..."); + // Still run aspire update to pick up whatever local packages are available + sequenceBuilder.Type("aspire update") + .Enter() + .WaitUntil(s => waitingForPerformUpdates.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForNugetConfigDir.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForApplyNugetConfig.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .Enter() + .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + } + } + + // Step 12: Log the dev CLI version and verify packages were updated + output.WriteLine("Step 12: Logging dev CLI version and verifying package update..."); + sequenceBuilder.Type("aspire --version") + .Enter() + .WaitForSuccessPrompt(counter); + + // Verify the #:package directives in apphost.cs were updated from GA version + sequenceBuilder.Type("grep '#:package\\|#:sdk' apphost.cs") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 13: Redeploy with dev packages — same apphost, NO compact naming + // The dev packages contain our changes but default naming is unchanged, + // so this should reuse the same resources created by the GA deploy. + output.WriteLine("Step 13: Redeploying with dev packages (no compact naming)..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => waitingForPipelineSucceeded.Search(s).Count > 0, TimeSpan.FromMinutes(30)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 14: Verify no duplicate storage accounts + output.WriteLine("Step 14: Verifying no duplicate storage accounts..."); + sequenceBuilder + .Type($"DEV_STORAGE_COUNT=$(az storage account list -g \"{resourceGroupName}\" --query \"length([])\" -o tsv) && " + + "echo \"Dev deploy storage count: $DEV_STORAGE_COUNT\" && " + + "echo \"GA deploy storage count: $GA_STORAGE_COUNT\" && " + + "if [ \"$DEV_STORAGE_COUNT\" = \"$GA_STORAGE_COUNT\" ]; then " + + "echo '✅ No duplicate storage accounts — default naming unchanged on upgrade'; " + + "else " + + "echo \"❌ Storage count changed from $GA_STORAGE_COUNT to $DEV_STORAGE_COUNT — NAMING REGRESSION\"; exit 1; " + + "fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(30)); + + // Step 15: Exit + sequenceBuilder.Type("exit").Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"✅ Upgrade test completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(UpgradeFromGaToDevDoesNotDuplicateStorageAccounts), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"❌ Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(UpgradeFromGaToDevDoesNotDuplicateStorageAccounts), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + output.WriteLine(process.ExitCode == 0 + ? $"Resource group deletion initiated: {resourceGroupName}" + : $"Resource group deletion may have failed (exit code {process.ExitCode})"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs index 1b07a17e257..905b001719b 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs @@ -144,6 +144,21 @@ internal static Hex1bTerminalInputSequenceBuilder InstallAspireCliFromPullReques .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(300)); } + /// + /// Installs the latest GA (release quality) Aspire CLI. + /// + internal static Hex1bTerminalInputSequenceBuilder InstallAspireCliRelease( + this Hex1bTerminalInputSequenceBuilder builder, + SequenceCounter counter) + { + var command = "curl -fsSL https://aka.ms/aspire/get/install.sh | bash -s -- --quality release"; + + return builder + .Type(command) + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(300)); + } + /// /// Configures the PATH and environment variables for the Aspire CLI. /// diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index 2ffe5d25d2f..e9a0bb6c563 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -1326,6 +1326,63 @@ await Verify(manifest.ToString(), "json") .AppendContentAsFile(bicep, "bicep"); } + [Fact] + public async Task AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + // Use a deliberately long name (15 chars) that would cause collisions without compact naming + var env = builder.AddAzureContainerAppEnvironment("my-long-env-name"); + env.WithCompactResourceNaming(); + + var pg = builder.AddAzurePostgresFlexibleServer("pg") + .WithPasswordAuthentication() + .AddDatabase("db"); + + builder.AddContainer("cache", "redis") + .WithVolume("App.da-ta", "/data") + .WithReference(pg); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var environment = Assert.Single(model.Resources.OfType()); + + var manifest = await GetManifestWithBicep(environment); + + await Verify(manifest.BicepText, "bicep"); + } + + [Fact] + public async Task CompactNamingMultipleVolumesHaveUniqueNames() + { + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var env = builder.AddAzureContainerAppEnvironment("my-ace"); + env.WithCompactResourceNaming(); + + builder.AddContainer("druid", "apache/druid", "34.0.0") + .WithHttpEndpoint(targetPort: 8081) + .WithVolume("druid_shared", "/opt/shared") + .WithVolume("coordinator_var", "/opt/druid/var") + .WithBindMount("./config", "/opt/druid/conf"); + + using var app = builder.Build(); + + await ExecuteBeforeStartHooksAsync(app, default); + + var model = app.Services.GetRequiredService(); + + var environment = Assert.Single(model.Resources.OfType()); + + var manifest = await GetManifestWithBicep(environment); + + await Verify(manifest.BicepText, "bicep"); + } + // see https://github.com/dotnet/aspire/issues/8381 for more information on this scenario // Azure SqlServer needs an admin when it is first provisioned. To supply this, we use the // principalId from the Azure Container App Environment. diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.bicep new file mode 100644 index 00000000000..fe2f52e7b54 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.AddContainerAppEnvironmentWithCompactNamingPreservesUniqueString.verified.bicep @@ -0,0 +1,129 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param my_long_env_name_acr_outputs_name string + +var resourceToken = uniqueString(resourceGroup().id) + +resource my_long_env_name_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('my_long_env_name_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource my_long_env_name_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: my_long_env_name_acr_outputs_name +} + +resource my_long_env_name_acr_my_long_env_name_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(my_long_env_name_acr.id, my_long_env_name_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: my_long_env_name_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: my_long_env_name_acr +} + +resource my_long_env_name_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('mylongenvnamelaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource my_long_env_name 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('mylongenvname${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: my_long_env_name_law.properties.customerId + sharedKey: my_long_env_name_law.listKeys().primarySharedKey + } + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: my_long_env_name +} + +resource my_long_env_name_storageVolume 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('mylongenvsv${resourceToken}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_LRS' + } + properties: { + largeFileSharesState: 'Enabled' + minimumTlsVersion: 'TLS1_2' + } + tags: tags +} + +resource storageVolumeFileService 'Microsoft.Storage/storageAccounts/fileServices@2024-01-01' = { + name: 'default' + parent: my_long_env_name_storageVolume +} + +resource shares_volumes_cache_0 'Microsoft.Storage/storageAccounts/fileServices/shares@2024-01-01' = { + name: take('${toLower('cache')}-${toLower('Appdata')}', 60) + properties: { + enabledProtocols: 'SMB' + shareQuota: 1024 + } + parent: storageVolumeFileService +} + +resource managedStorage_volumes_cache_0 'Microsoft.App/managedEnvironments/storages@2025-01-01' = { + name: take('${toLower('cache')}-${toLower('Appdata')}-${resourceToken}', 32) + properties: { + azureFile: { + accountName: my_long_env_name_storageVolume.name + accountKey: my_long_env_name_storageVolume.listKeys().keys[0].value + accessMode: 'ReadWrite' + shareName: shares_volumes_cache_0.name + } + } + parent: my_long_env_name +} + +output volumes_cache_0 string = managedStorage_volumes_cache_0.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = my_long_env_name_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = my_long_env_name_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = my_long_env_name_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = my_long_env_name_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = my_long_env_name_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = my_long_env_name.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = my_long_env_name.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = my_long_env_name.properties.defaultDomain \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.bicep new file mode 100644 index 00000000000..3fe9e593aa4 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureContainerAppsTests.CompactNamingMultipleVolumesHaveUniqueNames.verified.bicep @@ -0,0 +1,177 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +param userPrincipalId string = '' + +param tags object = { } + +param my_ace_acr_outputs_name string + +var resourceToken = uniqueString(resourceGroup().id) + +resource my_ace_mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + name: take('my_ace_mi-${uniqueString(resourceGroup().id)}', 128) + location: location + tags: tags +} + +resource my_ace_acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: my_ace_acr_outputs_name +} + +resource my_ace_acr_my_ace_mi_AcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(my_ace_acr.id, my_ace_mi.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')) + properties: { + principalId: my_ace_mi.properties.principalId + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + } + scope: my_ace_acr +} + +resource my_ace_law 'Microsoft.OperationalInsights/workspaces@2025-02-01' = { + name: take('myacelaw-${uniqueString(resourceGroup().id)}', 63) + location: location + properties: { + sku: { + name: 'PerGB2018' + } + } + tags: tags +} + +resource my_ace 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: take('myace${uniqueString(resourceGroup().id)}', 24) + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: my_ace_law.properties.customerId + sharedKey: my_ace_law.listKeys().primarySharedKey + } + } + workloadProfiles: [ + { + name: 'consumption' + workloadProfileType: 'Consumption' + } + ] + } + tags: tags +} + +resource aspireDashboard 'Microsoft.App/managedEnvironments/dotNetComponents@2024-10-02-preview' = { + name: 'aspire-dashboard' + properties: { + componentType: 'AspireDashboard' + } + parent: my_ace +} + +resource my_ace_storageVolume 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('myacesv${resourceToken}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_LRS' + } + properties: { + largeFileSharesState: 'Enabled' + minimumTlsVersion: 'TLS1_2' + } + tags: tags +} + +resource storageVolumeFileService 'Microsoft.Storage/storageAccounts/fileServices@2024-01-01' = { + name: 'default' + parent: my_ace_storageVolume +} + +resource shares_volumes_druid_0 'Microsoft.Storage/storageAccounts/fileServices/shares@2024-01-01' = { + name: take('${toLower('druid')}-${toLower('druid_shared')}', 60) + properties: { + enabledProtocols: 'SMB' + shareQuota: 1024 + } + parent: storageVolumeFileService +} + +resource managedStorage_volumes_druid_0 'Microsoft.App/managedEnvironments/storages@2025-01-01' = { + name: take('${toLower('druid')}-${toLower('druid_shared')}-${resourceToken}', 32) + properties: { + azureFile: { + accountName: my_ace_storageVolume.name + accountKey: my_ace_storageVolume.listKeys().keys[0].value + accessMode: 'ReadWrite' + shareName: shares_volumes_druid_0.name + } + } + parent: my_ace +} + +resource shares_volumes_druid_1 'Microsoft.Storage/storageAccounts/fileServices/shares@2024-01-01' = { + name: take('${toLower('druid')}-${toLower('coordinator_var')}', 60) + properties: { + enabledProtocols: 'SMB' + shareQuota: 1024 + } + parent: storageVolumeFileService +} + +resource managedStorage_volumes_druid_1 'Microsoft.App/managedEnvironments/storages@2025-01-01' = { + name: take('${toLower('druid')}-${toLower('coordinator_var')}-${resourceToken}', 32) + properties: { + azureFile: { + accountName: my_ace_storageVolume.name + accountKey: my_ace_storageVolume.listKeys().keys[0].value + accessMode: 'ReadWrite' + shareName: shares_volumes_druid_1.name + } + } + parent: my_ace +} + +resource shares_bindmounts_druid_0 'Microsoft.Storage/storageAccounts/fileServices/shares@2024-01-01' = { + name: take('${toLower('druid')}-${toLower('bm0')}', 60) + properties: { + enabledProtocols: 'SMB' + shareQuota: 1024 + } + parent: storageVolumeFileService +} + +resource managedStorage_bindmounts_druid_0 'Microsoft.App/managedEnvironments/storages@2025-01-01' = { + name: take('${toLower('druid')}-${toLower('bm0')}-${resourceToken}', 32) + properties: { + azureFile: { + accountName: my_ace_storageVolume.name + accountKey: my_ace_storageVolume.listKeys().keys[0].value + accessMode: 'ReadWrite' + shareName: shares_bindmounts_druid_0.name + } + } + parent: my_ace +} + +output volumes_druid_0 string = managedStorage_volumes_druid_0.name + +output volumes_druid_1 string = managedStorage_volumes_druid_1.name + +output bindmounts_druid_0 string = managedStorage_bindmounts_druid_0.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = my_ace_law.name + +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = my_ace_law.id + +output AZURE_CONTAINER_REGISTRY_NAME string = my_ace_acr.name + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = my_ace_acr.properties.loginServer + +output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = my_ace_mi.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = my_ace.name + +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = my_ace.id + +output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = my_ace.properties.defaultDomain \ No newline at end of file From 64bf57f06f71a4bca3f3f3ab2c1212eed3226227 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Wed, 18 Feb 2026 17:41:00 -0800 Subject: [PATCH 120/256] Update daily report to 13.2 milestone burndown (#14563) * Update daily report to 13.2 milestone burndown Refocus the daily-repo-status agentic workflow to serve as a 13.2 release burndown report: - Track 13.2 milestone issues closed/opened in the last 24 hours - Highlight new bugs added to the milestone - Summarize PRs merged to release/13.2 branch - List PRs targeting release/13.2 awaiting review - Surface relevant 13.2 discussions - Generate a Mermaid xychart burndown using cache-memory snapshots - Keep general triage queue as a brief secondary section - Schedule daily around 9am, auto-close older report issues Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback: clarify cache schema and queries - Exclude PRs from milestone counts (issues-only filter) - Specify exact JSON schema for cache-memory burndown snapshots - Add dedup, sort, and trim-to-7 logic for cache entries - Simplify 'new issues' query to opened-in-last-24h with milestone Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/daily-repo-status.lock.yml | 501 +++++++++++-------- .github/workflows/daily-repo-status.md | 125 ++++- 2 files changed, 386 insertions(+), 240 deletions(-) diff --git a/.github/workflows/daily-repo-status.lock.yml b/.github/workflows/daily-repo-status.lock.yml index 327fe1d0f38..f226e7a052b 100644 --- a/.github/workflows/daily-repo-status.lock.yml +++ b/.github/workflows/daily-repo-status.lock.yml @@ -13,28 +13,26 @@ # \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ # \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ # -# This file was automatically generated by gh-aw (v0.43.22). DO NOT EDIT. +# This file was automatically generated by gh-aw (v0.45.5). DO NOT EDIT. # -# To update this file, edit githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb and run: +# To update this file, edit the corresponding .md file and run: # gh aw compile # Not all edits will cause changes to this file. # # For more information: https://github.github.com/gh-aw/introduction/overview/ # -# This workflow creates daily repo status reports. It gathers recent repository -# activity (issues, PRs, discussions, releases, code changes) and generates -# engaging GitHub issues with productivity insights, community highlights, -# and project recommendations. +# Daily burndown report for the Aspire 13.2 milestone. Tracks progress +# on issues closed, new bugs found, notable changes merged into the +# release/13.2 branch, pending PR reviews, and discussions. Generates +# a 7-day burndown chart using cached daily snapshots. # -# Source: githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb -# -# frontmatter-hash: bec92641275aec67119420ff1264936a5fd32ec8a3734c7665ec0659fa174613 +# frontmatter-hash: 427ab537ab52b999a8cbb139515b504ba7359549cab995530c129ea037f08ef0 -name: "Daily Repo Status" +name: "13.2 Release Burndown Report" "on": schedule: - - cron: "42 7 * * *" - # Friendly format: daily (scattered) + - cron: "42 9 * * *" + # Friendly format: daily around 9am (scattered) workflow_dispatch: permissions: {} @@ -42,7 +40,7 @@ permissions: {} concurrency: group: "gh-aw-${{ github.workflow }}" -run-name: "Daily Repo Status" +run-name: "13.2 Release Burndown Report" jobs: activation: @@ -54,9 +52,17 @@ jobs: comment_repo: "" steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@fe858c3e14589bf396594a0b106e634d9065823e # v0.43.22 + uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 with: destination: /opt/gh-aw/actions + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + .github + .agents + fetch-depth: 1 + persist-credentials: false - name: Check workflow file timestamps uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: @@ -67,14 +73,170 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + run: | + bash /opt/gh-aw/actions/create_prompt_first.sh + cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/cache_memory_prompt.md" >> "$GH_AW_PROMPT" + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + GitHub API Access Instructions + + The gh CLI is NOT authenticated. Do NOT use gh commands for GitHub operations. + + + To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls. + + Temporary IDs: Some safe output tools support a temporary ID field (usually named temporary_id) so you can reference newly-created items elsewhere in the SAME agent output (for example, using #aw_abc1 in a later body). + + **IMPORTANT - temporary_id format rules:** + - If you DON'T need to reference the item later, OMIT the temporary_id field entirely (it will be auto-generated if needed) + - If you DO need cross-references/chaining, you MUST match this EXACT validation regex: /^aw_[A-Za-z0-9]{3,8}$/i + - Format: aw_ prefix followed by 3 to 8 alphanumeric characters (A-Z, a-z, 0-9, case-insensitive) + - Valid alphanumeric characters: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 + - INVALID examples: aw_ab (too short), aw_123456789 (too long), aw_test-id (contains hyphen), aw_id_123 (contains underscore) + - VALID examples: aw_abc, aw_abc1, aw_Test123, aw_A1B2C3D4, aw_12345678 + - To generate valid IDs: use 3-8 random alphanumeric characters or omit the field to let the system auto-generate + + Do NOT invent other aw_* formats — downstream steps will reject them with validation errors matching against /^aw_[A-Za-z0-9]{3,8}$/i. + + Discover available tools from the safeoutputs MCP server. + + **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped. + + **Note**: If you made no other safe output tool calls during this workflow execution, call the "noop" tool to provide a status message indicating completion or that no actions were needed. + + + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + {{#runtime-import .github/workflows/daily-repo-status.md}} + GH_AW_PROMPT_EOF + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ALLOWED_EXTENSIONS: '' + GH_AW_CACHE_DESCRIPTION: '' + GH_AW_CACHE_DIR: '/tmp/gh-aw/cache-memory/' + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: ${{ needs.pre_activation.outputs.matched_command }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_ALLOWED_EXTENSIONS: process.env.GH_AW_ALLOWED_EXTENSIONS, + GH_AW_CACHE_DESCRIPTION: process.env.GH_AW_CACHE_DESCRIPTION, + GH_AW_CACHE_DIR: process.env.GH_AW_CACHE_DIR, + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Upload prompt artifact + if: success() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: prompt + path: /tmp/gh-aw/aw-prompts/prompt.txt + retention-days: 1 agent: needs: activation runs-on: ubuntu-latest permissions: contents: read + discussions: read issues: read pull-requests: read + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GH_AW_ASSETS_ALLOWED_EXTS: "" @@ -84,6 +246,7 @@ jobs: GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_WORKFLOW_ID_SANITIZED: dailyrepostatus outputs: checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} has_patch: ${{ steps.collect_output.outputs.has_patch }} @@ -93,7 +256,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@fe858c3e14589bf396594a0b106e634d9065823e # v0.43.22 + uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -102,6 +265,16 @@ jobs: persist-credentials: false - name: Create gh-aw temp directory run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh + # Cache memory file share configuration from frontmatter processed below + - name: Create cache-memory directory + run: bash /opt/gh-aw/actions/create_cache_memory_dir.sh + - name: Restore cache-memory file share data + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} + path: /tmp/gh-aw/cache-memory + restore-keys: | + memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} @@ -139,12 +312,11 @@ jobs: engine_name: "GitHub Copilot CLI", model: process.env.GH_AW_MODEL_AGENT_COPILOT || "", version: "", - agent_version: "0.0.409", - cli_version: "v0.43.22", - workflow_name: "Daily Repo Status", + agent_version: "0.0.410", + cli_version: "v0.45.5", + workflow_name: "13.2 Release Burndown Report", experimental: false, supports_tools_allowlist: true, - supports_http_transport: true, run_id: context.runId, run_number: context.runNumber, run_attempt: process.env.GITHUB_RUN_ATTEMPT, @@ -156,8 +328,8 @@ jobs: staged: false, allowed_domains: ["defaults"], firewall_enabled: true, - awf_version: "v0.16.4", - awmg_version: "", + awf_version: "v0.19.1", + awmg_version: "v0.1.4", steps: { firewall: "squid" }, @@ -178,11 +350,11 @@ jobs: env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Install GitHub Copilot CLI - run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.409 + run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.410 - name: Install awf binary - run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.16.4 + run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.19.1 - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.16.4 ghcr.io/github/gh-aw-firewall/squid:0.16.4 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 node:lts-alpine + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.19.1 ghcr.io/github/gh-aw-firewall/squid:0.19.1 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 node:lts-alpine - name: Write Safe Outputs Config run: | mkdir -p /opt/gh-aw/safeoutputs @@ -194,7 +366,7 @@ jobs: cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' [ { - "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[repo-status] \". Labels [report daily-status] will be automatically added.", + "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[13.2-burndown] \". Labels [report burndown] will be automatically added.", "inputSchema": { "additionalProperties": false, "properties": { @@ -218,7 +390,7 @@ jobs: }, "temporary_id": { "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 3 to 8 alphanumeric characters (e.g., 'aw_abc1', 'aw_Test123'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", - "pattern": "^aw_[A-Za-z0-9]{4,8}$", + "pattern": "^aw_[A-Za-z0-9]{3,8}$", "type": "string" }, "title": { @@ -412,7 +584,7 @@ jobs: bash /opt/gh-aw/actions/start_safe_outputs_server.sh - - name: Start MCP gateway + - name: Start MCP Gateway id: start-mcp-gateway env: GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} @@ -446,7 +618,7 @@ jobs: "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", "GITHUB_READ_ONLY": "1", - "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + "GITHUB_TOOLSETS": "repos,issues,pull_requests,discussions,search" } }, "safeoutputs": { @@ -471,149 +643,36 @@ jobs: script: | const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); await generateWorkflowOverview(core); - - name: Create prompt with built-in context - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - run: | - bash /opt/gh-aw/actions/create_prompt_first.sh - cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT" - - GH_AW_PROMPT_EOF - cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT" - cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" - cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - - GitHub API Access Instructions - - The gh CLI is NOT authenticated. Do NOT use gh commands for GitHub operations. - - - To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls. - - Temporary IDs: Some safe output tools support a temporary ID field (usually named temporary_id) so you can reference newly-created items elsewhere in the SAME agent output (for example, using #aw_abc1 in a later body). - - **IMPORTANT - temporary_id format rules:** - - If you DON'T need to reference the item later, OMIT the temporary_id field entirely (it will be auto-generated if needed) - - If you DO need cross-references/chaining, you MUST match this EXACT validation regex: /^aw_[A-Za-z0-9]{3,8}$/i - - Format: aw_ prefix followed by 3 to 8 alphanumeric characters (A-Z, a-z, 0-9, case-insensitive) - - Valid alphanumeric characters: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 - - INVALID examples: aw_ab (too short), aw_123456789 (too long), aw_test-id (contains hyphen), aw_id_123 (contains underscore) - - VALID examples: aw_abc, aw_abc1, aw_Test123, aw_A1B2C3D4, aw_12345678 - - To generate valid IDs: use 3-8 random alphanumeric characters or omit the field to let the system auto-generate - - Do NOT invent other aw_* formats — downstream steps will reject them with validation errors matching against /^aw_[A-Za-z0-9]{3,8}$/i. - - Discover available tools from the safeoutputs MCP server. - - **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped. - - **Note**: If you made no other safe output tool calls during this workflow execution, call the "noop" tool to provide a status message indicating completion or that no actions were needed. - - - - The following GitHub context information is available for this workflow: - {{#if __GH_AW_GITHUB_ACTOR__ }} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if __GH_AW_GITHUB_REPOSITORY__ }} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if __GH_AW_GITHUB_WORKSPACE__ }} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} - - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} - - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} - - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} - - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ - {{/if}} - {{#if __GH_AW_GITHUB_RUN_ID__ }} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - {{#runtime-import .github/workflows/daily-repo-status.md}} - GH_AW_PROMPT_EOF - - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - with: - script: | - const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - - // Call the substitution function - return await substitutePlaceholders({ - file: process.env.GH_AW_PROMPT, - substitutions: { - GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, - GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, - GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, - GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, - GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE - } - }); - - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + - name: Download prompt artifact + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); - await main(); - - name: Validate prompt placeholders - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh - - name: Print prompt - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/print_prompt_summary.sh + name: prompt + path: /tmp/gh-aw/aw-prompts - name: Clean git credentials run: bash /opt/gh-aw/actions/clean_git_credentials.sh - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): + # --allow-tool github + # --allow-tool safeoutputs + # --allow-tool shell(cat) + # --allow-tool shell(date) + # --allow-tool shell(echo) + # --allow-tool shell(grep) + # --allow-tool shell(head) + # --allow-tool shell(ls) + # --allow-tool shell(pwd) + # --allow-tool shell(sort) + # --allow-tool shell(tail) + # --allow-tool shell(uniq) + # --allow-tool shell(wc) + # --allow-tool shell(yq) + # --allow-tool write timeout-minutes: 20 run: | set -o pipefail - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.16.4 --skip-pull \ - -- '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' \ - 2>&1 | tee /tmp/gh-aw/agent-stdio.log + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.19.1 --skip-pull \ + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --add-dir /tmp/gh-aw/cache-memory/ --allow-all-paths --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} @@ -654,7 +713,7 @@ jobs: else echo "No session-state directory found at $SESSION_STATE_DIR" fi - - name: Stop MCP gateway + - name: Stop MCP Gateway if: always() continue-on-error: true env: @@ -680,13 +739,14 @@ jobs: SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload Safe Outputs if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: safe-output path: ${{ env.GH_AW_SAFE_OUTPUTS }} if-no-files-found: warn - name: Ingest agent output id: collect_output + if: always() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} @@ -701,13 +761,13 @@ jobs: await main(); - name: Upload sanitized agent output if: always() && env.GH_AW_AGENT_OUTPUT - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: agent-output path: ${{ env.GH_AW_AGENT_OUTPUT }} if-no-files-found: warn - name: Upload engine output files - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: agent_outputs path: | @@ -725,7 +785,7 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs'); await main(); - - name: Parse MCP gateway logs for step summary + - name: Parse MCP Gateway logs for step summary if: always() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: @@ -743,11 +803,22 @@ jobs: # Fix permissions on firewall logs so they can be uploaded as artifacts # AWF runs with sudo, creating files owned by root sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true - awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Upload cache-memory data as artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + if: always() + with: + name: cache-memory + path: /tmp/gh-aw/cache-memory - name: Upload agent artifacts if: always() continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: agent-artifacts path: | @@ -765,25 +836,24 @@ jobs: - agent - detection - safe_outputs + - update_cache_memory if: (always()) && (needs.agent.result != 'skipped') runs-on: ubuntu-slim permissions: contents: read - discussions: write issues: write - pull-requests: write outputs: noop_message: ${{ steps.noop.outputs.noop_message }} tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@fe858c3e14589bf396594a0b106e634d9065823e # v0.43.22 + uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 with: destination: /opt/gh-aw/actions - name: Download agent output artifact continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: name: agent-output path: /tmp/gh-aw/safeoutputs/ @@ -798,9 +868,7 @@ jobs: env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_NOOP_MAX: 1 - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb/workflows/daily-repo-status.md" + GH_AW_WORKFLOW_NAME: "13.2 Release Burndown Report" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -813,9 +881,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb/workflows/daily-repo-status.md" + GH_AW_WORKFLOW_NAME: "13.2 Release Burndown Report" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -828,9 +894,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb/workflows/daily-repo-status.md" + GH_AW_WORKFLOW_NAME: "13.2 Release Burndown Report" GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_WORKFLOW_ID: "daily-repo-status" @@ -848,9 +912,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb/workflows/daily-repo-status.md" + GH_AW_WORKFLOW_NAME: "13.2 Release Burndown Report" GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} @@ -862,47 +924,31 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); await main(); - - name: Update reaction comment with completion status - id: conclusion - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} - GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }} - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.result }} - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/notify_comment_error.cjs'); - await main(); detection: needs: agent if: needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true' runs-on: ubuntu-latest permissions: {} + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" timeout-minutes: 10 outputs: success: ${{ steps.parse_results.outputs.success }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@fe858c3e14589bf396594a0b106e634d9065823e # v0.43.22 + uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 with: destination: /opt/gh-aw/actions - name: Download agent artifacts continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: name: agent-artifacts path: /tmp/gh-aw/threat-detection/ - name: Download agent output artifact continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: name: agent-output path: /tmp/gh-aw/threat-detection/ @@ -914,8 +960,8 @@ jobs: - name: Setup threat detection uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: - WORKFLOW_NAME: "Daily Repo Status" - WORKFLOW_DESCRIPTION: "This workflow creates daily repo status reports. It gathers recent repository\nactivity (issues, PRs, discussions, releases, code changes) and generates\nengaging GitHub issues with productivity insights, community highlights,\nand project recommendations." + WORKFLOW_NAME: "13.2 Release Burndown Report" + WORKFLOW_DESCRIPTION: "Daily burndown report for the Aspire 13.2 milestone. Tracks progress\non issues closed, new bugs found, notable changes merged into the\nrelease/13.2 branch, pending PR reviews, and discussions. Generates\na 7-day burndown chart using cached daily snapshots." HAS_PATCH: ${{ needs.agent.outputs.has_patch }} with: script: | @@ -933,7 +979,7 @@ jobs: env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Install GitHub Copilot CLI - run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.409 + run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.410 - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): @@ -974,7 +1020,7 @@ jobs: await main(); - name: Upload threat detection log if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: threat-detection.log path: /tmp/gh-aw/threat-detection/detection.log @@ -991,10 +1037,9 @@ jobs: issues: write timeout-minutes: 15 env: + GH_AW_ENGINE_ID: "copilot" GH_AW_WORKFLOW_ID: "daily-repo-status" - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb/workflows/daily-repo-status.md" + GH_AW_WORKFLOW_NAME: "13.2 Release Burndown Report" outputs: create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} @@ -1002,12 +1047,12 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@fe858c3e14589bf396594a0b106e634d9065823e # v0.43.22 + uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 with: destination: /opt/gh-aw/actions - name: Download agent output artifact continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: name: agent-output path: /tmp/gh-aw/safeoutputs/ @@ -1021,7 +1066,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"labels\":[\"report\",\"daily-status\"],\"max\":1,\"title_prefix\":\"[repo-status] \"},\"missing_data\":{},\"missing_tool\":{}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"close_older_issues\":true,\"labels\":[\"report\",\"burndown\"],\"max\":1,\"title_prefix\":\"[13.2-burndown] \"},\"missing_data\":{},\"missing_tool\":{}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -1030,3 +1075,27 @@ jobs: const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); await main(); + update_cache_memory: + needs: + - agent + - detection + if: always() && needs.detection.outputs.success == 'true' + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 + with: + destination: /opt/gh-aw/actions + - name: Download cache-memory artifact (default) + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + continue-on-error: true + with: + name: cache-memory + path: /tmp/gh-aw/cache-memory + - name: Save cache-memory to cache (default) + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} + path: /tmp/gh-aw/cache-memory + diff --git a/.github/workflows/daily-repo-status.md b/.github/workflows/daily-repo-status.md index 8d91504c61d..6291aed99d5 100644 --- a/.github/workflows/daily-repo-status.md +++ b/.github/workflows/daily-repo-status.md @@ -1,54 +1,131 @@ --- description: | - This workflow creates daily repo status reports. It gathers recent repository - activity (issues, PRs, discussions, releases, code changes) and generates - engaging GitHub issues with productivity insights, community highlights, - and project recommendations. + Daily burndown report for the Aspire 13.2 milestone. Tracks progress + on issues closed, new bugs found, notable changes merged into the + release/13.2 branch, pending PR reviews, and discussions. Generates + a 7-day burndown chart using cached daily snapshots. on: - schedule: daily + schedule: daily around 9am workflow_dispatch: permissions: contents: read issues: read pull-requests: read + discussions: read network: defaults tools: github: - # If in a public repo, setting `lockdown: false` allows - # reading issues, pull requests and comments from 3rd-parties - # If in a private repo this has no particular effect. + toolsets: [repos, issues, pull_requests, discussions, search] lockdown: false + cache-memory: + bash: ["echo", "date", "cat", "wc"] safe-outputs: create-issue: - title-prefix: "[repo-status] " - labels: [report, daily-status] -source: githubnext/agentics/workflows/daily-repo-status.md@69b5e3ae5fa7f35fa555b0a22aee14c36ab57ebb + title-prefix: "[13.2-burndown] " + labels: [report, burndown] + close-older-issues: true --- -# Daily Repo Status +# 13.2 Release Burndown Report -Create an upbeat daily status report for the repo as a GitHub issue. +Create a daily burndown report for the **Aspire 13.2 milestone** as a GitHub issue. +The primary goal of this report is to help the team track progress towards the 13.2 release. -## What to include +## Data gathering -- Recent repository activity (issues, PRs, discussions, releases, code changes) -- Progress tracking, goal reminders and highlights -- Project status and recommendations -- Actionable next steps for maintainers +Collect the following data using the GitHub tools. All time-based queries should look at the **last 24 hours** unless stated otherwise. + +### 1. Milestone snapshot + +- Find the milestone named **13.2** in this repository. +- Count the **total open issues** and **total closed issues** in the milestone, **excluding pull requests**. Use an issues-only filter (for example, a search query like `is:issue milestone:"13.2" state:open` / `state:closed`) so the counts are consistent across tools. +- Store today's snapshot (date, open count, closed count) using the **cache-memory** tool with the key `burndown-13.2-snapshot`. + - The value for this key **must** be a JSON array of objects with the exact shape: + `[{ "date": "YYYY-MM-DD", "open": , "closed": }, ...]` + - When writing today's data: + 1. Read the existing cache value (if any) and parse it as JSON. If the cache is empty or invalid, start from an empty array. + 2. If an entry for today's date already exists, **replace** it instead of adding a duplicate. + 3. If no entry exists, append a new object. + 4. Sort by date ascending and trim to the **most recent 7 entries**. + 5. Serialize back to JSON and overwrite the cache value. + +### 2. Issues closed in the last 24 hours (13.2 milestone) + +- Search for issues in this repository that were **closed in the last 24 hours** and belong to the **13.2 milestone**. +- For each issue, note the issue number, title, and who closed it. + +### 3. New issues added to 13.2 milestone in the last 24 hours + +- Search for issues in this repository that were **opened in the last 24 hours** and are assigned to the **13.2 milestone**. +- Highlight any that are labeled as `bug` — these are newly discovered bugs for the release. + +### 4. Notable changes merged into release/13.2 + +- Look at pull requests **merged in the last 24 hours** whose **base branch is `release/13.2`**. +- Summarize the most impactful or interesting changes (group by area if possible). + +### 5. PRs pending review targeting release/13.2 + +- Find **open pull requests** with base branch `release/13.2` that are **awaiting reviews** (have no approving reviews yet, or have review requests pending). +- List them with PR number, title, author, and how long they've been open. + +### 6. Discussions related to 13.2 + +- Search discussions in this repository that mention "13.2" or the milestone, especially any **recent activity in the last 24 hours**. +- Briefly summarize any relevant discussion threads. + +### 7. General triage needs (secondary) + +- Briefly note any **new issues opened in the last 24 hours that have no milestone assigned** and may need triage. +- Keep this section short — the focus is on 13.2. + +## Burndown chart + +Using the historical data stored via **cache-memory** (key: `burndown-13.2-snapshot`), generate a **Mermaid xychart** showing the number of **open issues** in the 13.2 milestone over the last 7 days (or however many data points are available). + +Use this format so it renders natively in the GitHub issue: + +~~~ +```mermaid +xychart-beta + title "13.2 Milestone Burndown (Open Issues)" + x-axis [Feb 13, Feb 14, Feb 15, ...] + y-axis "Open Issues" 0 --> MAX + line [N1, N2, N3, ...] +``` +~~~ + +If fewer than 2 data points are available, note that the chart will become richer over the coming days as more snapshots are collected, and still show whatever data is available. + +## Report structure + +Create a GitHub issue with the following sections in this order: + +1. **📊 Burndown Chart** — The Mermaid chart (or a note that data is still being collected) +2. **📈 Milestone Progress** — Total open vs closed, percentage complete, net change today +3. **✅ Issues Closed Today** — Table or list of issues closed in the 13.2 milestone +4. **🐛 New Bugs Found** — Any new bug issues added to the 13.2 milestone +5. **🚀 Notable Changes Merged** — Summary of impactful PRs merged to release/13.2 +6. **👀 PRs Awaiting Review** — Open PRs targeting release/13.2 that need reviewer attention +7. **💬 Discussions** — Relevant 13.2 discussion activity +8. **📋 Triage Queue** — Brief list of un-milestoned issues that need attention (keep short) ## Style -- Be positive, encouraging, and helpful 🌟 -- Use emojis moderately for engagement -- Keep it concise - adjust length based on actual activity +- Be concise and data-driven — this is a status report, not a blog post +- Use tables for lists of issues and PRs where appropriate +- Use emojis for section headers to make scanning easy +- If there was no activity in a section, say so briefly (e.g., "No new bugs found today 🎉") +- End with a one-line motivational note for the team ## Process -1. Gather recent activity from the repository -2. Study the repository, its issues and its pull requests -3. Create a new GitHub issue with your findings and insights +1. Gather all the data described above +2. Read historical burndown data from cache-memory and store today's snapshot +3. Generate the burndown chart +4. Create a new GitHub issue with all sections populated From 17cae0da3444c2ca790319d798572586212ab87e Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Thu, 19 Feb 2026 13:30:47 +1100 Subject: [PATCH 121/256] Fix TryGetResourceToolMap cache miss causing perpetual tools/list refresh loop (#14539) TryGetResourceToolMap always returned false because it compared _selectedAppHostPath against _auxiliaryBackchannelMonitor.SelectedAppHostPath, which is only set by explicit select_apphost calls (usually null). After RefreshResourceToolMapAsync sets _selectedAppHostPath to the connection's actual path, the comparison null != "/path/to/AppHost" always failed, so every tools/list call triggered a full refresh instead of using the cached map. Fix: Add ResolvedAppHostPath property to IAuxiliaryBackchannelMonitor that returns SelectedConnection?.AppHostInfo?.AppHostPath, and compare against that. Rename field to _lastRefreshedAppHostPath for clarity. Fixes #14538 Co-authored-by: Mitch Denny Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../IAuxiliaryBackchannelMonitor.cs | 5 ++ .../Mcp/McpResourceToolRefreshService.cs | 6 +- .../Commands/AgentMcpCommandTests.cs | 60 +++++++++++++++++++ .../TestAppHostAuxiliaryBackchannel.cs | 11 ++++ 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs index 36f0a90d257..5ab0d332686 100644 --- a/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs +++ b/src/Aspire.Cli/Backchannel/IAuxiliaryBackchannelMonitor.cs @@ -31,6 +31,11 @@ internal interface IAuxiliaryBackchannelMonitor /// IAppHostAuxiliaryBackchannel? SelectedConnection { get; } + /// + /// Gets the AppHost path of the currently resolved connection, or null if no connection is available. + /// + string? ResolvedAppHostPath => SelectedConnection?.AppHostInfo?.AppHostPath; + /// /// Gets all connections that are within the scope of the specified working directory. /// diff --git a/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs b/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs index e3d3c368333..31e633058d2 100644 --- a/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs +++ b/src/Aspire.Cli/Mcp/McpResourceToolRefreshService.cs @@ -20,7 +20,7 @@ internal sealed class McpResourceToolRefreshService : IMcpResourceToolRefreshSer private McpServer? _server; private Dictionary _resourceToolMap = new(StringComparer.Ordinal); private bool _invalidated = true; - private string? _selectedAppHostPath; + private string? _lastRefreshedAppHostPath; public McpResourceToolRefreshService( IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor, @@ -35,7 +35,7 @@ public bool TryGetResourceToolMap(out IReadOnlyDictionary + { + Interlocked.Increment(ref getResourceSnapshotsCallCount); + return Task.FromResult(new List + { + new ResourceSnapshot + { + Name = "db-mcp-xyz", + DisplayName = "db-mcp", + ResourceType = "Container", + State = "Running", + McpServer = new ResourceSnapshotMcpServer + { + EndpointUrl = "http://localhost:8080/mcp", + Tools = + [ + new Tool + { + Name = "query_db", + Description = "Query the database" + } + ] + } + } + }); + } + }; + + _backchannelMonitor.AddConnection(mockBackchannel.Hash, mockBackchannel.SocketPath, mockBackchannel); + + // Act - Call ListTools twice + var tools1 = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout(); + var tools2 = await _mcpClient.ListToolsAsync(cancellationToken: _cts.Token).DefaultTimeout(); + + // Assert - Both calls return the resource tool + Assert.Contains(tools1, t => t.Name == "db_mcp_query_db"); + Assert.Contains(tools2, t => t.Name == "db_mcp_query_db"); + + // The resource tool map should be cached after the first call, + // so GetResourceSnapshotsAsync should only be called once (during the first refresh). + // Before the fix, TryGetResourceToolMap always returned false due to + // SelectedAppHostPath vs SelectedConnection path mismatch, causing every + // ListTools call to trigger a full refresh. + Assert.Equal(1, getResourceSnapshotsCallCount); + } + [Fact] public async Task McpServer_CallTool_UnknownTool_ReturnsError() { diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs index 31c7ea06dd8..fe266efc374 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs @@ -46,6 +46,12 @@ internal sealed class TestAppHostAuxiliaryBackchannel : IAppHostAuxiliaryBackcha /// public Func?, CancellationToken, Task>? CallResourceMcpToolHandler { get; set; } + /// + /// Gets or sets the function to call when GetResourceSnapshotsAsync is invoked. + /// If null, returns the ResourceSnapshots list. + /// + public Func>>? GetResourceSnapshotsHandler { get; set; } + public Task GetDashboardUrlsAsync(CancellationToken cancellationToken = default) { return Task.FromResult(DashboardUrlsState); @@ -53,6 +59,11 @@ internal sealed class TestAppHostAuxiliaryBackchannel : IAppHostAuxiliaryBackcha public Task> GetResourceSnapshotsAsync(CancellationToken cancellationToken = default) { + if (GetResourceSnapshotsHandler is not null) + { + return GetResourceSnapshotsHandler(cancellationToken); + } + return Task.FromResult(ResourceSnapshots); } From dcddebab401295ea37d3cb86d9815d541b694302 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:46:12 -0600 Subject: [PATCH 122/256] Add AzureServiceTags with common Azure service tags for NSG rules (#14452) * Add AzureServiceTags class with common Azure service tags and tests Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> * Use the new constants in more places. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> Co-authored-by: Eric Erhardt --- .../Program.cs | 11 +- .../AzureServiceTags.cs | 111 ++++++++++++++++++ .../AzureVirtualNetworkExtensions.cs | 4 +- src/Aspire.Hosting.Azure.Network/README.md | 4 +- ...zureNetworkSecurityGroupExtensionsTests.cs | 4 +- .../AzureVirtualNetworkExtensionsTests.cs | 60 ++++++++++ 6 files changed, 183 insertions(+), 11 deletions(-) create mode 100644 src/Aspire.Hosting.Azure.Network/AzureServiceTags.cs diff --git a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs index f7dcb60b2b4..81ad596b9e8 100644 --- a/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs +++ b/playground/AzureVirtualNetworkEndToEnd/AzureVirtualNetworkEndToEnd.AppHost/Program.cs @@ -3,6 +3,7 @@ #pragma warning disable AZPROVISION001 // Azure.Provisioning.Network is experimental +using Aspire.Hosting.Azure; using Azure.Provisioning.Network; var builder = DistributedApplication.CreateBuilder(args); @@ -13,17 +14,17 @@ var vnet = builder.AddAzureVirtualNetwork("vnet"); var containerAppsSubnet = vnet.AddSubnet("container-apps", "10.0.0.0/23") - .AllowInbound(port: "443", from: "AzureLoadBalancer", protocol: SecurityRuleProtocol.Tcp) - .DenyInbound(from: "VirtualNetwork") - .DenyInbound(from: "Internet"); + .AllowInbound(port: "443", from: AzureServiceTags.AzureLoadBalancer, protocol: SecurityRuleProtocol.Tcp) + .DenyInbound(from: AzureServiceTags.VirtualNetwork) + .DenyInbound(from: AzureServiceTags.Internet); // Create a NAT Gateway for deterministic outbound IP on the ACA subnet var natGateway = builder.AddNatGateway("nat"); containerAppsSubnet.WithNatGateway(natGateway); var privateEndpointsSubnet = vnet.AddSubnet("private-endpoints", "10.0.2.0/27") - .AllowInbound(port: "443", from: "VirtualNetwork", protocol: SecurityRuleProtocol.Tcp) - .DenyInbound(from: "Internet"); + .AllowInbound(port: "443", from: AzureServiceTags.VirtualNetwork, protocol: SecurityRuleProtocol.Tcp) + .DenyInbound(from: AzureServiceTags.Internet); // Configure the Container App Environment to use the VNet builder.AddAzureContainerAppEnvironment("env") diff --git a/src/Aspire.Hosting.Azure.Network/AzureServiceTags.cs b/src/Aspire.Hosting.Azure.Network/AzureServiceTags.cs new file mode 100644 index 00000000000..d09d8b61ec6 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Network/AzureServiceTags.cs @@ -0,0 +1,111 @@ +// 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.Azure; + +/// +/// Provides well-known Azure service tags that can be used as source or destination address prefixes +/// in network security group rules. +/// +/// +/// +/// Service tags represent a group of IP address prefixes from a given Azure service. Microsoft manages the +/// address prefixes encompassed by each tag and automatically updates them as addresses change. +/// +/// +/// These tags can be used with the from and to parameters of methods such as +/// , , +/// , , +/// or with the and properties. +/// +/// +/// +/// Use service tags when configuring network security rules: +/// +/// var subnet = vnet.AddSubnet("web", "10.0.1.0/24") +/// .AllowInbound(port: "443", from: AzureServiceTags.AzureLoadBalancer, protocol: SecurityRuleProtocol.Tcp) +/// .DenyInbound(from: AzureServiceTags.Internet); +/// +/// +public static class AzureServiceTags +{ + /// + /// Represents the Internet address space, including all publicly routable IP addresses. + /// + public const string Internet = nameof(Internet); + + /// + /// Represents the address space for the virtual network, including all connected address spaces, + /// all connected on-premises address spaces, and peered virtual networks. + /// + public const string VirtualNetwork = nameof(VirtualNetwork); + + /// + /// Represents the Azure infrastructure load balancer. This tag is commonly used to allow + /// health probe traffic from Azure. + /// + public const string AzureLoadBalancer = nameof(AzureLoadBalancer); + + /// + /// Represents Azure Traffic Manager probe IP addresses. + /// + public const string AzureTrafficManager = nameof(AzureTrafficManager); + + /// + /// Represents the Azure Storage service. This tag does not include specific Storage accounts; + /// it covers all Azure Storage IP addresses. + /// + public const string Storage = nameof(Storage); + + /// + /// Represents Azure SQL Database, Azure Database for MySQL, Azure Database for PostgreSQL, + /// Azure Database for MariaDB, and Azure Synapse Analytics. + /// + public const string Sql = nameof(Sql); + + /// + /// Represents Azure Cosmos DB service addresses. + /// + public const string AzureCosmosDB = nameof(AzureCosmosDB); + + /// + /// Represents Azure Key Vault service addresses. + /// + public const string AzureKeyVault = nameof(AzureKeyVault); + + /// + /// Represents Azure Event Hubs service addresses. + /// + public const string EventHub = nameof(EventHub); + + /// + /// Represents Azure Service Bus service addresses. + /// + public const string ServiceBus = nameof(ServiceBus); + + /// + /// Represents Azure Container Registry service addresses. + /// + public const string AzureContainerRegistry = nameof(AzureContainerRegistry); + + /// + /// Represents Azure App Service and Azure Functions service addresses. + /// + public const string AppService = nameof(AppService); + + /// + /// Represents Microsoft Entra ID (formerly Azure Active Directory) service addresses. + /// + public const string AzureActiveDirectory = nameof(AzureActiveDirectory); + + /// + /// Represents Azure Monitor service addresses, including Log Analytics, Application Insights, + /// and Azure Monitor metrics. + /// + public const string AzureMonitor = nameof(AzureMonitor); + + /// + /// Represents the Gateway Manager service, used for VPN Gateway and Application Gateway management traffic. + /// + public const string GatewayManager = nameof(GatewayManager); +} diff --git a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs index 4e15aa7ad3f..3d5d18475c0 100644 --- a/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs +++ b/src/Aspire.Hosting.Azure.Network/AzureVirtualNetworkExtensions.cs @@ -361,8 +361,8 @@ public static IResourceBuilder WithNetworkSecurityGroup( /// This example allows HTTPS traffic from the Azure Load Balancer: /// /// var subnet = vnet.AddSubnet("web", "10.0.1.0/24") - /// .AllowInbound(port: "443", from: "AzureLoadBalancer", protocol: SecurityRuleProtocol.Tcp) - /// .DenyInbound(from: "Internet"); + /// .AllowInbound(port: "443", from: AzureServiceTags.AzureLoadBalancer, protocol: SecurityRuleProtocol.Tcp) + /// .DenyInbound(from: AzureServiceTags.Internet); /// /// public static IResourceBuilder AllowInbound( diff --git a/src/Aspire.Hosting.Azure.Network/README.md b/src/Aspire.Hosting.Azure.Network/README.md index 90975e8bbc6..3590fb1e102 100644 --- a/src/Aspire.Hosting.Azure.Network/README.md +++ b/src/Aspire.Hosting.Azure.Network/README.md @@ -90,8 +90,8 @@ Add security rules to control traffic flow on subnets using shorthand methods: ```csharp var vnet = builder.AddAzureVirtualNetwork("vnet"); var subnet = vnet.AddSubnet("web", "10.0.1.0/24") - .AllowInbound(port: "443", from: "AzureLoadBalancer", protocol: SecurityRuleProtocol.Tcp) - .DenyInbound(from: "Internet"); + .AllowInbound(port: "443", from: AzureServiceTags.AzureLoadBalancer, protocol: SecurityRuleProtocol.Tcp) + .DenyInbound(from: AzureServiceTags.Internet); ``` An NSG is automatically created when shorthand methods are used. Priority auto-increments (100, 200, 300...) and rule names are auto-generated. diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureNetworkSecurityGroupExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureNetworkSecurityGroupExtensionsTests.cs index 964a030b6a3..79ba00243ac 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureNetworkSecurityGroupExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureNetworkSecurityGroupExtensionsTests.cs @@ -247,7 +247,7 @@ public async Task MultipleNSGs_WithSameRuleName_GeneratesDistinctBicepIdentifier Direction = SecurityRuleDirection.Inbound, Access = SecurityRuleAccess.Allow, Protocol = SecurityRuleProtocol.Tcp, - SourceAddressPrefix = "VirtualNetwork", + SourceAddressPrefix = AzureServiceTags.VirtualNetwork, SourcePortRange = "*", DestinationAddressPrefix = "*", DestinationPortRange = "443" @@ -271,7 +271,7 @@ public void WithNetworkSecurityGroup_AfterShorthand_Throws() var vnet = builder.AddAzureVirtualNetwork("myvnet"); var nsg = builder.AddNetworkSecurityGroup("web-nsg"); var subnet = vnet.AddSubnet("web-subnet", "10.0.1.0/24") - .AllowInbound(port: "443", from: "AzureLoadBalancer"); + .AllowInbound(port: "443", from: AzureServiceTags.AzureLoadBalancer); var exception = Assert.Throws(() => subnet.WithNetworkSecurityGroup(nsg)); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs index 08fafbde1a3..1747a3b1f96 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureVirtualNetworkExtensionsTests.cs @@ -361,6 +361,66 @@ await Verify(vnetManifest.BicepText, extension: "bicep") .AppendContentAsFile(nsgManifest.BicepText, "bicep", "nsg"); } + [Fact] + public void ServiceTags_CanBeUsedAsFromAndToParameters() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var vnet = builder.AddAzureVirtualNetwork("myvnet"); + var subnet = vnet.AddSubnet("web", "10.0.1.0/24") + .AllowInbound(port: "443", from: AzureServiceTags.AzureLoadBalancer, protocol: SecurityRuleProtocol.Tcp) + .DenyInbound(from: AzureServiceTags.Internet) + .AllowOutbound(port: "443", to: AzureServiceTags.Storage) + .DenyOutbound(to: AzureServiceTags.VirtualNetwork); + + var rules = subnet.Resource.NetworkSecurityGroup!.SecurityRules; + Assert.Equal(4, rules.Count); + + Assert.Equal("AzureLoadBalancer", rules[0].SourceAddressPrefix); + Assert.Equal("Internet", rules[1].SourceAddressPrefix); + Assert.Equal("Storage", rules[2].DestinationAddressPrefix); + Assert.Equal("VirtualNetwork", rules[3].DestinationAddressPrefix); + } + + [Fact] + public void ServiceTags_CanBeUsedInSecurityRuleProperties() + { + var rule = new AzureSecurityRule + { + Name = "allow-https-from-lb", + Priority = 100, + Direction = SecurityRuleDirection.Inbound, + Access = SecurityRuleAccess.Allow, + Protocol = SecurityRuleProtocol.Tcp, + SourceAddressPrefix = AzureServiceTags.AzureLoadBalancer, + DestinationAddressPrefix = AzureServiceTags.VirtualNetwork, + DestinationPortRange = "443" + }; + + Assert.Equal("AzureLoadBalancer", rule.SourceAddressPrefix); + Assert.Equal("VirtualNetwork", rule.DestinationAddressPrefix); + } + + [Fact] + public void ServiceTags_HaveExpectedValues() + { + Assert.Equal("Internet", AzureServiceTags.Internet); + Assert.Equal("VirtualNetwork", AzureServiceTags.VirtualNetwork); + Assert.Equal("AzureLoadBalancer", AzureServiceTags.AzureLoadBalancer); + Assert.Equal("AzureTrafficManager", AzureServiceTags.AzureTrafficManager); + Assert.Equal("Storage", AzureServiceTags.Storage); + Assert.Equal("Sql", AzureServiceTags.Sql); + Assert.Equal("AzureCosmosDB", AzureServiceTags.AzureCosmosDB); + Assert.Equal("AzureKeyVault", AzureServiceTags.AzureKeyVault); + Assert.Equal("EventHub", AzureServiceTags.EventHub); + Assert.Equal("ServiceBus", AzureServiceTags.ServiceBus); + Assert.Equal("AzureContainerRegistry", AzureServiceTags.AzureContainerRegistry); + Assert.Equal("AppService", AzureServiceTags.AppService); + Assert.Equal("AzureActiveDirectory", AzureServiceTags.AzureActiveDirectory); + Assert.Equal("AzureMonitor", AzureServiceTags.AzureMonitor); + Assert.Equal("GatewayManager", AzureServiceTags.GatewayManager); + } + [Fact] public void AllFourDirectionAccessCombos_SetCorrectly() { From f3d3d637f2c6f3f5a2e1e232a4034db7831c1fa4 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 19 Feb 2026 17:57:06 -0600 Subject: [PATCH 123/256] Allow docker build secrets to be files (#14559) * Allow docker build secrets to be files This allows people to pass .npmrc files as docker secrets without baking auth credentials into this image. * Use explicit type in secret argument. --- .../Publishing/BuildImageSecretValue.cs | 32 +++++ .../Publishing/ContainerRuntimeBase.cs | 14 ++- .../Publishing/DockerContainerRuntime.cs | 11 +- .../Publishing/IContainerRuntime.cs | 2 +- .../Publishing/PodmanContainerRuntime.cs | 11 +- .../ResourceContainerImageManager.cs | 6 +- .../Publishing/FakeContainerRuntime.cs | 6 +- .../ResourceContainerImageManagerTests.cs | 114 +++++++++++++++++- 8 files changed, 172 insertions(+), 24 deletions(-) create mode 100644 src/Aspire.Hosting/Publishing/BuildImageSecretValue.cs diff --git a/src/Aspire.Hosting/Publishing/BuildImageSecretValue.cs b/src/Aspire.Hosting/Publishing/BuildImageSecretValue.cs new file mode 100644 index 00000000000..3d32ddd9306 --- /dev/null +++ b/src/Aspire.Hosting/Publishing/BuildImageSecretValue.cs @@ -0,0 +1,32 @@ +// 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; + +namespace Aspire.Hosting.Publishing; + +/// +/// Specifies the type of a build secret. +/// +[Experimental("ASPIRECONTAINERRUNTIME001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public enum BuildImageSecretType +{ + /// + /// The secret value is provided via an environment variable. + /// + Environment, + + /// + /// The secret value is a file path. + /// + File +} + +/// +/// Represents a resolved build secret with its value and type. +/// +/// The resolved secret value. For secrets, this is the secret content. +/// For secrets, this is the file path. +/// The type of the build secret, indicating whether it is environment-based or file-based. +[Experimental("ASPIRECONTAINERRUNTIME001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public record BuildImageSecretValue(string? Value, BuildImageSecretType Type); diff --git a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs index dc313c593a7..6b0bf535b60 100644 --- a/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs +++ b/src/Aspire.Hosting/Publishing/ContainerRuntimeBase.cs @@ -37,7 +37,7 @@ protected ContainerRuntimeBase(ILogger logger) public abstract Task CheckIfRunningAsync(CancellationToken cancellationToken); - public abstract Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken); + public abstract Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken); public virtual async Task TagImageAsync(string localImageName, string targetImageName, CancellationToken cancellationToken) { @@ -241,18 +241,22 @@ protected static string BuildArgumentsString(Dictionary buildAr /// The build secrets to include. /// Whether to require a non-null value for secrets (default: false). /// A string containing the formatted build secrets. - protected static string BuildSecretsString(Dictionary buildSecrets, bool requireValue = false) + internal static string BuildSecretsString(Dictionary buildSecrets, bool requireValue = false) { var result = string.Empty; foreach (var buildSecret in buildSecrets) { - if (requireValue && buildSecret.Value is null) + if (buildSecret.Value.Type == BuildImageSecretType.File) { - result += $" --secret \"id={buildSecret.Key}\""; + result += $" --secret \"id={buildSecret.Key},type=file,src={buildSecret.Value.Value}\""; + } + else if (requireValue && buildSecret.Value.Value is null) + { + result += $" --secret \"id={buildSecret.Key},type=env\""; } else { - result += $" --secret \"id={buildSecret.Key},env={buildSecret.Key.ToUpperInvariant()}\""; + result += $" --secret \"id={buildSecret.Key},type=env,env={buildSecret.Key.ToUpperInvariant()}\""; } } return result; diff --git a/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs b/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs index 7770bc0959a..82607a85a0c 100644 --- a/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/DockerContainerRuntime.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIRECONTAINERRUNTIME001 using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp.Process; @@ -17,7 +18,7 @@ public DockerContainerRuntime(ILogger logger) : base(log protected override string RuntimeExecutable => "docker"; public override string Name => "Docker"; - private async Task RunDockerBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) + private async Task RunDockerBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) { var imageName = !string.IsNullOrEmpty(options?.Tag) ? $"{options.ImageName}:{options.Tag}" @@ -107,12 +108,12 @@ private async Task RunDockerBuildAsync(string contextPath, string dockerfil InheritEnv = true, }; - // Add build secrets as environment variables + // Add build secrets as environment variables (only for environment-type secrets) foreach (var buildSecret in buildSecrets) { - if (buildSecret.Value is not null) + if (buildSecret.Value.Type == BuildImageSecretType.Environment && buildSecret.Value.Value is not null) { - spec.EnvironmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value; + spec.EnvironmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value.Value; } } @@ -145,7 +146,7 @@ private async Task RunDockerBuildAsync(string contextPath, string dockerfil } } - public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) + public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) { // Normalize the context path to handle trailing slashes and relative paths var normalizedContextPath = Path.GetFullPath(contextPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); diff --git a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs index 681f1e6159d..88a4a58a772 100644 --- a/src/Aspire.Hosting/Publishing/IContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/IContainerRuntime.cs @@ -34,7 +34,7 @@ public interface IContainerRuntime /// Build secrets to pass to the build process. /// The target build stage. /// A token to cancel the operation. - Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken); + Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken); /// /// Tags a container image with a new name. diff --git a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs index f6d874ac07c..d93eefd21ae 100644 --- a/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs +++ b/src/Aspire.Hosting/Publishing/PodmanContainerRuntime.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREPIPELINES003 +#pragma warning disable ASPIRECONTAINERRUNTIME001 using Microsoft.Extensions.Logging; @@ -15,7 +16,7 @@ public PodmanContainerRuntime(ILogger logger) : base(log protected override string RuntimeExecutable => "podman"; public override string Name => "Podman"; - private async Task RunPodmanBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) + private async Task RunPodmanBuildAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) { var imageName = !string.IsNullOrEmpty(options?.Tag) ? $"{options.ImageName}:{options.Tag}" @@ -60,13 +61,13 @@ private async Task RunPodmanBuildAsync(string contextPath, string dockerfil arguments += $" \"{contextPath}\""; - // Prepare environment variables for build secrets + // Prepare environment variables for build secrets (only for environment-type secrets) var environmentVariables = new Dictionary(); foreach (var buildSecret in buildSecrets) { - if (buildSecret.Value is not null) + if (buildSecret.Value.Type == BuildImageSecretType.Environment && buildSecret.Value.Value is not null) { - environmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value; + environmentVariables[buildSecret.Key.ToUpperInvariant()] = buildSecret.Value.Value; } } @@ -79,7 +80,7 @@ private async Task RunPodmanBuildAsync(string contextPath, string dockerfil environmentVariables).ConfigureAwait(false); } - public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) + public override async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) { var exitCode = await RunPodmanBuildAsync( contextPath, diff --git a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs index d23f8564f33..f8c8e6c2b5e 100644 --- a/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs +++ b/src/Aspire.Hosting/Publishing/ResourceContainerImageManager.cs @@ -440,10 +440,12 @@ private async Task BuildContainerImageFromDockerfileAsync(IResource resource, Do } // Resolve build secrets - var resolvedBuildSecrets = new Dictionary(); + var resolvedBuildSecrets = new Dictionary(); foreach (var buildSecret in dockerfileBuildAnnotation.BuildSecrets) { - resolvedBuildSecrets[buildSecret.Key] = await ResolveValue(buildSecret.Value, cancellationToken).ConfigureAwait(false); + var secretType = buildSecret.Value is FileInfo ? BuildImageSecretType.File : BuildImageSecretType.Environment; + var resolvedValue = await ResolveValue(buildSecret.Value, cancellationToken).ConfigureAwait(false); + resolvedBuildSecrets[buildSecret.Key] = new BuildImageSecretValue(resolvedValue, secretType); } // ensure outputPath is created if specified since docker/podman won't create it for us diff --git a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs index 100109755df..baec499b9ac 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/FakeContainerRuntime.cs @@ -26,9 +26,9 @@ public sealed class FakeContainerRuntime(bool shouldFail = false, bool isRunning public List<(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options)> BuildImageCalls { get; } = []; public List<(string registryServer, string username, string password)> LoginToRegistryCalls { get; } = []; public Dictionary? CapturedBuildArguments { get; private set; } - public Dictionary? CapturedBuildSecrets { get; private set; } + public Dictionary? CapturedBuildSecrets { get; private set; } public string? CapturedStage { get; private set; } - public Func, Dictionary, string?, CancellationToken, Task>? BuildImageAsyncCallback { get; set; } + public Func, Dictionary, string?, CancellationToken, Task>? BuildImageAsyncCallback { get; set; } public Task CheckIfRunningAsync(CancellationToken cancellationToken) { @@ -70,7 +70,7 @@ public Task PushImageAsync(IResource resource, CancellationToken cancellationTok return Task.CompletedTask; } - public async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) + public async Task BuildImageAsync(string contextPath, string dockerfilePath, ContainerImageBuildOptions? options, Dictionary buildArguments, Dictionary buildSecrets, string? stage, CancellationToken cancellationToken) { // Capture the arguments for verification in tests CapturedBuildArguments = buildArguments; diff --git a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs index 1a9f24eff41..d4cb66a2e4c 100644 --- a/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs +++ b/tests/Aspire.Hosting.Tests/Publishing/ResourceContainerImageManagerTests.cs @@ -676,7 +676,8 @@ public async Task CanBuildImageFromDockerfileWithBuildArgsSecretsAndStage() // Verify that the correct build secrets were passed Assert.NotNull(fakeContainerRuntime.CapturedBuildSecrets); Assert.Single(fakeContainerRuntime.CapturedBuildSecrets); - Assert.Equal("mysecret", fakeContainerRuntime.CapturedBuildSecrets["SECRET_ASENV"]); + Assert.Equal("mysecret", fakeContainerRuntime.CapturedBuildSecrets["SECRET_ASENV"].Value); + Assert.Equal(BuildImageSecretType.Environment, fakeContainerRuntime.CapturedBuildSecrets["SECRET_ASENV"].Type); // Verify that the correct stage was passed Assert.Equal("runner", fakeContainerRuntime.CapturedStage); @@ -829,10 +830,117 @@ public async Task CanResolveBuildSecretsWithDifferentValueTypes() Assert.Equal(2, fakeContainerRuntime.CapturedBuildSecrets.Count); // Parameter should resolve to its configured value - Assert.Equal("secret-value", fakeContainerRuntime.CapturedBuildSecrets["STRING_SECRET"]); + Assert.Equal("secret-value", fakeContainerRuntime.CapturedBuildSecrets["STRING_SECRET"].Value); + Assert.Equal(BuildImageSecretType.Environment, fakeContainerRuntime.CapturedBuildSecrets["STRING_SECRET"].Type); // Null parameter should resolve to null - Assert.Null(fakeContainerRuntime.CapturedBuildSecrets["NULL_SECRET"]); + Assert.Null(fakeContainerRuntime.CapturedBuildSecrets["NULL_SECRET"].Value); + Assert.Equal(BuildImageSecretType.Environment, fakeContainerRuntime.CapturedBuildSecrets["NULL_SECRET"].Type); + } + + [Fact] + public async Task CanResolveBuildSecretsWithFileType() + { + using var builder = TestDistributedApplicationBuilder.Create(output); + + builder.Services.AddLogging(logging => + { + logging.AddFakeLogging(); + logging.AddXunit(output); + }); + + // Create a fake container runtime to capture build secrets + var fakeContainerRuntime = new FakeContainerRuntime(shouldFail: false); + builder.Services.AddKeyedSingleton("docker", fakeContainerRuntime); + + var (tempContextPath, tempDockerfilePath) = await DockerfileUtils.CreateTemporaryDockerfileAsync(); + + // Create a temporary file to use as a file-based secret + using var tempDir = new TestTempDirectory(); + var tempSecretFile = System.IO.Path.Combine(tempDir.Path, ".npmrc"); + await File.WriteAllTextAsync(tempSecretFile, "secret-file-content"); + + // Add an env-based secret parameter + builder.Configuration["Parameters:envsecret"] = "env-secret-value"; + var envSecret = builder.AddParameter("envsecret", secret: true); + + var container = builder.AddDockerfile("container", tempContextPath, tempDockerfilePath) + .WithBuildSecret("ENV_SECRET", envSecret); + + // Add a file-based secret directly via the annotation + var annotation = container.Resource.Annotations.OfType().Single(); + annotation.BuildSecrets["FILE_SECRET"] = new FileInfo(tempSecretFile); + + using var app = builder.Build(); + + using var cts = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var imageBuilder = app.Services.GetRequiredService(); + await imageBuilder.BuildImageAsync(container.Resource, cts.Token); + + // Verify that both secret types are resolved correctly + Assert.NotNull(fakeContainerRuntime.CapturedBuildSecrets); + Assert.Equal(2, fakeContainerRuntime.CapturedBuildSecrets.Count); + + // Environment-based secret + Assert.Equal("env-secret-value", fakeContainerRuntime.CapturedBuildSecrets["ENV_SECRET"].Value); + Assert.Equal(BuildImageSecretType.Environment, fakeContainerRuntime.CapturedBuildSecrets["ENV_SECRET"].Type); + + // File-based secret should resolve to the full file path + Assert.Equal(new FileInfo(tempSecretFile).FullName, fakeContainerRuntime.CapturedBuildSecrets["FILE_SECRET"].Value); + Assert.Equal(BuildImageSecretType.File, fakeContainerRuntime.CapturedBuildSecrets["FILE_SECRET"].Type); + } + + [Fact] + public void BuildSecretsStringFormatsEnvSecretCorrectly() + { + var secrets = new Dictionary + { + ["MY_SECRET"] = new BuildImageSecretValue("secret-value", BuildImageSecretType.Environment) + }; + + var result = ContainerRuntimeBase.BuildSecretsString(secrets); + + Assert.Equal(" --secret \"id=MY_SECRET,type=env,env=MY_SECRET\"", result); + } + + [Fact] + public void BuildSecretsStringFormatsFileSecretCorrectly() + { + var secrets = new Dictionary + { + ["npmrc"] = new BuildImageSecretValue("/path/to/.npmrc", BuildImageSecretType.File) + }; + + var result = ContainerRuntimeBase.BuildSecretsString(secrets); + + Assert.Equal(" --secret \"id=npmrc,type=file,src=/path/to/.npmrc\"", result); + } + + [Fact] + public void BuildSecretsStringFormatsNullEnvSecretWithRequireValue() + { + var secrets = new Dictionary + { + ["MY_SECRET"] = new BuildImageSecretValue(null, BuildImageSecretType.Environment) + }; + + var result = ContainerRuntimeBase.BuildSecretsString(secrets, requireValue: true); + + Assert.Equal(" --secret \"id=MY_SECRET,type=env\"", result); + } + + [Fact] + public void BuildSecretsStringFormatsMixedSecretTypes() + { + var secrets = new Dictionary + { + ["ENV_TOKEN"] = new BuildImageSecretValue("token-value", BuildImageSecretType.Environment), + ["npmrc"] = new BuildImageSecretValue("/app/.npmrc", BuildImageSecretType.File) + }; + + var result = ContainerRuntimeBase.BuildSecretsString(secrets); + + Assert.Equal(" --secret \"id=ENV_TOKEN,type=env,env=ENV_TOKEN\" --secret \"id=npmrc,type=file,src=/app/.npmrc\"", result); } [Fact] From 1dcab1646dcd60c1cf780e54ee9022abc1ad052d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Feb 2026 11:49:38 +1100 Subject: [PATCH 124/256] Fix CopilotCliRunner version parsing for 'GitHub Copilot CLI X.X.X' format (#14568) * Fix CopilotCliRunner version parsing for 'GitHub Copilot CLI X.X.X' format Extract version parsing into a testable TryParseVersionOutput method that handles prefixed version strings like 'GitHub Copilot CLI 0.0.397' by taking the last space-separated token before parsing. Fixes #14174 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR feedback: handle trailing punctuation in version string Trim trailing period and other punctuation before parsing, to handle output like 'GitHub Copilot CLI 0.0.397.' from the issue report. Add test case for the trailing period format. 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> --- .../Agents/CopilotCli/CopilotCliRunner.cs | 58 ++++++++++++------- .../Agents/CopilotCliRunnerTests.cs | 33 +++++++++++ 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/src/Aspire.Cli/Agents/CopilotCli/CopilotCliRunner.cs b/src/Aspire.Cli/Agents/CopilotCli/CopilotCliRunner.cs index 42cc875c1ec..1f5c1a3110e 100644 --- a/src/Aspire.Cli/Agents/CopilotCli/CopilotCliRunner.cs +++ b/src/Aspire.Cli/Agents/CopilotCli/CopilotCliRunner.cs @@ -52,28 +52,8 @@ internal sealed class CopilotCliRunner(ILogger logger) : ICopi } var output = await outputTask.ConfigureAwait(false); - var versionString = output.Trim(); - if (string.IsNullOrEmpty(versionString)) - { - logger.LogDebug("GitHub Copilot CLI returned empty version output"); - return null; - } - - // Version output may be on the first line if multi-line - var lines = versionString.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries); - if (lines.Length > 0) - { - versionString = lines[0].Trim(); - } - - // Try to parse the version string (may have a 'v' prefix like "v1.2.3") - if (versionString.StartsWith('v') || versionString.StartsWith('V')) - { - versionString = versionString[1..]; - } - - if (SemVersion.TryParse(versionString, SemVersionStyles.Any, out var version)) + if (TryParseVersionOutput(output, out var version)) { logger.LogDebug("Found GitHub Copilot CLI version: {Version}", version); return version; @@ -88,4 +68,40 @@ internal sealed class CopilotCliRunner(ILogger logger) : ICopi return null; } } + + internal static bool TryParseVersionOutput(string output, out SemVersion? version) + { + version = null; + var versionString = output.Trim(); + + if (string.IsNullOrEmpty(versionString)) + { + return false; + } + + // Version output may be on the first line if multi-line + var lines = versionString.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries); + if (lines.Length > 0) + { + versionString = lines[0].Trim(); + } + + // Try to extract the version from known formats like "GitHub Copilot CLI 0.0.397" + var lastSpaceIndex = versionString.LastIndexOf(' '); + if (lastSpaceIndex >= 0) + { + versionString = versionString[(lastSpaceIndex + 1)..]; + } + + // Trim common trailing punctuation that may follow the version (for example, "0.0.397.") + versionString = versionString.TrimEnd('.', ',', ')'); + + // Try to parse the version string (may have a 'v' prefix like "v1.2.3") + if (versionString.StartsWith('v') || versionString.StartsWith('V')) + { + versionString = versionString[1..]; + } + + return SemVersion.TryParse(versionString, SemVersionStyles.Any, out version); + } } diff --git a/tests/Aspire.Cli.Tests/Agents/CopilotCliRunnerTests.cs b/tests/Aspire.Cli.Tests/Agents/CopilotCliRunnerTests.cs index e4721afe76e..b55a52d79b1 100644 --- a/tests/Aspire.Cli.Tests/Agents/CopilotCliRunnerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/CopilotCliRunnerTests.cs @@ -24,4 +24,37 @@ public async Task GetVersionAsync_ChecksForCopilot() // we just verify the method completes without throwing // Version can be null (not installed) or a real version } + + [Theory] + [InlineData("GitHub Copilot CLI 0.0.397", 0, 0, 397)] + [InlineData("GitHub Copilot CLI 1.2.3", 1, 2, 3)] + [InlineData("0.0.397", 0, 0, 397)] + [InlineData("1.2.3", 1, 2, 3)] + [InlineData("v1.2.3", 1, 2, 3)] + [InlineData("V1.2.3", 1, 2, 3)] + [InlineData("GitHub Copilot CLI 0.0.397\nsome other output", 0, 0, 397)] + [InlineData(" GitHub Copilot CLI 0.0.397 ", 0, 0, 397)] + [InlineData("GitHub Copilot CLI 0.0.397.", 0, 0, 397)] + public void TryParseVersionOutput_ValidVersionStrings_ReturnsTrue(string input, int major, int minor, int patch) + { + var result = CopilotCliRunner.TryParseVersionOutput(input, out var version); + + Assert.True(result); + Assert.NotNull(version); + Assert.Equal(major, version.Major); + Assert.Equal(minor, version.Minor); + Assert.Equal(patch, version.Patch); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not a version")] + public void TryParseVersionOutput_InvalidVersionStrings_ReturnsFalse(string input) + { + var result = CopilotCliRunner.TryParseVersionOutput(input, out var version); + + Assert.False(result); + Assert.Null(version); + } } From be8bbce7c303b6ac5e3273e1b34cc12fd93fdfad Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Feb 2026 12:36:27 +1100 Subject: [PATCH 125/256] Suppress update notification when running with --detach (#14571) * Suppress update notification when running with --detach When running 'aspire run --detach', the parent process no longer displays the update notification message before exiting. The update check is not useful in detach mode since the parent exits immediately after spawning the child process. Fixes part of https://github.com/dotnet/aspire/issues/14238 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add unit tests for update notification suppression in detach mode Validates that the update notification is not shown when --detach is used, and that it is shown for normal (non-detach) runs. Uses a tracking ICliUpdateNotifier to verify the behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move TestCliUpdateNotifier to shared TestServices folder Consolidate duplicate ICliUpdateNotifier test implementations from RunCommandTests and UpdateCommandTests into a single shared TestCliUpdateNotifier in TestServices, combining tracking and callback capabilities. 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/RunCommand.cs | 4 ++ .../Commands/RunCommandTests.cs | 43 +++++++++++++++++++ .../Commands/UpdateCommandTests.cs | 22 ---------- .../TestServices/TestCliUpdateNotifier.cs | 28 ++++++++++++ 4 files changed, 75 insertions(+), 22 deletions(-) create mode 100644 tests/Aspire.Cli.Tests/TestServices/TestCliUpdateNotifier.cs diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 3e00efb00c2..1157323f66d 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -66,6 +66,9 @@ internal sealed class RunCommand : BaseCommand private readonly IAppHostProjectFactory _projectFactory; private readonly IAuxiliaryBackchannelMonitor _backchannelMonitor; private readonly Diagnostics.FileLoggerProvider _fileLoggerProvider; + private bool _isDetachMode; + + protected override bool UpdateNotificationsEnabled => !_isDetachMode; private static readonly Option s_projectOption = new("--project") { @@ -150,6 +153,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { var passedAppHostProjectFile = parseResult.GetValue(s_projectOption); var detach = parseResult.GetValue(s_detachOption); + _isDetachMode = detach; var format = parseResult.GetValue(s_formatOption); var isolated = parseResult.GetValue(s_isolatedOption); var noBuild = parseResult.GetValue(s_noBuildOption); diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 0cdf41939b4..3d69f71073f 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -87,6 +87,48 @@ public async Task RunCommand_WhenProjectFileDoesNotExist_ReturnsNonZeroExitCode( Assert.Equal(ExitCodeConstants.FailedToFindProject, exitCode); } + [Fact] + public async Task RunCommand_WithDetachFlag_DoesNotShowUpdateNotification() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var testNotifier = new TestCliUpdateNotifier(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ProjectLocatorFactory = _ => new NoProjectFileProjectLocator(); + options.CliUpdateNotifierFactory = _ => testNotifier; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("run --detach"); + + await result.InvokeAsync().DefaultTimeout(); + + Assert.False(testNotifier.NotifyWasCalled, "Update notification should not be shown when --detach is used"); + } + + [Fact] + public async Task RunCommand_WithoutDetachFlag_ShowsUpdateNotification() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var testNotifier = new TestCliUpdateNotifier(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ProjectLocatorFactory = _ => new NoProjectFileProjectLocator(); + options.CliUpdateNotifierFactory = _ => testNotifier; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("run"); + + await result.InvokeAsync().DefaultTimeout(); + + Assert.True(testNotifier.NotifyWasCalled, "Update notification should be shown when --detach is not used"); + } + [Fact] public void GetDetachedFailureMessage_ReturnsBuildSpecificMessage_ForBuildFailureExitCode() { @@ -1452,4 +1494,5 @@ public bool IsFeatureEnabled(string featureName, bool defaultValue = false) return _features.TryGetValue(featureName, out var value) ? value : defaultValue; } } + } diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 19114dcf36e..165e9544d47 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -10,7 +10,6 @@ using Aspire.Cli.Resources; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; -using Aspire.Cli.Utils; using Microsoft.Extensions.DependencyInjection; using Spectre.Console; using Microsoft.AspNetCore.InternalTesting; @@ -981,27 +980,6 @@ public void WriteConsoleLog(string message, int? lineNumber = null, string? type => _innerService.WriteConsoleLog(message, lineNumber, type, isErrorMessage); } -// Test implementation of ICliUpdateNotifier -internal sealed class TestCliUpdateNotifier : ICliUpdateNotifier -{ - public Func? IsUpdateAvailableCallback { get; set; } - - public Task CheckForCliUpdatesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - public void NotifyIfUpdateAvailable() - { - // No-op for tests - } - - public bool IsUpdateAvailable() - { - return IsUpdateAvailableCallback?.Invoke() ?? false; - } -} - // Test implementation of IProjectUpdater internal sealed class TestProjectUpdater : IProjectUpdater { diff --git a/tests/Aspire.Cli.Tests/TestServices/TestCliUpdateNotifier.cs b/tests/Aspire.Cli.Tests/TestServices/TestCliUpdateNotifier.cs new file mode 100644 index 00000000000..bdac02e13c1 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/TestCliUpdateNotifier.cs @@ -0,0 +1,28 @@ +// 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.Utils; + +namespace Aspire.Cli.Tests.TestServices; + +internal sealed class TestCliUpdateNotifier : ICliUpdateNotifier +{ + public bool NotifyWasCalled { get; private set; } + + public Func? IsUpdateAvailableCallback { get; set; } + + public Task CheckForCliUpdatesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public void NotifyIfUpdateAvailable() + { + NotifyWasCalled = true; + } + + public bool IsUpdateAvailable() + { + return IsUpdateAvailableCallback?.Invoke() ?? false; + } +} From 7f9f56f2ac319021b86c77db372bc38444fdd27b Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Fri, 20 Feb 2026 16:09:49 +1100 Subject: [PATCH 126/256] Add ACA deployment E2E tests for custom and existing ACR (#14510) * Add ACA deployment E2E tests for custom and existing ACR Add two new deployment E2E tests: - AcaCustomRegistryDeploymentTests: Deploys a starter app to ACA using AddAzureContainerRegistry + WithAzureContainerRegistry to create and attach a custom registry instead of relying on the default ACR. - AcaExistingRegistryDeploymentTests: Pre-creates an ACR via az CLI, then deploys a starter app to ACA referencing the existing ACR via AsExisting() with parameters. Both tests follow the established AcaStarterDeploymentTests pattern with Hex1b terminal automation, endpoint verification, and cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix deployment test failures - Custom Registry: Increase deploy wait to 35 min, detect PIPELINE FAILED to fail fast instead of timing out, bump test timeout to 45 min - Existing Registry: Use null for resourceGroup param in AsExisting() since ACR is in the same resource group as the deployment, avoiding cross-RG Bicep scope that caused compilation errors. Add same pipeline failure detection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Verify container images exist in ACR after deployment Replace simple ACR existence checks with comprehensive image verification: - Discover the ACR name in the resource group - List all repositories in the ACR - Verify each repository has at least one tagged image - Fail the test if no images are found This ensures the deployment actually pushed container images to the custom/existing registry, not just that the ACR resource exists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix misleading comment: recordings are uploaded for all tests The comment said 'only for failed tests' but the code uploads recordings for all tests regardless of pass/fail status. 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 | 2 +- .../AcaCustomRegistryDeploymentTests.cs | 352 ++++++++++++++++ .../AcaExistingRegistryDeploymentTests.cs | 378 ++++++++++++++++++ 3 files changed, 731 insertions(+), 1 deletion(-) create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs diff --git a/.github/workflows/deployment-tests.yml b/.github/workflows/deployment-tests.yml index ade7167cf17..5643a0d8a05 100644 --- a/.github/workflows/deployment-tests.yml +++ b/.github/workflows/deployment-tests.yml @@ -534,7 +534,7 @@ jobs: ${CANCELLED_LIST}" fi - # Check for recordings and upload them (only for failed tests) + # Check for recordings and upload them RECORDINGS_DIR="cast_files" if [ -d "$RECORDINGS_DIR" ] && compgen -G "$RECORDINGS_DIR"/*.cast > /dev/null; then diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs new file mode 100644 index 00000000000..9f89389e4a2 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs @@ -0,0 +1,352 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Aspire applications to Azure Container Apps +/// with a custom Azure Container Registry created via AddAzureContainerRegistry. +/// +public sealed class AcaCustomRegistryDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 45 minutes to allow for Azure provisioning. + // Full deployments with custom ACR can take up to 35 minutes if Azure infrastructure is backed up. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(45); + + [Fact] + public async Task DeployStarterTemplateWithCustomRegistry() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployStarterTemplateWithCustomRegistryCore(cancellationToken); + } + + private async Task DeployStarterTemplateWithCustomRegistryCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithCustomRegistry)); + var startTime = DateTime.UtcNow; + var deploymentUrls = new Dictionary(); + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("aca-custom-acr"); + var projectName = "AcaCustomAcr"; + + output.WriteLine($"Test: {nameof(DeployStarterTemplateWithCustomRegistry)}"); + output.WriteLine($"Project Name: {projectName}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + // Pattern searchers for aspire new interactive prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find("Enter the output path:"); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find("Do you want to create a test project?"); + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searchers for deployment completion + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var waitingForPipelineFailed = new CellPatternSearcher() + .Find("PIPELINE FAILED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create starter project using aspire new with interactive prompts + output.WriteLine("Step 3: Creating starter project..."); + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first template (Starter App ASP.NET Core/Blazor) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Type(projectName) + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Accept default output path + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for localhost URLs (default) + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + // For Redis prompt, default is "Yes" so we need to select "No" by pressing Down + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .Enter() // Select "No" for Redis Cache + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for test project (default) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 4: Navigate to project directory + output.WriteLine("Step 4: Navigating to project directory..."); + sequenceBuilder + .Type($"cd {projectName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5: Add Aspire.Hosting.Azure.AppContainers package + output.WriteLine("Step 5: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 6: Add Aspire.Hosting.Azure.ContainerRegistry package + output.WriteLine("Step 6: Adding Azure Container Registry hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerRegistry") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 7: Modify AppHost.cs to add custom ACR and ACA environment + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost"); + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + + output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add custom Azure Container Registry and Container App Environment +var acr = builder.AddAzureContainerRegistry("myacr"); +builder.AddAzureContainerAppEnvironment("infra").WithAzureContainerRegistry(acr); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}"); + }); + + // Step 8: Navigate to AppHost project directory + output.WriteLine("Step 8: Navigating to AppHost directory..."); + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 9: Set environment variables for deployment + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 10: Deploy to Azure Container Apps using aspire deploy + output.WriteLine("Step 10: Starting Azure Container Apps deployment..."); + var pipelineSucceeded = false; + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => + { + if (waitingForPipelineSucceeded.Search(s).Count > 0) + { + pipelineSucceeded = true; + return true; + } + return waitingForPipelineFailed.Search(s).Count > 0; + }, TimeSpan.FromMinutes(35)) + .ExecuteCallback(() => + { + if (!pipelineSucceeded) + { + throw new InvalidOperationException("Deployment pipeline failed. Check the terminal output for details."); + } + }) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 11: Extract deployment URLs and verify endpoints with retry + output.WriteLine("Step 11: Verifying deployed endpoints..."); + sequenceBuilder + .Type($"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 && " + + // Get external endpoints only (exclude .internal. which are not publicly accessible) + "urls=$(az containerapp list -g \"$RG_NAME\" --query \"[].properties.configuration.ingress.fqdn\" -o tsv 2>/dev/null | grep -v '\\.internal\\.') && " + + "if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " + + "failed=0 && " + + "for url in $urls; do " + + "echo \"Checking https://$url...\"; " + + "success=0; " + + "for i in $(seq 1 18); do " + + "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 10 2>/dev/null); " + + "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " + + "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " + + "done; " + + "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " + + "done && " + + "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 12: Verify custom ACR contains container images + output.WriteLine("Step 12: Verifying container images in custom ACR..."); + sequenceBuilder + .Type($"ACR_NAME=$(az acr list -g \"{resourceGroupName}\" --query \"[0].name\" -o tsv) && " + + "echo \"ACR: $ACR_NAME\" && " + + "if [ -z \"$ACR_NAME\" ]; then echo \"❌ No ACR found in resource group\"; exit 1; fi && " + + "REPOS=$(az acr repository list --name \"$ACR_NAME\" -o tsv) && " + + "echo \"Repositories: $REPOS\" && " + + "if [ -z \"$REPOS\" ]; then echo \"❌ No container images found in ACR\"; exit 1; fi && " + + "for repo in $REPOS; do " + + "TAGS=$(az acr repository show-tags --name \"$ACR_NAME\" --repository \"$repo\" -o tsv); " + + "echo \" $repo: $TAGS\"; " + + "if [ -z \"$TAGS\" ]; then echo \" ❌ No tags for $repo\"; exit 1; fi; " + + "done && " + + "echo \"✅ All container images verified in ACR\"") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 13: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployStarterTemplateWithCustomRegistry), + resourceGroupName, + deploymentUrls, + duration); + + output.WriteLine("✅ Test passed!"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployStarterTemplateWithCustomRegistry), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName, output); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + } + } + + /// + /// Triggers cleanup of a specific resource group. + /// This is fire-and-forget - the hourly cleanup workflow handles any missed resources. + /// + private static void TriggerCleanupResourceGroup(string resourceGroupName, ITestOutputHelper output) + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs new file mode 100644 index 00000000000..b93678ac62b --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs @@ -0,0 +1,378 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Aspire applications to Azure Container Apps +/// using a pre-existing Azure Container Registry referenced via AsExisting. +/// +public sealed class AcaExistingRegistryDeploymentTests(ITestOutputHelper output) +{ + // Timeout set to 45 minutes to allow for ACR pre-creation and Azure provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(45); + + [Fact] + public async Task DeployStarterTemplateWithExistingRegistry() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployStarterTemplateWithExistingRegistryCore(cancellationToken); + } + + private async Task DeployStarterTemplateWithExistingRegistryCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithExistingRegistry)); + var startTime = DateTime.UtcNow; + var deploymentUrls = new Dictionary(); + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("aca-existing-acr"); + var projectName = "AcaExistingAcr"; + + // ACR names must be alphanumeric only, 5-50 chars, globally unique + var runId = DeploymentE2ETestHelpers.GetRunId(); + var attempt = DeploymentE2ETestHelpers.GetRunAttempt(); + var acrName = $"e2eexistingacr{runId}{attempt}"; + // Truncate to 50 chars max (ACR name limit) + if (acrName.Length > 50) + { + acrName = acrName[..50]; + } + + output.WriteLine($"Test: {nameof(DeployStarterTemplateWithExistingRegistry)}"); + output.WriteLine($"Project Name: {projectName}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Pre-created ACR Name: {acrName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + // Pattern searchers for aspire new interactive prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find("Enter the output path:"); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find("Do you want to create a test project?"); + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + // Pattern searchers for deployment completion + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var waitingForPipelineFailed = new CellPatternSearcher() + .Find("PIPELINE FAILED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Pre-create resource group and ACR via az CLI + output.WriteLine("Step 3: Pre-creating resource group and ACR..."); + sequenceBuilder + .Type($"az group create --name {resourceGroupName} --location westus3 -o none") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + sequenceBuilder + .Type($"az acr create --name {acrName} --resource-group {resourceGroupName} --sku Basic --admin-enabled false -o none") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + + output.WriteLine($"Pre-created ACR: {acrName} in resource group: {resourceGroupName}"); + + // Step 4: Create starter project using aspire new with interactive prompts + output.WriteLine("Step 4: Creating starter project..."); + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // Select first template (Starter App ASP.NET Core/Blazor) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Type(projectName) + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Accept default output path + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for localhost URLs (default) + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + // For Redis prompt, default is "Yes" so we need to select "No" by pressing Down + .Key(Hex1b.Input.Hex1bKey.DownArrow) + .Enter() // Select "No" for Redis Cache + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for test project (default) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 5: Navigate to project directory + output.WriteLine("Step 5: Navigating to project directory..."); + sequenceBuilder + .Type($"cd {projectName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 6: Add Aspire.Hosting.Azure.AppContainers package + output.WriteLine("Step 6: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 7: Add Aspire.Hosting.Azure.ContainerRegistry package + output.WriteLine("Step 7: Adding Azure Container Registry hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.ContainerRegistry") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 8: Modify AppHost.cs to reference the existing ACR + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost"); + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + + output.WriteLine($"Looking for AppHost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Reference existing Azure Container Registry via parameter +var acrName = builder.AddParameter("acrName"); +var acr = builder.AddAzureContainerRegistry("existingacr").AsExisting(acrName, null); +builder.AddAzureContainerAppEnvironment("infra").WithAzureContainerRegistry(acr); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified AppHost.cs at: {appHostFilePath}"); + }); + + // Step 9: Navigate to AppHost project directory + output.WriteLine("Step 9: Navigating to AppHost directory..."); + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 10: Set environment variables for deployment including ACR parameter + sequenceBuilder.Type( + $"unset ASPIRE_PLAYGROUND && " + + $"export AZURE__LOCATION=westus3 && " + + $"export AZURE__RESOURCEGROUP={resourceGroupName} && " + + $"export Parameters__acrName={acrName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 11: Deploy to Azure Container Apps using aspire deploy + output.WriteLine("Step 11: Starting Azure Container Apps deployment..."); + var pipelineSucceeded = false; + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + .WaitUntil(s => + { + if (waitingForPipelineSucceeded.Search(s).Count > 0) + { + pipelineSucceeded = true; + return true; + } + return waitingForPipelineFailed.Search(s).Count > 0; + }, TimeSpan.FromMinutes(35)) + .ExecuteCallback(() => + { + if (!pipelineSucceeded) + { + throw new InvalidOperationException("Deployment pipeline failed. Check the terminal output for details."); + } + }) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 12: Extract deployment URLs and verify endpoints with retry + output.WriteLine("Step 12: Verifying deployed endpoints..."); + sequenceBuilder + .Type($"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 && " + + "urls=$(az containerapp list -g \"$RG_NAME\" --query \"[].properties.configuration.ingress.fqdn\" -o tsv 2>/dev/null | grep -v '\\.internal\\.') && " + + "if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " + + "failed=0 && " + + "for url in $urls; do " + + "echo \"Checking https://$url...\"; " + + "success=0; " + + "for i in $(seq 1 18); do " + + "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 10 2>/dev/null); " + + "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " + + "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " + + "done; " + + "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " + + "done && " + + "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 13: Verify the pre-existing ACR contains container images + output.WriteLine("Step 13: Verifying container images in pre-existing ACR..."); + sequenceBuilder + .Type($"echo \"ACR: {acrName}\" && " + + $"REPOS=$(az acr repository list --name \"{acrName}\" -o tsv) && " + + "echo \"Repositories: $REPOS\" && " + + "if [ -z \"$REPOS\" ]; then echo \"❌ No container images found in ACR\"; exit 1; fi && " + + "for repo in $REPOS; do " + + $"TAGS=$(az acr repository show-tags --name \"{acrName}\" --repository \"$repo\" -o tsv); " + + "echo \" $repo: $TAGS\"; " + + "if [ -z \"$TAGS\" ]; then echo \" ❌ No tags for $repo\"; exit 1; fi; " + + "done && " + + "echo \"✅ All container images verified in ACR\"") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); + + // Step 14: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployStarterTemplateWithExistingRegistry), + resourceGroupName, + deploymentUrls, + duration); + + output.WriteLine("✅ Test passed!"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployStarterTemplateWithExistingRegistry), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName, output); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + } + } + + /// + /// Triggers cleanup of a specific resource group. + /// This is fire-and-forget - the hourly cleanup workflow handles any missed resources. + /// + private static void TriggerCleanupResourceGroup(string resourceGroupName, ITestOutputHelper output) + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + } + } +} From 4534a1533dab737b6cdf86e248419cede5b296a5 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 20 Feb 2026 09:07:54 -0800 Subject: [PATCH 127/256] Fix NU1009 error for CPM projects during aspire update (#14585) * Fix NU1009 error for CPM projects during aspire update When a project uses Central Package Management (CPM) and has a PackageVersion entry for Aspire.Hosting.AppHost in Directory.Packages.props, running 'aspire update' would leave the PackageVersion orphaned after removing the PackageReference from the csproj. The new SDK format adds an implicit PackageReference with IsImplicitlyDefined=true, and NuGet rejects PackageVersion entries for implicitly-defined packages (NU1009). The fix removes the orphaned PackageVersion entry from Directory.Packages.props during the SDK migration in aspire update. Fixes #14550 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Handle NuGet.config prompts in CPM e2e test The aspire update flow prompts for NuGet.config directory and confirmation when the project doesn't have an existing NuGet.config. The test now handles both prompts by accepting the defaults. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Projects/ProjectUpdater.cs | 55 +++++++ .../CentralPackageManagementTests.cs | 147 ++++++++++++++++++ .../Projects/ProjectUpdaterTests.cs | 58 +++++++ 3 files changed, 260 insertions(+) create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs diff --git a/src/Aspire.Cli/Projects/ProjectUpdater.cs b/src/Aspire.Cli/Projects/ProjectUpdater.cs index 7ae71fd8cc7..835a2f429a1 100644 --- a/src/Aspire.Cli/Projects/ProjectUpdater.cs +++ b/src/Aspire.Cli/Projects/ProjectUpdater.cs @@ -447,6 +447,13 @@ internal static async Task UpdateSdkVersionInCsprojAppHostAsync(FileInfo project projectDocument.Save(projectFile.FullName); + // The new SDK format adds an implicit PackageReference for Aspire.Hosting.AppHost with + // IsImplicitlyDefined="true". NuGet's Central Package Management (CPM) rejects any + // PackageVersion entry that targets an implicitly-defined package (NU1009). If the user + // was previously managing this package through CPM, we must also remove the now-orphaned + // PackageVersion entry from Directory.Packages.props to prevent the NU1009 error. + RemovePackageVersionFromDirectoryPackagesProps(projectFile, "Aspire.Hosting.AppHost"); + await Task.CompletedTask; } @@ -509,6 +516,54 @@ private static bool IsEmptyOrWhitespace(XmlNode node) return true; } + private static void RemovePackageVersionFromDirectoryPackagesProps(FileInfo projectFile, string packageId) + { + var cpmInfo = DetectCentralPackageManagement(projectFile); + if (!cpmInfo.UsesCentralPackageManagement || cpmInfo.DirectoryPackagesPropsFile is null) + { + return; + } + + try + { + var propsDocument = new XmlDocument(); + propsDocument.PreserveWhitespace = true; + propsDocument.Load(cpmInfo.DirectoryPackagesPropsFile.FullName); + + var packageVersionNode = propsDocument.SelectSingleNode($"/Project/ItemGroup/PackageVersion[@Include='{packageId}']"); + if (packageVersionNode?.ParentNode is null) + { + return; + } + + var parentNode = packageVersionNode.ParentNode; + + RemoveNodeWithWhitespace(packageVersionNode); + + if (parentNode.Name == "ItemGroup" && IsEmptyOrWhitespace(parentNode)) + { + RemoveNodeWithWhitespace(parentNode); + } + + propsDocument.Save(cpmInfo.DirectoryPackagesPropsFile.FullName); + } + catch (Exception ex) + { + // The csproj has already been updated at this point, so we can't roll back. + // Inform the user what manual step is needed to complete the migration. + throw new ProjectUpdaterException( + string.Format( + CultureInfo.InvariantCulture, + "The project file was updated successfully, but the PackageVersion entry for '{0}' could not be " + + "removed from '{1}': {2}. Please manually remove the " + + "entry from this file to avoid NU1009 build errors.", + packageId, + cpmInfo.DirectoryPackagesPropsFile.FullName, + ex.Message), + ex); + } + } + private static async Task UpdateSdkVersionInSingleFileAppHostAsync(FileInfo projectFile, NuGetPackageCli package) { var fileContent = await File.ReadAllTextAsync(projectFile.FullName); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs new file mode 100644 index 00000000000..069baeae379 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs @@ -0,0 +1,147 @@ +// 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; +using Aspire.Cli.Tests.Utils; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end tests for Central Package Management (CPM) compatibility. +/// Validates that aspire update correctly handles CPM projects. +/// Each test class runs as a separate CI job for parallelization. +/// +public sealed class CentralPackageManagementTests(ITestOutputHelper output) +{ + [Fact] + public async Task AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesProps() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesProps)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // aspire update prompts + var waitingForPerformUpdates = new CellPatternSearcher() + .Find("Perform updates?"); + + var waitingForNuGetConfigDirectory = new CellPatternSearcher() + .Find("Which directory for NuGet.config file?"); + + var waitingForApplyNuGetConfig = new CellPatternSearcher() + .Find("Apply these changes to NuGet.config?"); + + var waitingForUpdateSuccessful = new CellPatternSearcher() + .Find("Update successful!"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Disable update notifications to prevent the CLI self-update prompt + // from appearing after "Update successful!" and blocking the test. + sequenceBuilder + .Type("aspire config set features.updateNotificationsEnabled false -g") + .Enter() + .WaitForSuccessPrompt(counter); + + // Set up an old-format AppHost project with CPM that has a PackageVersion + // for Aspire.Hosting.AppHost. This simulates a pre-migration project where + // the user adopted CPM before the SDK started adding the implicit reference. + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, "CpmTest"); + var appHostDir = Path.Combine(projectDir, "CpmTest.AppHost"); + var appHostCsprojPath = Path.Combine(appHostDir, "CpmTest.AppHost.csproj"); + var directoryPackagesPropsPath = Path.Combine(projectDir, "Directory.Packages.props"); + + sequenceBuilder + .ExecuteCallback(() => + { + Directory.CreateDirectory(appHostDir); + + File.WriteAllText(appHostCsprojPath, """ + + + + Exe + net9.0 + true + + + + + + """); + + File.WriteAllText(Path.Combine(appHostDir, "Program.cs"), """ + var builder = DistributedApplication.CreateBuilder(args); + builder.Build().Run(); + """); + + File.WriteAllText(directoryPackagesPropsPath, """ + + + true + + + + + + """); + }) + // Use --channel stable to skip the channel selection prompt that appears + // in CI when PR hive directories are present. + .Type($"aspire update --project \"{appHostCsprojPath}\" --channel stable") + .Enter() + .WaitUntil(s => waitingForPerformUpdates.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter() // confirm "Perform updates?" (default: Yes) + // The updater may prompt for a NuGet.config location and ask to apply changes + // when the project doesn't have an existing NuGet.config. Accept defaults for both. + .WaitUntil(s => waitingForNuGetConfigDirectory.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() // accept default directory + .WaitUntil(s => waitingForApplyNuGetConfig.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() // confirm "Apply these changes to NuGet.config?" (default: Yes) + .WaitUntil(s => waitingForUpdateSuccessful.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .WaitForSuccessPrompt(counter) + // Verify the PackageVersion for Aspire.Hosting.AppHost was removed + .VerifyFileDoesNotContain(directoryPackagesPropsPath, "Aspire.Hosting.AppHost") + // Verify dotnet restore succeeds (would fail with NU1009 without the fix) + .Type($"dotnet restore \"{appHostCsprojPath}\"") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(120)) + // Clean up: re-enable update notifications + .Type("aspire config delete features.updateNotificationsEnabled -g") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs index 0ccafc41816..9c8cf17ea31 100644 --- a/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/ProjectUpdaterTests.cs @@ -2546,6 +2546,64 @@ public async Task UpdateSdkVersionInCsprojAppHostAsync_DoesNotMatchSimilarSdkNam var updatedContent = await File.ReadAllTextAsync(projectFile); await Verify(updatedContent, extension: "xml"); } + [Fact] + public async Task UpdateSdkVersionInCsprojAppHostAsync_RemovesPackageVersionFromDirectoryPackagesPropsForCpm() + { + // Arrange - simulates a CPM project with an old-format AppHost that has + // Aspire.Hosting.AppHost in both csproj (as PackageReference) and + // Directory.Packages.props (as PackageVersion). After SDK migration, the + // PackageReference is removed from csproj but the orphaned PackageVersion + // must also be removed to avoid NU1009. + // See: https://github.com/dotnet/aspire/issues/14550 + using var workspace = TemporaryWorkspace.Create(outputHelper); + var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"); + var originalContent = """ + + + + Exe + net9.0 + + + + + + + """; + + await File.WriteAllTextAsync(projectFile, originalContent); + + // Create Directory.Packages.props with CPM enabled and PackageVersion for Aspire.Hosting.AppHost + var directoryPackagesPropsFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Directory.Packages.props"); + await File.WriteAllTextAsync(directoryPackagesPropsFile, """ + + + true + + + + + + + """); + + var package = new NuGetPackageCli { Id = "Aspire.AppHost.Sdk", Version = "13.0.2", Source = "nuget.org" }; + + // Act + await ProjectUpdater.UpdateSdkVersionInCsprojAppHostAsync(new FileInfo(projectFile), package).DefaultTimeout(); + + // Assert - PackageVersion for Aspire.Hosting.AppHost should be removed from Directory.Packages.props + var updatedPropsContent = await File.ReadAllTextAsync(directoryPackagesPropsFile); + Assert.DoesNotContain("Aspire.Hosting.AppHost", updatedPropsContent); + // Other PackageVersion entries should be preserved + Assert.Contains("Aspire.Hosting.Redis", updatedPropsContent); + + // Also verify the csproj was updated correctly + var updatedCsprojContent = await File.ReadAllTextAsync(projectFile); + Assert.DoesNotContain("Aspire.Hosting.AppHost", updatedCsprojContent); + Assert.Contains("Aspire.Hosting.Redis", updatedCsprojContent); + Assert.Contains("Aspire.AppHost.Sdk/13.0.2", updatedCsprojContent); + } } internal static class MSBuildJsonDocumentExtensions From 83502ca245257f93e1eb6242ec4c4a436785b83c Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 20 Feb 2026 10:34:02 -0800 Subject: [PATCH 128/256] Update external NuGet dependencies to latest versions (#14549) * Update external NuGet dependencies and enhance dependency update skill Update 38 external dependencies to their latest versions: - Confluent.Kafka 2.12.0 -> 2.13.0 - Google.Protobuf 3.33.0 -> 3.33.5 - Grpc.AspNetCore/ClientFactory 2.71.0 -> 2.76.0 - Grpc.Tools 2.72.0 -> 2.78.0 - Hex1b/McpServer/Tool 0.78.0 -> 0.90.0 - Humanizer.Core 2.14.1 -> 3.0.1 - JsonPatch.Net 3.3.0 -> 5.0.0 - KubernetesClient 18.0.5 -> 18.0.13 - Markdig 0.43.0 -> 0.45.0 - Microsoft.Data.SqlClient 6.1.2 -> 6.1.4 - Microsoft.DevTunnels.Connections 1.3.6 -> 1.3.12 - Microsoft.FluentUI.AspNetCore.Components 4.13.2 -> 4.14.0 - ModelContextProtocol 0.4.0-preview.3 -> 0.8.0-preview.1 - MongoDB.Driver 3.5.0 -> 3.6.0 - MongoDB.Driver.Core.Extensions.DiagnosticSources 2.1.0 -> 3.0.0 - MySqlConnector.DependencyInjection 2.4.0 -> 2.5.0 - NATS.Net 2.6.11 -> 2.7.2 - Npgsql.DependencyInjection/OpenTelemetry 10.0.0 -> 10.0.1 - OpenAI 2.7.0 -> 2.8.0 - OpenTelemetry.Exporter.Console/InMemory 1.14.0 -> 1.15.0 - OpenTelemetry.Instrumentation.GrpcNetClient 1.14.0-beta.1 -> 1.15.0-beta.1 - Oracle.ManagedDataAccess.OpenTelemetry 23.26.0 -> 23.26.100 - Polly.Core/Extensions 8.6.4 -> 8.6.5 - Pomelo.EntityFrameworkCore.MySql 8.0.3 -> 9.0.0 - Qdrant.Client 1.15.1 -> 1.16.1 - RabbitMQ.Client 7.1.2 -> 7.2.0 - Spectre.Console 0.52.1-preview.0.5 -> 0.54.1-alpha.0.37 - StackExchange.Redis 2.9.32 -> 2.11.0 - StreamJsonRpc 2.22.23 -> 2.24.84 - System.IO.Hashing 9.0.10 -> 10.0.3 Move Microsoft.Extensions.Caching.Memory to the common section (no longer TFM-conditional) to support Pomelo 9.0.0's transitive dependency on it. Also enhance the dependency-update skill with: - Bulk update workflow (update-then-verify-then-mirror) - Transitive pinning conflict guidance (NU1109) - Broken transitive dep metadata guidance (NU1603) - Known special-handling packages - Azure CLI fallback for Windows (az.cmd) - Hex1b.Tool added to package family list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix build errors from dependency updates - MCP SDK 0.8.0: Convert IDictionary to Dictionary for IReadOnlyDictionary compatibility in AgentMcpCommand.cs, suppress MCPEXP001 experimental warning - Humanizer 3.0: Fully qualify Resources references in ResourceDetails.razor to avoid ambiguity with Humanizer.Resources namespace - MySqlConnector 2.5.0: Migrate from obsolete MySqlConnectorLogManager.Provider to DbContextOptionsBuilder.UseLoggerFactory(), remove unused MySqlConnector.Logging.Microsoft.Extensions.Logging package reference - RabbitMQ.Client 7.2.0: Suppress SYSLIB0026/0027/0028 warnings from source-generated configuration binder code touching obsolete X509Certificate2 APIs, regenerate ConfigurationSchema.json - FluentUI 4.14.0: Implement new IMessageService.ShowMessageBar/Async MarkupString overloads in test mock - Revert OpenAI to 2.7.0 (needs coordinated update with Microsoft.Extensions.AI.OpenAI) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix test failures from dependency updates - Revert JsonPatch.Net to 3.3.0: Updating to 5.0.0 caused TypeLoadException due to incompatible JsonPointer.Net value type change vs pinned JsonSchema.Net 7.4.0. Both packages need to be updated together in a separate PR. - Update Microsoft.Azure.StackExchangeRedis 3.2.1 -> 3.3.1 and Microsoft.Extensions.Azure 1.13.0 -> 1.13.1: Required for StackExchange.Redis 2.11.0 compatibility. The AMR refactor split Azure auth into AzureManagedRedisOptionsProvider; updated tests to check for the new type instead of IAzureCacheTokenEvents. - Revert Pomelo MySqlConnector logging to MySqlConnectorLogManager.Provider with #pragma CS0618 suppression: UseLoggerFactory only configures EF Core logging, not MySqlConnector's internal categories (ConnectionPool, etc.). There is no non-obsolete alternative when using Pomelo without MySqlDataSource. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Exclude incompatible analyzers from transitive flow to consuming projects Humanizer.Core 3.x and StreamJsonRpc 2.24.x ship Roslyn analyzers that are incompatible with older SDK versions (net8): - Humanizer.Analyzers requires System.Collections.Immutable 9.0.0 (CS8032) - StreamJsonRpc.Analyzers targets Roslyn 4.14.0 (CS9057) These analyzers flow transitively to template projects, causing build failures when template tests verify backward compatibility with older SDKs. Add PrivateAssets="analyzers" to prevent these analyzers from flowing to consuming projects while keeping them available for our own builds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert Humanizer.Core and StreamJsonRpc to previous versions Humanizer.Core 3.0.1 and StreamJsonRpc 2.24.84 ship Roslyn analyzers that are incompatible with older SDKs (net8) used by template tests: - Humanizer.Analyzers requires System.Collections.Immutable 9.0.0 (CS8032) - StreamJsonRpc.Analyzers targets Roslyn 4.14.0 (CS9057) PrivateAssets="analyzers" does not prevent these from flowing to template projects since the analyzers are resolved from the NuGet package cache at build time. Reverting to compatible versions: - Humanizer.Core: 3.0.1 -> 2.14.1 - StreamJsonRpc: 2.24.84 -> 2.22.23 Also reverts the Humanizer namespace ambiguity fix in ResourceDetails.razor since 2.14.1 does not introduce the Humanizer.Resources collision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert Spectre.Console to 0.52.1-preview.0.5 Spectre.Console 0.54.1-alpha.0.37 changed hyperlink rendering behavior: the [link=url] markup no longer emits ANSI OSC 8 hyperlink escape sequences in the same way, breaking the ConsoleActivityLoggerTests that verify clickable link rendering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix up RabbitMQ * Fix Azure Redis break from https://github.com/StackExchange/StackExchange.Redis/pull/2986 The Azure Managed Redis endpoints were split out into another class, so we need to check both classes. * Alphabetize Caching.Memory and update remaining OTel packages to 1.15.0 - Move Microsoft.Extensions.Caching.Memory to alphabetical position in the common section of Directory.Packages.props - Update OpenTelemetry version properties in eng/Versions.props: - Instrumentation.AspNetCore: 1.14.0 -> 1.15.0 - Instrumentation.Http: 1.14.0 -> 1.15.0 - Extensions.Hosting: 1.14.0 -> 1.15.0 - Instrumentation.Runtime: 1.14.0 -> 1.15.0 - Exporter.OpenTelemetryProtocol: 1.14.0 -> 1.15.0 - Azure.Monitor.OpenTelemetry.Exporter: 1.5.0 -> 1.6.0 - Regenerate Aspire.Seq ConfigurationSchema.json for OTel changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert Azure.Monitor.OpenTelemetry.Exporter update due to AOT warning * Update dependency skill to document OTel packages in eng/Versions.props - Document that OTel packages are split across Directory.Packages.props (hardcoded versions) and eng/Versions.props (MSBuild properties), and both must be updated together to keep OTel in sync - Add guidance to check eng/Versions.props when identifying packages - Add Humanizer.Core, StreamJsonRpc, and Azure.Monitor.OpenTelemetry.Exporter to the special handling section with upstream issue links Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Eric Erhardt --- .github/skills/dependency-update/SKILL.md | 103 +++++++++++++++++- Directory.Packages.props | 76 +++++++------ eng/Versions.props | 10 +- src/Aspire.Cli/Aspire.Cli.csproj | 7 +- src/Aspire.Cli/Commands/AgentMcpCommand.cs | 8 +- ...rosoftAzureStackExchangeRedisExtensions.cs | 3 +- .../AspireEFMySqlExtensions.cs | 7 +- .../Aspire.RabbitMQ.Client.csproj | 8 +- .../Aspire.RabbitMQ.Client/AssemblyInfo.cs | 3 +- .../Aspire.Seq/ConfigurationSchema.json | 8 ++ .../Shared/TestMessageService.cs | 11 ++ ...tAzureStackExchangeRedisExtensionsTests.cs | 1 + .../EnrichMySqlTests.cs | 2 + 13 files changed, 193 insertions(+), 54 deletions(-) diff --git a/.github/skills/dependency-update/SKILL.md b/.github/skills/dependency-update/SKILL.md index 0f99b55f5a0..aa883249335 100644 --- a/.github/skills/dependency-update/SKILL.md +++ b/.github/skills/dependency-update/SKILL.md @@ -63,6 +63,8 @@ For each package, extract: - Package ID (the `Include` attribute) - Current version (the `Version` attribute) +**Important:** Some external dependencies have their versions defined as MSBuild properties in `eng/Versions.props` rather than inline in `Directory.Packages.props`. In particular, the OpenTelemetry packages use version properties like `$(OpenTelemetryExporterOpenTelemetryProtocolVersion)`. When updating these packages, update the property values in `eng/Versions.props` directly. Check both files when looking for a package version. + ### 2. Look Up Latest Versions on nuget.org For each package, query the nuget.org API to find available versions: @@ -232,9 +234,11 @@ Provide a final summary: Some packages should be updated together. Common families in this repo: -- **Hex1b**: `Hex1b`, `Hex1b.McpServer` +- **Hex1b**: `Hex1b`, `Hex1b.McpServer`, `Hex1b.Tool` - **Azure.Provisioning**: `Azure.Provisioning`, `Azure.Provisioning.*` -- **OpenTelemetry**: `OpenTelemetry.*` (versions often defined as MSBuild properties in `eng/Versions.props`) +- **OpenTelemetry**: `OpenTelemetry.*` — versions are split across two locations: + - `Directory.Packages.props` (external deps section): `OpenTelemetry.Exporter.Console`, `OpenTelemetry.Exporter.InMemory`, `OpenTelemetry.Instrumentation.GrpcNetClient` — these have hardcoded versions and should be updated directly. + - `eng/Versions.props` (OTel section): `OpenTelemetry.Instrumentation.AspNetCore`, `OpenTelemetry.Instrumentation.Http`, `OpenTelemetry.Extensions.Hosting`, `OpenTelemetry.Instrumentation.Runtime`, `OpenTelemetry.Exporter.OpenTelemetryProtocol`, `Azure.Monitor.OpenTelemetry.Exporter` — these use MSBuild version properties and must be updated in `eng/Versions.props`. **All OTel packages should be kept in sync at the same version when possible.** - **AspNetCore.HealthChecks**: `AspNetCore.HealthChecks.*` - **Grpc**: `Grpc.AspNetCore`, `Grpc.Net.ClientFactory`, `Grpc.Tools` - **Polly**: `Polly.Core`, `Polly.Extensions` @@ -243,12 +247,105 @@ Some packages should be updated together. Common families in this repo: When updating one member of a family, check if other members also have updates available and suggest updating them together. +## Bulk Update Workflow + +When updating many packages at once (e.g., "update all external dependencies"), use this optimized workflow instead of updating one at a time: + +### 1. Update versions first, mirror later + +Update all package versions in `Directory.Packages.props` before triggering any mirror pipelines. This lets you verify the full set of changes compiles correctly before investing time in mirroring. + +### 2. Temporarily add nuget.org to verify restore + +To test whether the updated versions work together, temporarily add nuget.org as a package source: + +```xml + + + + + + + +``` + +Then run `build.cmd -restore` (Windows) or `./build.sh --restore` (Linux/macOS) to verify all packages resolve. **Remove nuget.org from NuGet.config before committing.** + +### 3. Watch for transitive pinning conflicts (NU1109) + +This repo uses Central Package Management with transitive pinning. When a package update pulls in a transitive dependency at a higher version than what's centrally pinned, NuGet will report **NU1109** (package downgrade detected). + +This is especially common with: +- **`Microsoft.Extensions.*`** packages (e.g., `Microsoft.Extensions.Caching.Memory`) +- **`System.*`** packages (e.g., `System.Text.Json`) +- Any package that depends on ASP.NET Core or EF Core framework packages + +**Important:** Do NOT blindly bump transitive pinned versions of `Microsoft.Extensions.*` or `System.*` packages. These affect the shared framework surface area and could force customers onto versions that break their applications. Always flag these for human review. + +**Example:** Updating `Pomelo.EntityFrameworkCore.MySql` from 8.x to 9.x pulls in `Microsoft.EntityFrameworkCore.Relational 9.0.0`, which transitively requires `Microsoft.Extensions.Caching.Memory >= 9.0.0`. But the net8.0 TFM pins it to `8.0.x`, causing NU1109. This requires careful analysis of whether the pinned version can be safely bumped. + +### 4. Watch for broken transitive dependency metadata (NU1603) + +Some packages have transitive dependencies that reference exact versions that don't exist on any feed. NuGet resolves a nearby version instead and emits **NU1603**. Since this repo treats warnings as errors, this becomes a build failure. + +When you encounter NU1603, it's usually a package authoring issue upstream. Report it to the user and suggest holding off on that particular update until the upstream package is fixed. + +### 5. Discover which packages need mirroring + +After verifying restore works with nuget.org, remove nuget.org from NuGet.config, clear the NuGet cache (`dotnet nuget locals all -c`), and run restore again. Any **NU1102** (unable to find package) errors indicate packages that need to be mirrored via the `dotnet-migrate-package` pipeline. + +This is more efficient than pre-mirroring all packages upfront, because many updated versions may already exist on the internal feeds (dotnet-public mirrors most of nuget.org). Only the missing ones need explicit pipeline runs. + +### 6. Trigger mirror pipelines for missing packages + +For each missing package, trigger the pipeline (see Step 6 above). Pipelines typically take **5–10 minutes** to complete but can sometimes take longer. You can trigger multiple pipelines in parallel if they're for different packages. + +After all pipelines complete, clear the NuGet cache again and re-run restore to confirm everything resolves. Repeat if additional transitive packages were also missing. + +## Packages Known to Require Special Handling + +Some external dependencies have known constraints: + +- **`Pomelo.EntityFrameworkCore.MySql`** — Major version bumps lift `Microsoft.EntityFrameworkCore` and its transitive `Microsoft.Extensions.*` dependencies, which conflict with net8.0 LTS pinning. Always verify compatibility across all target frameworks. +- **`Microsoft.AI.Foundry.Local`** — Has historically had broken transitive dependency metadata (NU1603). Check if the issue is resolved before updating. +- **`Spectre.Console`** — Currently on pre-release. Always update to the latest pre-release, not the latest stable. Verify hyperlink rendering behavior hasn't changed (test: `ConsoleActivityLoggerTests`). +- **`Milvus.Client`** — No stable release exists. Always stays on pre-release. +- **`Humanizer.Core`** — Version 3.x ships a Roslyn analyzer that requires `System.Collections.Immutable` 9.0.0, which is incompatible with the .NET 8 SDK. Cannot update until the upstream issue is fixed ([Humanizr/Humanizer#1672](https://github.com/Humanizr/Humanizer/issues/1672)). +- **`StreamJsonRpc`** — Version 2.24.x ships a Roslyn analyzer targeting Roslyn 4.14.0, incompatible with the .NET 8 SDK. Cannot update until the upstream issue is fixed ([microsoft/vs-streamjsonrpc#1399](https://github.com/microsoft/vs-streamjsonrpc/issues/1399)). +- **`Azure.Monitor.OpenTelemetry.Exporter`** — Version 1.6.0 introduced AOT warnings. Hold at 1.5.0 until resolved. Version is in `eng/Versions.props`. + ## Important Constraints - **One package per pipeline run** — The script processes one dependency at a time - **Wait for completion** — Don't start the next pipeline run until the current one finishes (the pipeline queue is aggressive) - **Always check nuget.org** — The mirroring pipeline pulls from nuget.org - **Verify versions exist** — Before triggering the pipeline, confirm the version exists on nuget.org -- **Don't modify NuGet.config** — Package sources are managed separately; this skill only handles version updates +- **Don't modify NuGet.config** — Package sources are managed separately; this skill only handles version updates. Temporary nuget.org additions for verification must be reverted before committing. - **Don't modify eng/Version.Details.xml** — That file is managed by Dependency Flow automation (Maestro/Darc) - **Ask before proceeding** — Always present the version summary and get user confirmation before triggering pipelines + +## Fallback: Triggering Pipelines via Azure CLI Directly + +If the `MigratePackage.cs` companion script fails (e.g., it cannot find the `az` executable — on Windows it may be `az.cmd` rather than `az`), you can trigger the pipeline directly using the Azure CLI: + +```powershell +# Ensure the azure-devops extension is installed +az extension add --name azure-devops + +# Trigger the pipeline +az pipelines run ` + --organization "https://dev.azure.com/dnceng" ` + --project "internal" ` + --id 931 ` + --parameters "PackageNames=" "PackageVersion=" "MigrationType=New or non-Microsoft" + +# Poll for completion +az pipelines runs show ` + --organization "https://dev.azure.com/dnceng" ` + --project "internal" ` + --id ` + --query "{status: status, result: result}" ` + --output json +``` + +On Windows, use `az.cmd` instead of `az` if `az` is not found. Check with `Get-Command az.cmd`. diff --git a/Directory.Packages.props b/Directory.Packages.props index 097be0ed6d4..4f04edc0fb1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,8 +33,8 @@ - - + + @@ -91,55 +91,55 @@ - + - - - - - - - + + + + + + + - + - + - - - - - - - - - + + + + + + + + + - - + + - - - - - - + + + + + + - - + + - + - - - + + + - + @@ -201,6 +201,7 @@ + @@ -228,7 +229,6 @@ - @@ -254,7 +254,6 @@ - @@ -277,7 +276,6 @@ - diff --git a/eng/Versions.props b/eng/Versions.props index 194b5e9f25e..33153ae8d66 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -53,11 +53,11 @@ 10.2.0 10.2.0 - 1.14.0 - 1.14.0 - 1.14.0 - 1.14.0 - 1.14.0 + 1.15.0 + 1.15.0 + 1.15.0 + 1.15.0 + 1.15.0 1.5.0 2.23.32-alpha diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 688c6e0525a..af551b28c50 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -10,8 +10,13 @@ false aspire Aspire.Cli - $(NoWarn);CS1591 + + $(NoWarn);CS1591;MCPEXP001 true + false Size $(DefineConstants);CLI true diff --git a/src/Aspire.Cli/Commands/AgentMcpCommand.cs b/src/Aspire.Cli/Commands/AgentMcpCommand.cs index 3aff0911c2a..beafae19f11 100644 --- a/src/Aspire.Cli/Commands/AgentMcpCommand.cs +++ b/src/Aspire.Cli/Commands/AgentMcpCommand.cs @@ -181,7 +181,9 @@ private async ValueTask HandleCallToolAsync(RequestContext(a) + : null; var context = new CallToolContext { Notifier = new McpServerNotifier(_server!), @@ -217,7 +219,9 @@ private async ValueTask HandleCallToolAsync(RequestContext(a) + : null; if (_logger.IsEnabled(LogLevel.Debug)) { diff --git a/src/Components/Aspire.Microsoft.Azure.StackExchangeRedis/AspireMicrosoftAzureStackExchangeRedisExtensions.cs b/src/Components/Aspire.Microsoft.Azure.StackExchangeRedis/AspireMicrosoftAzureStackExchangeRedisExtensions.cs index 6c0d39d09e4..244dc9b8b4e 100644 --- a/src/Components/Aspire.Microsoft.Azure.StackExchangeRedis/AspireMicrosoftAzureStackExchangeRedisExtensions.cs +++ b/src/Components/Aspire.Microsoft.Azure.StackExchangeRedis/AspireMicrosoftAzureStackExchangeRedisExtensions.cs @@ -36,7 +36,8 @@ public static AspireRedisClientBuilder WithAzureAuthentication(this AspireRedisC configurationOptions => { var azureOptionsProvider = new AzureOptionsProvider(); - if (configurationOptions.EndPoints.Any(azureOptionsProvider.IsMatch)) + var azureManagedOptionsProvider = new AzureManagedRedisOptionsProvider(); + if (configurationOptions.EndPoints.Any(ep => azureOptionsProvider.IsMatch(ep) || azureManagedOptionsProvider.IsMatch(ep))) { // only set up Azure AD authentication if the endpoint indicates it's an Azure Cache for Redis instance credential ??= new DefaultAzureCredential(); diff --git a/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/AspireEFMySqlExtensions.cs b/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/AspireEFMySqlExtensions.cs index 5faf4ffd5a5..46f9d07361f 100644 --- a/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/AspireEFMySqlExtensions.cs +++ b/src/Components/Aspire.Pomelo.EntityFrameworkCore.MySql/AspireEFMySqlExtensions.cs @@ -93,10 +93,15 @@ public static partial class AspireEFMySqlExtensions void ConfigureDbContext(IServiceProvider serviceProvider, DbContextOptionsBuilder dbContextOptionsBuilder) { - // use the legacy method of setting the ILoggerFactory because Pomelo EF Core doesn't use MySqlDataSource + // MySqlConnectorLogManager.Provider is the only way to wire MySqlConnector's internal logging + // categories (e.g. MySqlConnector.ConnectionPool) into ILoggerFactory when using Pomelo, + // because Pomelo doesn't use MySqlDataSource. The API is marked obsolete but there is no + // non-obsolete alternative for this scenario. if (serviceProvider.GetService() is { } loggerFactory) { +#pragma warning disable CS0618 // Type or member is obsolete MySqlConnectorLogManager.Provider = new MicrosoftExtensionsLoggingLoggerProvider(loggerFactory); +#pragma warning restore CS0618 } var connectionString = settings.ConnectionString ?? string.Empty; diff --git a/src/Components/Aspire.RabbitMQ.Client/Aspire.RabbitMQ.Client.csproj b/src/Components/Aspire.RabbitMQ.Client/Aspire.RabbitMQ.Client.csproj index fa4d3d8c8ae..91f3ab35696 100644 --- a/src/Components/Aspire.RabbitMQ.Client/Aspire.RabbitMQ.Client.csproj +++ b/src/Components/Aspire.RabbitMQ.Client/Aspire.RabbitMQ.Client.csproj @@ -5,7 +5,13 @@ true $(ComponentDatabasePackageTags) rabbitmq amqp messaging eventing A RabbitMQ client that integrates with Aspire, including health checks, logging, and telemetry. - $(NoWarn);SYSLIB1100;SYSLIB1101 + + $(NoWarn);SYSLIB1100;SYSLIB1101;SYSLIB0026;SYSLIB0027;SYSLIB0028 diff --git a/src/Components/Aspire.RabbitMQ.Client/AssemblyInfo.cs b/src/Components/Aspire.RabbitMQ.Client/AssemblyInfo.cs index c44b6dca992..baace33a230 100644 --- a/src/Components/Aspire.RabbitMQ.Client/AssemblyInfo.cs +++ b/src/Components/Aspire.RabbitMQ.Client/AssemblyInfo.cs @@ -6,6 +6,7 @@ using RabbitMQ.Client; [assembly: ConfigurationSchema("Aspire:RabbitMQ:Client", typeof(RabbitMQClientSettings))] -[assembly: ConfigurationSchema("Aspire:RabbitMQ:Client:ConnectionFactory", typeof(ConnectionFactory), exclusionPaths: ["ClientProperties"])] +[assembly: ConfigurationSchema("Aspire:RabbitMQ:Client:ConnectionFactory", typeof(ConnectionFactory), + exclusionPaths: ["ClientProperties", "Ssl:ClientCertificateContext", "Endpoint:Ssl:ClientCertificateContext"])] [assembly: LoggingCategories("RabbitMQ.Client")] diff --git a/src/Components/Aspire.Seq/ConfigurationSchema.json b/src/Components/Aspire.Seq/ConfigurationSchema.json index dd36f5d233e..23425a2154f 100644 --- a/src/Components/Aspire.Seq/ConfigurationSchema.json +++ b/src/Components/Aspire.Seq/ConfigurationSchema.json @@ -72,6 +72,10 @@ "TimeoutMilliseconds": { "type": "integer", "description": "Gets or sets the max waiting time (in milliseconds) for the backend to process each batch. Default value: 10000." + }, + "UserAgentProductIdentifier": { + "type": "string", + "description": "Gets or sets a custom user agent identifier. This will be prepended to the default user agent string." } }, "description": "Gets OTLP exporter options for logs." @@ -127,6 +131,10 @@ "TimeoutMilliseconds": { "type": "integer", "description": "Gets or sets the max waiting time (in milliseconds) for the backend to process each batch. Default value: 10000." + }, + "UserAgentProductIdentifier": { + "type": "string", + "description": "Gets or sets a custom user agent identifier. This will be prepended to the default user agent string." } }, "description": "Gets OTLP exporter options for traces." diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TestMessageService.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TestMessageService.cs index e3dc4e3e819..8e5d31cee13 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TestMessageService.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TestMessageService.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 Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components; namespace Aspire.Dashboard.Components.Tests.Shared; @@ -66,6 +67,11 @@ public Message ShowMessageBar(string title, MessageIntent intent, string section throw new NotImplementedException(); } + public Message ShowMessageBar(MarkupString title, MessageIntent intent, string section) + { + throw new NotImplementedException(); + } + public Task ShowMessageBarAsync(Action options) { var messageOptions = new MessageOptions(); @@ -88,4 +94,9 @@ public Task ShowMessageBarAsync(string title, MessageIntent intent, str { throw new NotImplementedException(); } + + public Task ShowMessageBarAsync(MarkupString title, MessageIntent intent, string section) + { + throw new NotImplementedException(); + } } diff --git a/tests/Aspire.Microsoft.Azure.StackExchangeRedis.Tests/AspireMicrosoftAzureStackExchangeRedisExtensionsTests.cs b/tests/Aspire.Microsoft.Azure.StackExchangeRedis.Tests/AspireMicrosoftAzureStackExchangeRedisExtensionsTests.cs index dad2369cd88..021fdc7e62f 100644 --- a/tests/Aspire.Microsoft.Azure.StackExchangeRedis.Tests/AspireMicrosoftAzureStackExchangeRedisExtensionsTests.cs +++ b/tests/Aspire.Microsoft.Azure.StackExchangeRedis.Tests/AspireMicrosoftAzureStackExchangeRedisExtensionsTests.cs @@ -74,5 +74,6 @@ public void WithAzureAuthenticationNoopsWithNonAzure() var defaults = configurationOptions.Defaults; Assert.IsNotType(defaults, exactMatch: false); Assert.IsNotType(defaults, exactMatch: false); + Assert.IsNotType(defaults, exactMatch: false); } } diff --git a/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/EnrichMySqlTests.cs b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/EnrichMySqlTests.cs index fffe546fb0b..5462209deda 100644 --- a/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/EnrichMySqlTests.cs +++ b/tests/Aspire.Pomelo.EntityFrameworkCore.MySql.Tests/EnrichMySqlTests.cs @@ -33,7 +33,9 @@ protected override void RegisterComponent(HostApplicationBuilder builder, Action // use the legacy method of setting the ILoggerFactory because Pomelo EF Core doesn't use MySqlDataSource if (serviceProvider.GetService() is { } loggerFactory) { +#pragma warning disable CS0618 // Type or member is obsolete MySqlConnectorLogManager.Provider = new MicrosoftExtensionsLoggingLoggerProvider(loggerFactory); +#pragma warning restore CS0618 } options.UseMySql(ConnectionString, DefaultVersion); From 523dde00a2e59fada5a51f1ada9c89dc17d6e4a7 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 20 Feb 2026 12:49:44 -0800 Subject: [PATCH 129/256] Fixing issues with 13.2 burndown agentic workflow (#14597) * Add gh CLI and jq to allowed bash tools for burndown workflow The agent was failing because it tried to use gh CLI and curl to query milestone data but those commands weren't in the bash allowlist. Add gh:* (all gh subcommands) and jq to give the agent reliable access to GitHub API queries for milestone, issue, and PR data. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Pass GH_TOKEN to sandbox so gh CLI can authenticate The agent sandbox strips sensitive tokens by default. The gh CLI inside the container had no authentication, causing all GitHub API queries (milestone data, issue lists, PR lists) to fail with 'Permission denied'. Pass github.token as GH_TOKEN env var so the gh CLI can authenticate for read-only queries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove gh CLI from bash, rely on GitHub MCP tool for auth The gh CLI inside the sandbox has no auth token, causing all GitHub queries to fail. The original working workflow used only the github MCP tool (which gets auth automatically from the gh-aw framework). Remove gh:* and jq from bash allowlist and remove the GH_TOKEN env var workaround. The agent will use the github MCP tool instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove explicit bash tool list to restore --allow-all-tools Declaring bash: with explicit commands causes the gh-aw compiler to switch from --allow-all-tools to explicit --allow-tool lists. With explicit lists, the Copilot agent fails to discover and use the GitHub MCP tool, falling back to curl/web_fetch which have no auth. Remove the bash: declaration entirely. The default bash commands (echo, ls, cat, head, tail, grep, wc, sort, uniq, date) are still available and the compiler generates --allow-all-tools, matching the working original workflow behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Mermaid chart rendering and lock section headers - Mermaid blocks must use exactly 3 backticks, not 4. The gh-aw system instruction tells agents to use 4 backticks for markdown, which breaks GitHub Mermaid rendering. Add explicit override. - Lock section headers with exact names and order so the report format stays consistent across daily runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/daily-repo-status.lock.yml | 19 ++---------------- .github/workflows/daily-repo-status.md | 21 ++++++++++---------- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/.github/workflows/daily-repo-status.lock.yml b/.github/workflows/daily-repo-status.lock.yml index f226e7a052b..c06cbd8cdec 100644 --- a/.github/workflows/daily-repo-status.lock.yml +++ b/.github/workflows/daily-repo-status.lock.yml @@ -26,7 +26,7 @@ # release/13.2 branch, pending PR reviews, and discussions. Generates # a 7-day burndown chart using cached daily snapshots. # -# frontmatter-hash: 427ab537ab52b999a8cbb139515b504ba7359549cab995530c129ea037f08ef0 +# frontmatter-hash: 8edfd1622aa2c963de303d96b9f06ea78bcf680ad05120e37cb6751c2fa7fc0a name: "13.2 Release Burndown Report" "on": @@ -653,26 +653,11 @@ jobs: - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): - # --allow-tool github - # --allow-tool safeoutputs - # --allow-tool shell(cat) - # --allow-tool shell(date) - # --allow-tool shell(echo) - # --allow-tool shell(grep) - # --allow-tool shell(head) - # --allow-tool shell(ls) - # --allow-tool shell(pwd) - # --allow-tool shell(sort) - # --allow-tool shell(tail) - # --allow-tool shell(uniq) - # --allow-tool shell(wc) - # --allow-tool shell(yq) - # --allow-tool write timeout-minutes: 20 run: | set -o pipefail sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.19.1 --skip-pull \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --add-dir /tmp/gh-aw/cache-memory/ --allow-all-paths --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --add-dir /tmp/gh-aw/cache-memory/ --allow-all-paths --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} diff --git a/.github/workflows/daily-repo-status.md b/.github/workflows/daily-repo-status.md index 6291aed99d5..554b6c6e772 100644 --- a/.github/workflows/daily-repo-status.md +++ b/.github/workflows/daily-repo-status.md @@ -22,7 +22,6 @@ tools: toolsets: [repos, issues, pull_requests, discussions, search] lockdown: false cache-memory: - bash: ["echo", "date", "cat", "wc"] safe-outputs: create-issue: @@ -104,16 +103,18 @@ If fewer than 2 data points are available, note that the chart will become riche ## Report structure -Create a GitHub issue with the following sections in this order: +Create a GitHub issue with the following sections. Use these **exact section headers** in this **exact order** every time — do not rename, reorder, or omit any section: -1. **📊 Burndown Chart** — The Mermaid chart (or a note that data is still being collected) -2. **📈 Milestone Progress** — Total open vs closed, percentage complete, net change today -3. **✅ Issues Closed Today** — Table or list of issues closed in the 13.2 milestone -4. **🐛 New Bugs Found** — Any new bug issues added to the 13.2 milestone -5. **🚀 Notable Changes Merged** — Summary of impactful PRs merged to release/13.2 -6. **👀 PRs Awaiting Review** — Open PRs targeting release/13.2 that need reviewer attention -7. **💬 Discussions** — Relevant 13.2 discussion activity -8. **📋 Triage Queue** — Brief list of un-milestoned issues that need attention (keep short) +1. `## 📊 Burndown Chart` +2. `## 📈 Milestone Progress` +3. `## ✅ Issues Closed Today` +4. `## 🐛 New Bugs Found` +5. `## 🚀 Notable Changes Merged` +6. `## 👀 PRs Awaiting Review` +7. `## 💬 Discussions` +8. `## 📋 Triage Queue` + +If there is no activity for a section, keep the header and note that there was no activity. ## Style From 5da383b09ed1de4aef01214e37f9f2b33066723c Mon Sep 17 00:00:00 2001 From: William Godbe Date: Fri, 20 Feb 2026 12:51:30 -0800 Subject: [PATCH 130/256] Fix Copilot not working in forks (#14596) --- .github/workflows/copilot-setup-steps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 2e1cefd11ad..445119892ad 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -6,7 +6,7 @@ on: workflow_dispatch jobs: # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. copilot-setup-steps: - runs-on: 8-core-ubuntu-latest + runs-on: ${{ github.repository_owner == 'dotnet' && '8-core-ubuntu-latest' || 'ubuntu-latest' }} permissions: contents: read From b1733e9a6b01e960c8fba9499d45da13878d2e94 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Fri, 20 Feb 2026 12:59:41 -0800 Subject: [PATCH 131/256] Update container image tags to latest versions (#14555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update container image tags to latest versions Update 17 Docker container image tags across 12 hosting integrations: - EventHubs Emulator: 2.1.0 → 2.2.0 - ServiceBus SQL Server: 2022-latest → 2025-latest - Kafka (Confluent): 8.1.0 → 8.1.1 - Kafka UI: v1.3.0 → v1.4.2 - Keycloak: 26.4 → 26.5 - Milvus: v2.5.17 → v2.5.27 - MySQL: 9.5 → 9.6 - Oracle: 23.26.0.0 → 23.26.1.0 - Postgres: 17.6 → 18.2 - pgAdmin: 9.9.0 → 9.12.0 - pgWeb: 0.16.2 → 0.17.0 - Qdrant: v1.15.5 → v1.16.3 - Redis: 8.2 → 8.6 - RedisInsight: 2.70 → 3.0 - SQL Server: 2022-latest → 2025-latest - YARP: 2.3.0-preview.4 → 2.3.0-preview.5 Also adds an agent skill (.github/skills/update-container-images/) with a companion C# script to automate future container image tag updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix test snapshots and Postgres test assumptions for updated image tags - Update 5 YARP snapshot files: 2.3.0-preview.4 → 2.3.0-preview.5 - Update aspire-manifest.json: 2022-latest → 2025-latest - Fix Postgres v17 data path tests to explicitly set v17 image tag since the default image is now v18 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix hardcoded YARP tag in Dockerfile assertion test Update Assert.Contains for YARP image tag: 2.3.0-preview.4 → 2.3.0-preview.5 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR feedback: revert SQL Server, Postgres, and use rolling YARP tag - Revert SQL Server back to 2022-latest (Mac ARM incompatibility) - Revert Postgres back to 17.6 (data checksums concern, will do separately) - Change YARP to rolling tag 2.3-preview instead of pinned 2.3.0-preview.5 - Update all affected test snapshots and assertions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Document stdout/stderr conventions in UpdateImageTags.cs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../update-container-images/.editorconfig | 12 + .../Directory.Packages.props | 8 + .../skills/update-container-images/SKILL.md | 244 +++++++++++ .../UpdateImageTags.cs | 395 ++++++++++++++++++ .../EventHubsEmulatorContainerImageTags.cs | 4 +- .../KafkaContainerImageTags.cs | 8 +- .../KeycloakContainerImageTags.cs | 4 +- .../MilvusContainerImageTags.cs | 4 +- .../MySqlContainerImageTags.cs | 4 +- .../OracleContainerImageTags.cs | 4 +- .../PostgresContainerImageTags.cs | 8 +- .../QdrantContainerImageTags.cs | 4 +- .../RedisContainerImageTags.cs | 8 +- .../YarpContainerImageTags.cs | 2 +- .../Aspire.Hosting.Yarp.Tests/AddYarpTests.cs | 2 +- ...lesGeneratesCorrectDockerfile.verified.txt | 4 +- ...ileWithMultipleContainerFiles.verified.txt | 4 +- ...hMultipleSourceContainerFiles.verified.txt | 4 +- ...sMissingSourceImageGracefully.verified.txt | 2 +- ...lesConfigurationDockerCompose.verified.env | 4 +- 20 files changed, 694 insertions(+), 35 deletions(-) create mode 100644 .github/skills/update-container-images/.editorconfig create mode 100644 .github/skills/update-container-images/Directory.Packages.props create mode 100644 .github/skills/update-container-images/SKILL.md create mode 100644 .github/skills/update-container-images/UpdateImageTags.cs diff --git a/.github/skills/update-container-images/.editorconfig b/.github/skills/update-container-images/.editorconfig new file mode 100644 index 00000000000..54135adebee --- /dev/null +++ b/.github/skills/update-container-images/.editorconfig @@ -0,0 +1,12 @@ +# Override repo-level analyzers for standalone tool scripts +root = true + +[*.cs] +# Disable file header requirement +dotnet_diagnostic.IDE0073.severity = none +# Disable unused using warning (script may need conditional usings) +dotnet_diagnostic.IDE0005.severity = suggestion +# Disable ConfigureAwait requirement (not needed in console apps) +dotnet_diagnostic.CA2007.severity = none +# Disable locale-sensitive parsing warning +dotnet_diagnostic.CA1305.severity = none diff --git a/.github/skills/update-container-images/Directory.Packages.props b/.github/skills/update-container-images/Directory.Packages.props new file mode 100644 index 00000000000..886fb9b1236 --- /dev/null +++ b/.github/skills/update-container-images/Directory.Packages.props @@ -0,0 +1,8 @@ + + + + false + + diff --git a/.github/skills/update-container-images/SKILL.md b/.github/skills/update-container-images/SKILL.md new file mode 100644 index 00000000000..2db6ef16330 --- /dev/null +++ b/.github/skills/update-container-images/SKILL.md @@ -0,0 +1,244 @@ +--- +name: update-container-images +description: Updates Docker container image tags used by Aspire hosting integrations. Queries registries for newer tags, uses LLM to determine version-compatible updates, and applies changes. Use this when asked to update container image versions. +--- + +You are a specialized container image update agent for the dotnet/aspire repository. Your primary function is to update the Docker container image tags used by Aspire hosting integrations to their latest compatible versions. + +## Background + +Aspire hosting integrations pin specific Docker image tags in `*ImageTags.cs` files (e.g., `SeqContainerImageTags.cs`, `RedisContainerImageTags.cs`). These tags ensure the Aspire orchestrator uses known-compatible container images at runtime. Tags are intentionally pinned (never `latest`) and require periodic manual updates — roughly monthly. + +### Image Tag File Structure + +Each `*ImageTags.cs` file follows this pattern: + +```csharp +internal static class RedisContainerImageTags +{ + /// docker.io + public const string Registry = "docker.io"; + + /// library/redis + public const string Image = "library/redis"; + + /// 8.6 + public const string Tag = "8.6"; +} +``` + +Some files contain multiple image definitions (primary + companion tools) using field name prefixes: + +```csharp +// Primary image: Registry, Image, Tag +// Companion: PgAdminRegistry, PgAdminImage, PgAdminTag +``` + +### Registries + +The repository uses 5 container registries: + +| Registry | Domain | Auth | +|----------|--------|------| +| Docker Hub | `docker.io` | Anonymous (Hub REST API) | +| Microsoft Container Registry | `mcr.microsoft.com` | Anonymous (OCI v2) | +| GitHub Container Registry | `ghcr.io` | Anonymous token | +| Oracle Container Registry | `container-registry.oracle.com` | Anonymous token | +| Quay.io (Red Hat) | `quay.io` | Anonymous (OCI v2) | + +### Companion Script + +A single-file C# script is bundled at `.github/skills/update-container-images/UpdateImageTags.cs`. It discovers all `*ImageTags.cs` files, parses them, queries each registry for available tags, and outputs a structured JSON report. **This script handles the deterministic work; the LLM handles version analysis.** + +## Understanding User Requests + +This skill is typically invoked with one of: + +- "Update container images" — full sweep of all images +- "Update Docker image tags" — same as above +- "Check for container image updates" — report only, don't apply + +## Task Execution Steps + +### Step 1: Run the Tag Fetcher Script + +Run the companion script from the repository root to generate a JSON report of all images and their available tags: + +```bash +cd +dotnet run .github/skills/update-container-images/UpdateImageTags.cs 2>update-tags-stderr.txt 1>update-tags-report.json +``` + +Check stderr for any failures: + +```bash +cat update-tags-stderr.txt +``` + +All registries should report a tag count. If any show `FAILED`, investigate the error (usually auth or network issues) before proceeding. + +### Step 2: Analyze the JSON Report + +Read the generated `update-tags-report.json`. The report structure is: + +```json +{ + "images": [ + { + "file": "src\\Aspire.Hosting.Redis\\RedisContainerImageTags.cs", + "entries": [ + { + "registry": "docker.io", + "image": "library/redis", + "currentTag": "8.6", + "availableTags": ["8.6", "8.4", "8.2", "9.0", ...] + } + ] + } + ] +} +``` + +Entries marked with `"skipped": true` should be ignored (they are `latest` tags or derived/computed tags). + +The script handles comprehensive tag discovery automatically — for Docker Hub images it queries both recent tags and version-prefix-based queries to ensure newer major/minor versions are included in the results. + +### Step 3: Determine Version Updates + +For each image, apply these version analysis rules: + +#### Rule 1: Match the Version Format (Precision) + +The new tag must use the **same version format** as the current tag: + +| Current Tag Format | Example | Match Pattern | Do NOT pick | +|-------------------|---------|---------------|-------------| +| `M.m` (2-part) | `8.2` | `8.6`, `9.0` | `8.6.1`, `v8.6` | +| `M.m.p` (3-part) | `9.9.0` | `9.12.0`, `10.0.0` | `9.12`, `v9.12.0` | +| `vM.m.p` (v-prefix 3-part) | `v1.15.5` | `v1.16.3`, `v2.0.0` | `1.16.3`, `v1.16` | +| `vM.m` (v-prefix 2-part) | `v2.5` | `v2.6`, `v3.0` | `v2.5.1`, `2.5` | +| `YYYY.N` (year.seq) | `2025.2` | `2025.3`, `2026.1` | `2025.2.15571` | +| `M.m.p.b` (4-part) | `23.26.0.0` | `23.26.1.0` | `23.26.1` | +| `YYYY-suffix` | `2022-latest` | `2025-latest` | `2022-CU23` | +| `M.m.p-pre.N` | `2.3.0-preview.4` | `2.3.0-preview.5` | `2.3.0`, `2.3-preview` | + +#### Rule 2: Cross Major Versions + +**Do cross major version boundaries.** If Postgres is at `17.8` and `18.2` exists as an `M.m` tag, update to `18.2`. The goal is to pick the **newest tag** that matches the same format. + +#### Rule 3: Filter Out Platform Suffixes + +Ignore tags with platform suffixes like `-alpine`, `-bookworm`, `-amd64`, `-arm64`, `-fpm`, `-management-alpine`, etc. Only consider "bare" tags matching the version format. + +Exception: Tags like `4.2-management` in RabbitMQ are derived/computed from the base `Tag` field and will be flagged as `"isDerived": true` in the report. Skip these — they auto-update when the base tag is updated. + +#### Rule 4: Respect Known Issues + +Check the source file for comments about known issues. For example, Milvus has: + +```csharp +// Note that when trying to update to v2.6.0 we hit https://github.com/dotnet/aspire/issues/11184 +``` + +If such a comment exists, stay within the noted version range (e.g., v2.5.x for Milvus) unless you can verify the issue is resolved. + +#### Rule 5: Skip Non-Updatable Tags + +- Tags set to `"latest"` — cannot be version-bumped +- Tags set to `"vnext-preview"` — not a version scheme +- Derived/computed tags (e.g., `$"{Tag}-management"`) — updated automatically + +### Step 4: Present Update Summary + +Before applying changes, present a summary table to the user: + +``` +| Image | Current | New | Notes | +|-------|---------|-----|-------| +| library/postgres | 17.8 | 18.2 | Major version bump | +| qdrant/qdrant | v1.15.5 | v1.16.3 | Minor + patch bump | +| library/redis | 8.6 | 8.6 | Already latest | +``` + +Wait for user confirmation before proceeding. If the user wants to skip specific updates, honor that. + +### Step 5: Apply Changes + +Edit each `*ImageTags.cs` file to update both the tag value and its `` XML comment: + +```csharp +// Before: +/// 17.6 +public const string Tag = "17.6"; + +// After: +/// 18.2 +public const string Tag = "18.2"; +``` + +**Always update both the `` and the string literal** — they must stay in sync. + +### Step 6: Validate Build + +Build all affected projects to ensure the changes compile: + +```bash +# Restore first if needed +./restore.cmd # Windows +./restore.sh # Linux/macOS + +# Build each affected project +dotnet build src/Aspire.Hosting.Redis/Aspire.Hosting.Redis.csproj --no-restore -v q /p:SkipNativeBuild=true +dotnet build src/Aspire.Hosting.PostgreSQL/Aspire.Hosting.PostgreSQL.csproj --no-restore -v q /p:SkipNativeBuild=true +# ... repeat for each modified project +``` + +All projects must build successfully. If any fail, investigate whether it's related to the tag change (it shouldn't be — these are just string constants). + +### Step 7: Summarize Results + +Present a final summary: + +``` +## Container Image Tag Updates + +Updated 15 tags across 12 files: + +| File | Field | Old Tag | New Tag | +|------|-------|---------|---------| +| PostgresContainerImageTags.cs | Tag | 17.6 | 18.2 | +| PostgresContainerImageTags.cs | PgAdminTag | 9.9.0 | 9.12.0 | +| ... | ... | ... | ... | + +Unchanged (already latest): 14 entries +Skipped (latest/derived): 6 entries +Build: ✅ All affected projects compile +``` + +## Important Constraints + +1. **Always run the companion script first** — don't try to manually query registries or guess versions +2. **Always confirm with the user** before applying changes +3. **Always update both `` and string literal** in sync +4. **Always build after applying** to verify changes compile +5. **Never update `latest` tags** — they are intentionally unpinned +6. **Never add more precision** to a tag (e.g., don't change `8.6` to `8.6.1`) +7. **Never remove precision** from a tag (e.g., don't change `v1.16.3` to `v1.16`) +8. **Check for comments** about known issues before updating an image +9. **Clean up temporary files** (`update-tags-report.json`, `update-tags-stderr.txt`) after completing + +## Troubleshooting + +### Registry Query Failures + +- **Oracle `401 Unauthorized`**: The script needs to acquire a token from `https://container-registry.oracle.com/auth`. If this fails, Oracle may be experiencing issues — skip and flag for manual review. +- **Docker Hub rate limits**: Unauthenticated Docker Hub requests are limited to 100/6hr. The ~15-20 queries should be well within limits. +- **GHCR token failures**: GHCR anonymous tokens occasionally fail. Retry once before flagging. + +### Version Confusion + +Some images use non-standard versioning: +- **Seq**: Uses `YYYY.N` format (e.g., `2025.2`), but also has build-number tags like `2025.2.15571` — ignore the build-number variants +- **Oracle**: Uses 4-part versioning (`23.26.1.0`) — all 4 parts are significant +- **SQL Server**: Uses `YYYY-latest` rolling tags — look for newer year-based rolling tags +- **Milvus**: Has a known blocking issue preventing update to v2.6.x — stay on v2.5.x diff --git a/.github/skills/update-container-images/UpdateImageTags.cs b/.github/skills/update-container-images/UpdateImageTags.cs new file mode 100644 index 00000000000..1fbb37b2a32 --- /dev/null +++ b/.github/skills/update-container-images/UpdateImageTags.cs @@ -0,0 +1,395 @@ +#!/usr/bin/env dotnet run +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// This script discovers all *ImageTags.cs files in the repository, parses them +// to extract (registry, image, tag) tuples, queries each registry for available +// tags, and outputs a JSON report for LLM-driven version analysis. +// +// Output conventions: +// stdout — structured JSON report (redirect with 1>report.json) +// stderr — progress messages and diagnostics (redirect with 2>progress.txt) + +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.RegularExpressions; + +var repoRoot = FindRepoRoot(Directory.GetCurrentDirectory()); +if (repoRoot is null) +{ + Console.Error.WriteLine("Error: Could not find repository root (no .git directory found)."); + return 1; +} + +Console.Error.WriteLine($"Repository root: {repoRoot}"); + +// Phase 1: Parse all *ImageTags.cs files +var imageTagFiles = Directory.GetFiles(Path.Combine(repoRoot, "src"), "*ImageTags.cs", SearchOption.AllDirectories); +Console.Error.WriteLine($"Found {imageTagFiles.Length} image tag files."); + +var allImageEntries = new List(); + +foreach (var file in imageTagFiles.OrderBy(f => f)) +{ + var relativePath = Path.GetRelativePath(repoRoot, file); + var content = File.ReadAllText(file); + var entries = ParseImageTagsFile(content, relativePath); + if (entries.Count > 0) + { + allImageEntries.Add(new ImageFileReport { File = relativePath, Entries = entries }); + } +} + +Console.Error.WriteLine($"Parsed {allImageEntries.Sum(f => f.Entries.Count)} image entries across {allImageEntries.Count} files."); + +// Phase 2: Query registries for available tags +using var httpClient = new HttpClient(); +httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("AspireImageTagUpdater", "1.0")); + +var uniqueImages = allImageEntries + .SelectMany(f => f.Entries) + .Where(e => e.Skipped != true) + .GroupBy(e => (e.Registry, e.Image)) + .Select(g => (g.Key.Registry, g.Key.Image, CurrentTags: g.Select(e => e.CurrentTag).Distinct().ToList())) + .ToList(); + +Console.Error.WriteLine($"Querying {uniqueImages.Count} unique images across registries..."); + +var tagCache = new Dictionary<(string Registry, string Image), List>(); + +foreach (var (registry, image, currentTags) in uniqueImages) +{ + Console.Error.Write($" {registry}/{image} ... "); + try + { + var tags = await FetchTags(httpClient, registry, image, currentTags.FirstOrDefault()); + tagCache[(registry, image)] = tags; + Console.Error.WriteLine($"{tags.Count} tags"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"FAILED: {ex.Message}"); + tagCache[(registry, image)] = []; + } +} + +// Phase 3: Build and output the report +foreach (var fileReport in allImageEntries) +{ + foreach (var entry in fileReport.Entries) + { + if (entry.Skipped != true && tagCache.TryGetValue((entry.Registry, entry.Image), out var tags)) + { + entry.AvailableTags = tags; + } + } +} + +// Write report as JSON using Utf8JsonWriter (AOT-compatible) +using var stream = Console.OpenStandardOutput(); +using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + +writer.WriteStartObject(); +writer.WriteStartArray("images"); + +foreach (var fileReport in allImageEntries) +{ + writer.WriteStartObject(); + writer.WriteString("file", fileReport.File); + writer.WriteStartArray("entries"); + + foreach (var entry in fileReport.Entries) + { + writer.WriteStartObject(); + if (entry.FieldPrefix is not null) + { + writer.WriteString("fieldPrefix", entry.FieldPrefix); + } + writer.WriteString("registry", entry.Registry); + writer.WriteString("image", entry.Image); + writer.WriteString("currentTag", entry.CurrentTag); + if (entry.IsDerived == true) + { + writer.WriteBoolean("isDerived", true); + } + if (entry.Skipped == true) + { + writer.WriteBoolean("skipped", true); + } + if (entry.SkipReason is not null) + { + writer.WriteString("skipReason", entry.SkipReason); + } + if (entry.AvailableTags is not null) + { + writer.WriteStartArray("availableTags"); + foreach (var t in entry.AvailableTags) + { + writer.WriteStringValue(t); + } + writer.WriteEndArray(); + } + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); +} + +writer.WriteEndArray(); +writer.WriteEndObject(); +writer.Flush(); + +Console.Error.WriteLine(); +Console.Error.WriteLine("Done."); +return 0; + +// ─── Helper Methods ────────────────────────────────────────────────────────── + +static string? FindRepoRoot(string startDir) +{ + var dir = startDir; + while (dir is not null) + { + // .git can be a directory (normal repo) or a file (worktree) + if (Directory.Exists(Path.Combine(dir, ".git")) || File.Exists(Path.Combine(dir, ".git"))) + { + return dir; + } + dir = Directory.GetParent(dir)?.FullName; + } + return null; +} + +static List ParseImageTagsFile(string content, string relativePath) +{ + // Extract all const/static string field assignments + var fieldPattern = new Regex( + @"public\s+(?:const|static)\s+string\s+(\w+)\s*(?:{\s*get;\s*}\s*)?=\s*(.+?);", + RegexOptions.Multiline); + + var fields = new Dictionary(); + var derivedFields = new HashSet(); + + foreach (Match match in fieldPattern.Matches(content)) + { + var fieldName = match.Groups[1].Value; + var rawValue = match.Groups[2].Value.Trim(); + + // Check if this is a derived/interpolated value + if (rawValue.StartsWith("$\"") || rawValue.Contains('{')) + { + derivedFields.Add(fieldName); + fields[fieldName] = rawValue; + } + else + { + // Extract the string literal value + var literalMatch = Regex.Match(rawValue, @"""([^""]+)"""); + if (literalMatch.Success) + { + fields[fieldName] = literalMatch.Groups[1].Value; + } + } + } + + // Group fields by prefix. The primary image has fields named "Registry", "Image", "Tag". + // Secondary images have prefixed names like "PgAdminRegistry", "PgAdminImage", "PgAdminTag". + var entries = new List(); + + // Find all Tag fields to determine prefixes + var tagFields = fields.Keys.Where(k => k.EndsWith("Tag")).ToList(); + + foreach (var tagField in tagFields) + { + string prefix; + if (tagField == "Tag") + { + prefix = ""; + } + else + { + prefix = tagField[..^3]; // Remove "Tag" suffix + } + + var registryField = prefix + "Registry"; + var imageField = prefix + "Image"; + + // For the primary image, Registry/Image are always named "Registry"/"Image" + // For secondary images, they might use prefixed names + var registry = fields.GetValueOrDefault(registryField) ?? fields.GetValueOrDefault("Registry"); + var image = fields.GetValueOrDefault(imageField); + var tag = fields.GetValueOrDefault(tagField); + + if (image is null) + { + continue; + } + + var isDerived = derivedFields.Contains(tagField); + var isLatestOrUnversioned = !isDerived && (tag == "latest" || tag == "vnext-preview"); + + var entry = new ImageEntry + { + FieldPrefix = prefix == "" ? null : prefix, + Registry = registry ?? "docker.io", + Image = image, + CurrentTag = isDerived ? fields[tagField] : tag ?? "", + IsDerived = isDerived ? true : null, + Skipped = (isDerived || isLatestOrUnversioned) ? true : null, + SkipReason = isDerived ? "Derived/computed tag" + : isLatestOrUnversioned ? $"Tag is '{tag}'" + : null + }; + + entries.Add(entry); + } + + return entries; +} + +static async Task> FetchTags(HttpClient httpClient, string registry, string image, string? currentTag) +{ + return registry switch + { + "docker.io" => await FetchDockerHubTags(httpClient, image, currentTag), + "mcr.microsoft.com" => await FetchOciTags(httpClient, $"https://mcr.microsoft.com/v2/{image}/tags/list", authUrl: null), + "ghcr.io" => await FetchOciTags(httpClient, $"https://ghcr.io/v2/{image}/tags/list", + authUrl: $"https://ghcr.io/token?service=ghcr.io&scope=repository:{image}:pull"), + "container-registry.oracle.com" => await FetchOciTags(httpClient, $"https://container-registry.oracle.com/v2/{image}/tags/list", + authUrl: $"https://container-registry.oracle.com/auth?service=Oracle%20Registry&scope=repository:{image}:pull"), + "quay.io" => await FetchOciTags(httpClient, $"https://quay.io/v2/{image}/tags/list", authUrl: null), + _ => throw new NotSupportedException($"Unsupported registry: {registry}") + }; +} + +static async Task> FetchDockerHubTags(HttpClient httpClient, string image, string? currentTag) +{ + var allTags = new HashSet(); + + // Fetch the most recent 50 tags by last_updated (gives a broad baseline) + var baseUrl = $"https://hub.docker.com/v2/repositories/{image}/tags?page_size=50&ordering=-last_updated"; + await FetchDockerHubPage(httpClient, baseUrl, allTags); + + // For version-like current tags, also query with prefix filters to ensure we find + // newer versions. Docker Hub's last_updated ordering often surfaces old patched releases + // instead of the highest version numbers. + if (currentTag is not null) + { + foreach (var prefix in GetVersionPrefixes(currentTag)) + { + var prefixUrl = $"https://hub.docker.com/v2/repositories/{image}/tags?page_size=100&name={prefix}"; + await FetchDockerHubPage(httpClient, prefixUrl, allTags); + } + } + + return allTags.ToList(); +} + +static List GetVersionPrefixes(string currentTag) +{ + var prefixes = new List(); + + // Strip leading 'v' for numeric parsing + var stripped = currentTag.TrimStart('v'); + var vPrefix = currentTag.StartsWith('v') ? "v" : ""; + + // Try to parse the major version number + var dotIndex = stripped.IndexOf('.'); + var dashIndex = stripped.IndexOf('-'); + var majorStr = dotIndex > 0 ? stripped[..dotIndex] + : dashIndex > 0 ? stripped[..dashIndex] + : stripped; + + if (int.TryParse(majorStr, out var major)) + { + // Query for current major and next two majors to catch new releases + for (var m = major; m <= major + 2; m++) + { + prefixes.Add($"{vPrefix}{m}."); + } + } + + return prefixes; +} + +static async Task FetchDockerHubPage(HttpClient httpClient, string url, HashSet tags) +{ + try + { + var response = await httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + if (doc.RootElement.TryGetProperty("results", out var results)) + { + foreach (var result in results.EnumerateArray()) + { + if (result.TryGetProperty("name", out var name)) + { + tags.Add(name.GetString()!); + } + } + } + } + catch + { + // Ignore failures on supplementary prefix queries + } +} + +static async Task> FetchOciTags(HttpClient httpClient, string url, string? authUrl) +{ + var request = new HttpRequestMessage(HttpMethod.Get, url); + + if (authUrl is not null) + { + // Fetch anonymous token + var tokenResponse = await httpClient.GetAsync(authUrl); + tokenResponse.EnsureSuccessStatusCode(); + var tokenJson = await tokenResponse.Content.ReadAsStringAsync(); + using var tokenDoc = JsonDocument.Parse(tokenJson); + var token = tokenDoc.RootElement.GetProperty("token").GetString(); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + var response = await httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var tags = new List(); + if (doc.RootElement.TryGetProperty("tags", out var tagsArray)) + { + foreach (var tag in tagsArray.EnumerateArray()) + { + tags.Add(tag.GetString()!); + } + } + + // Return last 50 tags (OCI API doesn't sort by date, but tends to be roughly chronological) + return tags.TakeLast(50).ToList(); +} + +// ─── Models ────────────────────────────────────────────────────────────────── + +class ImageFileReport +{ + public string File { get; set; } = ""; + public List Entries { get; set; } = []; +} + +class ImageEntry +{ + public string? FieldPrefix { get; set; } + public string Registry { get; set; } = ""; + public string Image { get; set; } = ""; + public string CurrentTag { get; set; } = ""; + public bool? IsDerived { get; set; } + public bool? Skipped { get; set; } + public string? SkipReason { get; set; } + public List? AvailableTags { get; set; } +} diff --git a/src/Aspire.Hosting.Azure.EventHubs/EventHubsEmulatorContainerImageTags.cs b/src/Aspire.Hosting.Azure.EventHubs/EventHubsEmulatorContainerImageTags.cs index a51a54bb1fa..bfdc2df77e0 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/EventHubsEmulatorContainerImageTags.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/EventHubsEmulatorContainerImageTags.cs @@ -11,6 +11,6 @@ internal static class EventHubsEmulatorContainerImageTags /// azure-messaging/eventhubs-emulator public const string Image = "azure-messaging/eventhubs-emulator"; - /// 2.1.0 - public const string Tag = "2.1.0"; + /// 2.2.0 + public const string Tag = "2.2.0"; } diff --git a/src/Aspire.Hosting.Kafka/KafkaContainerImageTags.cs b/src/Aspire.Hosting.Kafka/KafkaContainerImageTags.cs index 85c5a41ee49..2a249dd8147 100644 --- a/src/Aspire.Hosting.Kafka/KafkaContainerImageTags.cs +++ b/src/Aspire.Hosting.Kafka/KafkaContainerImageTags.cs @@ -11,13 +11,13 @@ internal static class KafkaContainerImageTags /// confluentinc/confluent-local public const string Image = "confluentinc/confluent-local"; - /// 8.1.0 - public const string Tag = "8.1.0"; + /// 8.1.1 + public const string Tag = "8.1.1"; /// kafbat/kafka-ui public const string KafkaUiImage = "kafbat/kafka-ui"; - /// v1.3.0 - public const string KafkaUiTag = "v1.3.0"; + /// v1.4.2 + public const string KafkaUiTag = "v1.4.2"; } diff --git a/src/Aspire.Hosting.Keycloak/KeycloakContainerImageTags.cs b/src/Aspire.Hosting.Keycloak/KeycloakContainerImageTags.cs index 284c9d939e7..2de8a1ae0bd 100644 --- a/src/Aspire.Hosting.Keycloak/KeycloakContainerImageTags.cs +++ b/src/Aspire.Hosting.Keycloak/KeycloakContainerImageTags.cs @@ -11,8 +11,8 @@ internal static class KeycloakContainerImageTags /// keycloak/keycloak public const string Image = "keycloak/keycloak"; - /// 26.4 - public const string Tag = "26.4"; + /// 26.5 + public const string Tag = "26.5"; // 1000> public const int ContainerUser = 1000; diff --git a/src/Aspire.Hosting.Milvus/MilvusContainerImageTags.cs b/src/Aspire.Hosting.Milvus/MilvusContainerImageTags.cs index abd94fecad8..2edbb5e997a 100644 --- a/src/Aspire.Hosting.Milvus/MilvusContainerImageTags.cs +++ b/src/Aspire.Hosting.Milvus/MilvusContainerImageTags.cs @@ -12,8 +12,8 @@ internal static class MilvusContainerImageTags public const string Image = "milvusdb/milvus"; // Note that when trying to update to v2.6.0 we hit https://github.com/dotnet/aspire/issues/11184 - /// v2.5.17 - public const string Tag = "v2.5.17"; + /// v2.5.27 + public const string Tag = "v2.5.27"; /// zilliz/attu public const string AttuImage = "zilliz/attu"; diff --git a/src/Aspire.Hosting.MySql/MySqlContainerImageTags.cs b/src/Aspire.Hosting.MySql/MySqlContainerImageTags.cs index b7710dcebfe..3bde118edbf 100644 --- a/src/Aspire.Hosting.MySql/MySqlContainerImageTags.cs +++ b/src/Aspire.Hosting.MySql/MySqlContainerImageTags.cs @@ -11,8 +11,8 @@ internal static class MySqlContainerImageTags /// library/mysql public const string Image = "library/mysql"; - /// 9.5 - public const string Tag = "9.5"; + /// 9.6 + public const string Tag = "9.6"; /// library/phpmyadmin public const string PhpMyAdminImage = "library/phpmyadmin"; diff --git a/src/Aspire.Hosting.Oracle/OracleContainerImageTags.cs b/src/Aspire.Hosting.Oracle/OracleContainerImageTags.cs index bd23679dfba..e4cd0cca251 100644 --- a/src/Aspire.Hosting.Oracle/OracleContainerImageTags.cs +++ b/src/Aspire.Hosting.Oracle/OracleContainerImageTags.cs @@ -11,6 +11,6 @@ internal static class OracleContainerImageTags /// database/free public const string Image = "database/free"; - /// 23.26.0.0 - public const string Tag = "23.26.0.0"; + /// 23.26.1.0 + public const string Tag = "23.26.1.0"; } diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresContainerImageTags.cs b/src/Aspire.Hosting.PostgreSQL/PostgresContainerImageTags.cs index 0b1040c0460..5c4a836af27 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresContainerImageTags.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresContainerImageTags.cs @@ -20,8 +20,8 @@ internal static class PostgresContainerImageTags /// dpage/pgadmin4 public const string PgAdminImage = "dpage/pgadmin4"; - /// 9.9.0 - public const string PgAdminTag = "9.9.0"; + /// 9.12.0 + public const string PgAdminTag = "9.12.0"; /// docker.io public const string PgWebRegistry = "docker.io"; @@ -29,8 +29,8 @@ internal static class PostgresContainerImageTags /// sosedoff/pgweb public const string PgWebImage = "sosedoff/pgweb"; - /// 0.16.2 - public const string PgWebTag = "0.16.2"; + /// 0.17.0 + public const string PgWebTag = "0.17.0"; /// docker.io public const string PostgresMcpRegistry = "docker.io"; diff --git a/src/Aspire.Hosting.Qdrant/QdrantContainerImageTags.cs b/src/Aspire.Hosting.Qdrant/QdrantContainerImageTags.cs index 35c592e68e3..1aab63af63e 100644 --- a/src/Aspire.Hosting.Qdrant/QdrantContainerImageTags.cs +++ b/src/Aspire.Hosting.Qdrant/QdrantContainerImageTags.cs @@ -11,7 +11,7 @@ internal static class QdrantContainerImageTags /// qdrant/qdrant public const string Image = "qdrant/qdrant"; - /// v1.15.5 - public const string Tag = "v1.15.5"; + /// v1.16.3 + public const string Tag = "v1.16.3"; } diff --git a/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs b/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs index ec897d6f759..f33854a96e9 100644 --- a/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs +++ b/src/Aspire.Hosting.Redis/RedisContainerImageTags.cs @@ -11,8 +11,8 @@ internal static class RedisContainerImageTags /// library/redis public const string Image = "library/redis"; - /// 8.2 - public const string Tag = "8.2"; + /// 8.6 + public const string Tag = "8.6"; /// RedisCommanderRegistry public const string RedisCommanderRegistry = "docker.io"; @@ -29,6 +29,6 @@ internal static class RedisContainerImageTags /// redis/redisinsight public const string RedisInsightImage = "redis/redisinsight"; - /// 2.70 - public const string RedisInsightTag = "2.70"; + /// 3.0 + public const string RedisInsightTag = "3.0"; } diff --git a/src/Aspire.Hosting.Yarp/YarpContainerImageTags.cs b/src/Aspire.Hosting.Yarp/YarpContainerImageTags.cs index d69a9263c71..cfa8aeefb1c 100644 --- a/src/Aspire.Hosting.Yarp/YarpContainerImageTags.cs +++ b/src/Aspire.Hosting.Yarp/YarpContainerImageTags.cs @@ -9,5 +9,5 @@ internal static class YarpContainerImageTags public const string Image = "dotnet/nightly/yarp"; - public const string Tag = "2.3.0-preview.4"; + public const string Tag = "2.3-preview"; } diff --git a/tests/Aspire.Hosting.Yarp.Tests/AddYarpTests.cs b/tests/Aspire.Hosting.Yarp.Tests/AddYarpTests.cs index d84cb50ef03..95681872ce6 100644 --- a/tests/Aspire.Hosting.Yarp.Tests/AddYarpTests.cs +++ b/tests/Aspire.Hosting.Yarp.Tests/AddYarpTests.cs @@ -214,7 +214,7 @@ public async Task VerifyWithStaticFilesGeneratesCorrectDockerfileInPublishMode() var dockerfile = await annotation.DockerfileFactory(context); Assert.Contains("FROM", dockerfile); - Assert.Contains("dotnet/nightly/yarp:2.3.0-preview.4", dockerfile); + Assert.Contains("dotnet/nightly/yarp:2.3-preview", dockerfile); Assert.Contains("AS yarp", dockerfile); Assert.Contains("WORKDIR /app", dockerfile); Assert.Contains("COPY . /app/wwwroot", dockerfile); diff --git a/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesGeneratesCorrectDockerfile.verified.txt b/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesGeneratesCorrectDockerfile.verified.txt index 4c65c9d1dbb..38cb9eae710 100644 --- a/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesGeneratesCorrectDockerfile.verified.txt +++ b/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesGeneratesCorrectDockerfile.verified.txt @@ -1,8 +1,8 @@ -ARG SOURCE_IMAGENAME=sourceimage:latest +ARG SOURCE_IMAGENAME=sourceimage:latest FROM ${SOURCE_IMAGENAME} AS source_stage -FROM mcr.microsoft.com/dotnet/nightly/yarp:2.3.0-preview.4 +FROM mcr.microsoft.com/dotnet/nightly/yarp:2.3-preview WORKDIR /app COPY --from=source_stage /app/dist /app/wwwroot diff --git a/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesGeneratesCorrectDockerfileWithMultipleContainerFiles.verified.txt b/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesGeneratesCorrectDockerfileWithMultipleContainerFiles.verified.txt index 92a37e3e372..37db9137e82 100644 --- a/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesGeneratesCorrectDockerfileWithMultipleContainerFiles.verified.txt +++ b/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesGeneratesCorrectDockerfileWithMultipleContainerFiles.verified.txt @@ -1,8 +1,8 @@ -ARG SOURCE_IMAGENAME=sourceimage:latest +ARG SOURCE_IMAGENAME=sourceimage:latest FROM ${SOURCE_IMAGENAME} AS source_stage -FROM mcr.microsoft.com/dotnet/nightly/yarp:2.3.0-preview.4 +FROM mcr.microsoft.com/dotnet/nightly/yarp:2.3-preview WORKDIR /app COPY --from=source_stage /app/dist /app/wwwroot COPY --from=source_stage /app/assets /app/wwwroot diff --git a/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesGeneratesCorrectDockerfileWithMultipleSourceContainerFiles.verified.txt b/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesGeneratesCorrectDockerfileWithMultipleSourceContainerFiles.verified.txt index cf7caff77be..8621406af21 100644 --- a/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesGeneratesCorrectDockerfileWithMultipleSourceContainerFiles.verified.txt +++ b/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesGeneratesCorrectDockerfileWithMultipleSourceContainerFiles.verified.txt @@ -1,11 +1,11 @@ -ARG SOURCE1_IMAGENAME=sourceimage:latest +ARG SOURCE1_IMAGENAME=sourceimage:latest ARG SOURCE2_IMAGENAME=sourceimage2:latest FROM ${SOURCE1_IMAGENAME} AS source1_stage FROM ${SOURCE2_IMAGENAME} AS source2_stage -FROM mcr.microsoft.com/dotnet/nightly/yarp:2.3.0-preview.4 +FROM mcr.microsoft.com/dotnet/nightly/yarp:2.3-preview WORKDIR /app COPY --from=source1_stage /app/dist /app/wwwroot COPY --from=source1_stage /app/assets /app/wwwroot diff --git a/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesHandlesMissingSourceImageGracefully.verified.txt b/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesHandlesMissingSourceImageGracefully.verified.txt index 106e3953f8c..5e6c149ab2c 100644 --- a/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesHandlesMissingSourceImageGracefully.verified.txt +++ b/tests/Aspire.Hosting.Yarp.Tests/Snapshots/AddYarpTests.VerifyPublishWithStaticFilesHandlesMissingSourceImageGracefully.verified.txt @@ -1,3 +1,3 @@ -FROM mcr.microsoft.com/dotnet/nightly/yarp:2.3.0-preview.4 +FROM mcr.microsoft.com/dotnet/nightly/yarp:2.3-preview WORKDIR /app diff --git a/tests/Aspire.Hosting.Yarp.Tests/Snapshots/YarpConfigGeneratorTests.GenerateEnvVariablesConfigurationDockerCompose.verified.env b/tests/Aspire.Hosting.Yarp.Tests/Snapshots/YarpConfigGeneratorTests.GenerateEnvVariablesConfigurationDockerCompose.verified.env index d573f489bd6..94a8bc0ab9a 100644 --- a/tests/Aspire.Hosting.Yarp.Tests/Snapshots/YarpConfigGeneratorTests.GenerateEnvVariablesConfigurationDockerCompose.verified.env +++ b/tests/Aspire.Hosting.Yarp.Tests/Snapshots/YarpConfigGeneratorTests.GenerateEnvVariablesConfigurationDockerCompose.verified.env @@ -1,4 +1,4 @@ -services: +services: docker-compose-dashboard: image: "mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest" ports: @@ -22,7 +22,7 @@ networks: - "aspire" gateway: - image: "mcr.microsoft.com/dotnet/nightly/yarp:2.3.0-preview.4" + image: "mcr.microsoft.com/dotnet/nightly/yarp:2.3-preview" command: - "/app/yarp.dll" entrypoint: From d265192656158979a9a5f34af47453ec6445acca Mon Sep 17 00:00:00 2001 From: "Mitch Capper (they, them)" Date: Sun, 22 Feb 2026 15:42:40 -0800 Subject: [PATCH 132/256] fixed unreachable CircularBuffer.Decrement double decrement (#14609) --- src/Shared/CircularBuffer.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Shared/CircularBuffer.cs b/src/Shared/CircularBuffer.cs index d9812b201bf..551a5f09321 100644 --- a/src/Shared/CircularBuffer.cs +++ b/src/Shared/CircularBuffer.cs @@ -278,12 +278,7 @@ private void Increment(ref int index) private void Decrement(ref int index) { - if (index <= 0) - { - index = Capacity - 1; - } - - --index; + index = (index <= 0) ? Capacity - 1 : index - 1; } public CircularBuffer Clone() From 54c20aae1c3210eeec78a4b2e2fa285b85392883 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 23 Feb 2026 14:40:51 +1100 Subject: [PATCH 133/256] Fix aspire stop --non-interactive with multiple AppHosts (#14575) * Fix aspire stop --non-interactive with multiple AppHosts (#14558) When multiple AppHosts are running and aspire stop --non-interactive is used, the CLI now handles the scenario gracefully instead of crashing: - Single AppHost: auto-selects and stops it without prompting - Multiple AppHosts: shows clear error message suggesting --project or --all - No running AppHosts: shows appropriate error message Added --all option to stop all running AppHosts at once, which works in both interactive and non-interactive modes. Also adds WaitForAnyPrompt helper for E2E tests that expect non-zero exit codes, and three E2E test scenarios covering the new behavior. Fixes #14558 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review comments - Add --all/--project mutual exclusivity validation - Thread CancellationToken through StopAppHostAsync and all callers - Add XML doc summaries to private methods - Rewrite StopAllAppHosts test to create 2 projects and verify both are stopped - Split into two tests: from AppHost dir and from unrelated dir Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix --non-interactive to take precedence over ASPIRE_PLAYGROUND The --non-interactive flag was being overridden by ASPIRE_PLAYGROUND=true in CliHostEnvironment, causing non-interactive mode to be ignored in E2E tests. Swap the priority so explicit --non-interactive always wins. Also use --project in StopNonInteractiveSingleAppHost test to avoid interference from concurrent tests sharing the backchannel directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address JamesNK review feedback - Move error strings to resource files with format placeholders - Scope non-interactive auto-select to in-scope AppHosts only - Stop AppHosts in parallel instead of sequentially - Add ILogger logging to StopAllAppHostsAsync - Handle --all with resource argument mutual exclusivity - Use CultureInfo.InvariantCulture for string.Format calls 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> --- .../Backchannel/AppHostConnectionResolver.cs | 27 + src/Aspire.Cli/Commands/StopCommand.cs | 141 ++++- .../Resources/StopCommandStrings.Designer.cs | 24 + .../Resources/StopCommandStrings.resx | 12 + .../Resources/xlf/StopCommandStrings.cs.xlf | 20 + .../Resources/xlf/StopCommandStrings.de.xlf | 20 + .../Resources/xlf/StopCommandStrings.es.xlf | 20 + .../Resources/xlf/StopCommandStrings.fr.xlf | 20 + .../Resources/xlf/StopCommandStrings.it.xlf | 20 + .../Resources/xlf/StopCommandStrings.ja.xlf | 20 + .../Resources/xlf/StopCommandStrings.ko.xlf | 20 + .../Resources/xlf/StopCommandStrings.pl.xlf | 20 + .../xlf/StopCommandStrings.pt-BR.xlf | 20 + .../Resources/xlf/StopCommandStrings.ru.xlf | 20 + .../Resources/xlf/StopCommandStrings.tr.xlf | 20 + .../xlf/StopCommandStrings.zh-Hans.xlf | 20 + .../xlf/StopCommandStrings.zh-Hant.xlf | 20 + src/Aspire.Cli/Utils/CliHostEnvironment.cs | 20 +- .../Helpers/CliE2ETestHelpers.cs | 25 + .../StopNonInteractiveTests.cs | 595 ++++++++++++++++++ .../Utils/CliHostEnvironmentTests.cs | 16 +- 21 files changed, 1095 insertions(+), 25 deletions(-) create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index 651cdb617bb..814ccc19bbc 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs @@ -34,6 +34,33 @@ internal sealed class AppHostConnectionResolver( CliExecutionContext executionContext, ILogger logger) { + /// + /// Resolves all running AppHost connections using socket-first discovery. + /// Used when stopping all running AppHosts (e.g., via --all flag). + /// + /// Message to display while scanning for AppHosts. + /// Cancellation token. + /// All resolved connections, or an empty array if none found. + public async Task ResolveAllConnectionsAsync( + string scanningMessage, + CancellationToken cancellationToken) + { + var connections = await interactionService.ShowStatusAsync( + scanningMessage, + async () => + { + await backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); + return backchannelMonitor.Connections.ToList(); + }); + + if (connections.Count == 0) + { + return []; + } + + return connections.Select(c => new AppHostConnectionResult { Connection = c }).ToArray(); + } + /// /// Resolves an AppHost connection using socket-first discovery. /// diff --git a/src/Aspire.Cli/Commands/StopCommand.cs b/src/Aspire.Cli/Commands/StopCommand.cs index b931a6d5b6c..3fd69c8cd78 100644 --- a/src/Aspire.Cli/Commands/StopCommand.cs +++ b/src/Aspire.Cli/Commands/StopCommand.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.Diagnostics; +using System.Globalization; using Aspire.Cli.Backchannel; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; @@ -20,6 +21,7 @@ internal sealed class StopCommand : BaseCommand private readonly IInteractionService _interactionService; private readonly AppHostConnectionResolver _connectionResolver; private readonly ILogger _logger; + private readonly ICliHostEnvironment _hostEnvironment; private readonly TimeProvider _timeProvider; private static readonly Argument s_resourceArgument = new("resource") @@ -33,12 +35,18 @@ internal sealed class StopCommand : BaseCommand Description = StopCommandStrings.ProjectArgumentDescription }; + private static readonly Option s_allOption = new("--all") + { + Description = StopCommandStrings.AllOptionDescription + }; + public StopCommand( IInteractionService interactionService, IAuxiliaryBackchannelMonitor backchannelMonitor, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, + ICliHostEnvironment hostEnvironment, ILogger logger, AspireCliTelemetry telemetry, TimeProvider? timeProvider = null) @@ -46,18 +54,98 @@ public StopCommand( { _interactionService = interactionService; _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); + _hostEnvironment = hostEnvironment; _logger = logger; _timeProvider = timeProvider ?? TimeProvider.System; Arguments.Add(s_resourceArgument); Options.Add(s_projectOption); + Options.Add(s_allOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { var resourceName = parseResult.GetValue(s_resourceArgument); var passedAppHostProjectFile = parseResult.GetValue(s_projectOption); + var stopAll = parseResult.GetValue(s_allOption); + + // Validate mutual exclusivity of --all and --project + if (stopAll && passedAppHostProjectFile is not null) + { + _interactionService.DisplayError(string.Format(CultureInfo.InvariantCulture, StopCommandStrings.AllAndProjectMutuallyExclusive, s_allOption.Name, s_projectOption.Name)); + return ExitCodeConstants.FailedToFindProject; + } + + // Validate mutual exclusivity of --all and resource argument + if (stopAll && !string.IsNullOrEmpty(resourceName)) + { + _interactionService.DisplayError(string.Format(CultureInfo.InvariantCulture, StopCommandStrings.AllAndResourceMutuallyExclusive, s_allOption.Name)); + return ExitCodeConstants.FailedToFindProject; + } + + // Handle --all: stop all running AppHosts + if (stopAll) + { + return await StopAllAppHostsAsync(cancellationToken); + } + + // In non-interactive mode, try to auto-resolve without prompting + if (!_hostEnvironment.SupportsInteractiveInput) + { + return await ExecuteNonInteractiveAsync(passedAppHostProjectFile, resourceName, cancellationToken); + } + + return await ExecuteInteractiveAsync(passedAppHostProjectFile, resourceName, cancellationToken); + } + + /// + /// Handles the stop command in non-interactive mode by auto-resolving a single AppHost + /// or returning an error when multiple AppHosts are running. + /// + private async Task ExecuteNonInteractiveAsync(FileInfo? passedAppHostProjectFile, string? resourceName, CancellationToken cancellationToken) + { + // If --project is specified, use the standard resolver (no prompting needed) + if (passedAppHostProjectFile is not null) + { + return await ExecuteInteractiveAsync(passedAppHostProjectFile, resourceName, cancellationToken); + } + + // Scan for all running AppHosts + var allConnections = await _connectionResolver.ResolveAllConnectionsAsync( + StopCommandStrings.ScanningForRunningAppHosts, + cancellationToken); + + if (allConnections.Length == 0) + { + _interactionService.DisplayError(StopCommandStrings.NoRunningAppHostsFound); + return ExitCodeConstants.FailedToFindProject; + } + + // In non-interactive mode, only consider in-scope AppHosts (under current directory) + // to avoid accidentally stopping unrelated AppHosts + var inScopeConnections = allConnections.Where(c => c.Connection!.IsInScope).ToArray(); + // Single in-scope AppHost: auto-select it + if (inScopeConnections.Length == 1) + { + var connection = inScopeConnections[0].Connection!; + if (!string.IsNullOrEmpty(resourceName)) + { + return await StopResourceAsync(connection, resourceName, cancellationToken); + } + return await StopAppHostAsync(connection, cancellationToken); + } + + // Multiple in-scope AppHosts or none in scope: error with guidance + _interactionService.DisplayError(string.Format(CultureInfo.InvariantCulture, StopCommandStrings.MultipleAppHostsNonInteractive, s_projectOption.Name, s_allOption.Name)); + return ExitCodeConstants.FailedToFindProject; + } + + /// + /// Handles the stop command in interactive mode, prompting the user to select an AppHost if multiple are running. + /// + private async Task ExecuteInteractiveAsync(FileInfo? passedAppHostProjectFile, string? resourceName, CancellationToken cancellationToken) + { var result = await _connectionResolver.ResolveConnectionAsync( passedAppHostProjectFile, StopCommandStrings.ScanningForRunningAppHosts, @@ -74,22 +162,63 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var selectedConnection = result.Connection!; - // If a resource name is provided, stop that specific resource instead of the AppHost if (!string.IsNullOrEmpty(resourceName)) { return await StopResourceAsync(selectedConnection, resourceName, cancellationToken); } + return await StopAppHostAsync(selectedConnection, cancellationToken); + } + + /// + /// Stops all running AppHosts discovered via socket scanning. + /// + private async Task StopAllAppHostsAsync(CancellationToken cancellationToken) + { + var allConnections = await _connectionResolver.ResolveAllConnectionsAsync( + StopCommandStrings.ScanningForRunningAppHosts, + cancellationToken); + + if (allConnections.Length == 0) + { + _interactionService.DisplayError(StopCommandStrings.NoRunningAppHostsFound); + return ExitCodeConstants.FailedToFindProject; + } + + _logger.LogDebug("Found {Count} running AppHost(s) to stop", allConnections.Length); + + // Stop all AppHosts in parallel + var stopTasks = allConnections.Select(connectionResult => + { + var connection = connectionResult.Connection!; + var appHostPath = connection.AppHostInfo?.AppHostPath ?? "Unknown"; + _logger.LogDebug("Queuing stop for AppHost: {AppHostPath}", appHostPath); + return StopAppHostAsync(connection, cancellationToken); + }).ToArray(); + + var results = await Task.WhenAll(stopTasks); + var allStopped = results.All(exitCode => exitCode == ExitCodeConstants.Success); + + _logger.LogDebug("Stop all completed. All stopped: {AllStopped}", allStopped); + + return allStopped ? ExitCodeConstants.Success : ExitCodeConstants.FailedToDotnetRunAppHost; + } + + /// + /// Stops a single AppHost by sending a stop signal to its CLI process or falling back to RPC. + /// + private async Task StopAppHostAsync(IAppHostAuxiliaryBackchannel connection, CancellationToken cancellationToken) + { // Stop the selected AppHost - var appHostPath = selectedConnection.AppHostInfo?.AppHostPath ?? "Unknown"; + var appHostPath = connection.AppHostInfo?.AppHostPath ?? "Unknown"; // Use relative path for in-scope, full path for out-of-scope - var displayPath = selectedConnection.IsInScope - ? Path.GetRelativePath(ExecutionContext.WorkingDirectory.FullName, appHostPath) + var displayPath = connection.IsInScope + ? Path.GetRelativePath(ExecutionContext.WorkingDirectory.FullName, appHostPath) : appHostPath; _interactionService.DisplayMessage("package", $"Found running AppHost: {displayPath}"); _logger.LogDebug("Stopping AppHost: {AppHostPath}", appHostPath); - var appHostInfo = selectedConnection.AppHostInfo; + var appHostInfo = connection.AppHostInfo; _interactionService.DisplayMessage("stop_sign", "Sending stop signal..."); @@ -118,7 +247,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var rpcSucceeded = false; try { - rpcSucceeded = await selectedConnection.StopAppHostAsync(cancellationToken).ConfigureAwait(false); + rpcSucceeded = await connection.StopAppHostAsync(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { diff --git a/src/Aspire.Cli/Resources/StopCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/StopCommandStrings.Designer.cs index 350ac9f2a9b..521cafe1c7d 100644 --- a/src/Aspire.Cli/Resources/StopCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/StopCommandStrings.Designer.cs @@ -104,5 +104,29 @@ public static string NoInScopeAppHostsShowingAll { return ResourceManager.GetString("NoInScopeAppHostsShowingAll", resourceCulture); } } + + public static string AllOptionDescription { + get { + return ResourceManager.GetString("AllOptionDescription", resourceCulture); + } + } + + public static string MultipleAppHostsNonInteractive { + get { + return ResourceManager.GetString("MultipleAppHostsNonInteractive", resourceCulture); + } + } + + public static string AllAndProjectMutuallyExclusive { + get { + return ResourceManager.GetString("AllAndProjectMutuallyExclusive", resourceCulture); + } + } + + public static string AllAndResourceMutuallyExclusive { + get { + return ResourceManager.GetString("AllAndResourceMutuallyExclusive", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/StopCommandStrings.resx b/src/Aspire.Cli/Resources/StopCommandStrings.resx index 593816f742c..9771cbcbb7b 100644 --- a/src/Aspire.Cli/Resources/StopCommandStrings.resx +++ b/src/Aspire.Cli/Resources/StopCommandStrings.resx @@ -147,4 +147,16 @@ No running AppHosts found in the current directory. Showing all running AppHosts: + + Stop all running AppHosts. + + + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + + + The {0} and {1} options cannot be used together. + + + The {0} option cannot be used with a resource argument. + diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.cs.xlf index 1741a0e6892..f16f5e7fab5 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.cs.xlf @@ -2,6 +2,11 @@ + + Stop all running AppHosts. + Stop all running AppHosts. + + AppHost stopped successfully. Hostitel aplikací se úspěšně zastavil. @@ -17,6 +22,11 @@ Nepovedlo se zastavit hostitele aplikací. + + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + + Multiple AppHosts are running. Use --project to specify which one to stop, or select one: Je spuštěno více hostitelů aplikací. Pomocí --project určete, který z nich se má zastavit, nebo vyberte jednoho z nich: @@ -52,6 +62,16 @@ Spouští se hostitel aplikací Aspire... + + The {0} and {1} options cannot be used together. + The {0} and {1} options cannot be used together. + + + + The {0} option cannot be used with a resource argument. + The {0} option cannot be used with a resource argument. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.de.xlf index d0f298c9ad2..3e8aea07d7c 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.de.xlf @@ -2,6 +2,11 @@ + + Stop all running AppHosts. + Stop all running AppHosts. + + AppHost stopped successfully. AppHost wurde erfolgreich beendet. @@ -17,6 +22,11 @@ Beim Beenden des AppHost ist ein Fehler aufgetreten. + + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + + Multiple AppHosts are running. Use --project to specify which one to stop, or select one: Mehrere AppHosts sind aktiv. Verwenden Sie --project, um anzugeben, welcher AppHost gestoppt werden soll, oder wählen Sie einen aus: @@ -52,6 +62,16 @@ Aspire-AppHost wird beendet … + + The {0} and {1} options cannot be used together. + The {0} and {1} options cannot be used together. + + + + The {0} option cannot be used with a resource argument. + The {0} option cannot be used with a resource argument. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.es.xlf index 8ac6e6df44a..0adcc347afe 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.es.xlf @@ -2,6 +2,11 @@ + + Stop all running AppHosts. + Stop all running AppHosts. + + AppHost stopped successfully. AppHost se detuvo correctamente. @@ -17,6 +22,11 @@ No se pudo detener AppHost. + + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + + Multiple AppHosts are running. Use --project to specify which one to stop, or select one: Se están ejecutando varios AppHost. Use --project para especificar cuál se va a detener o seleccione uno: @@ -52,6 +62,16 @@ Deteniendo apphost de Aspire... + + The {0} and {1} options cannot be used together. + The {0} and {1} options cannot be used together. + + + + The {0} option cannot be used with a resource argument. + The {0} option cannot be used with a resource argument. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.fr.xlf index d935d5d550b..e4796337a42 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.fr.xlf @@ -2,6 +2,11 @@ + + Stop all running AppHosts. + Stop all running AppHosts. + + AppHost stopped successfully. AppHost s’est arrêtée avec succès. @@ -17,6 +22,11 @@ Échec de l'arrêt d’AppHost. + + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + + Multiple AppHosts are running. Use --project to specify which one to stop, or select one: Plusieurs AppHosts sont en cours d’exécution. Utilisez --project pour spécifier celui à arrêter ou sélectionnez-en un : @@ -52,6 +62,16 @@ Arrêt d'Aspire apphost... + + The {0} and {1} options cannot be used together. + The {0} and {1} options cannot be used together. + + + + The {0} option cannot be used with a resource argument. + The {0} option cannot be used with a resource argument. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.it.xlf index 441ca7ed325..4517aa8cda3 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.it.xlf @@ -2,6 +2,11 @@ + + Stop all running AppHosts. + Stop all running AppHosts. + + AppHost stopped successfully. AppHost è stato interrotto. @@ -17,6 +22,11 @@ Non è possibile arrestare AppHost. + + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + + Multiple AppHosts are running. Use --project to specify which one to stop, or select one: Sono in esecuzione più AppHost. Usare --project per specificare quale interrompere o selezionarne uno: @@ -52,6 +62,16 @@ Arresto dell'apphost Aspire in corso... + + The {0} and {1} options cannot be used together. + The {0} and {1} options cannot be used together. + + + + The {0} option cannot be used with a resource argument. + The {0} option cannot be used with a resource argument. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ja.xlf index 80704e4ff39..2713ffa4796 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ja.xlf @@ -2,6 +2,11 @@ + + Stop all running AppHosts. + Stop all running AppHosts. + + AppHost stopped successfully. AppHost が正常に停止しました。 @@ -17,6 +22,11 @@ AppHost を停止できませんでした。 + + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + + Multiple AppHosts are running. Use --project to specify which one to stop, or select one: 複数の AppHost が実行されています。--project を使用して停止するものを指定するか、次のいずれかを選択します。 @@ -52,6 +62,16 @@ Aspire apphost を停止しています... + + The {0} and {1} options cannot be used together. + The {0} and {1} options cannot be used together. + + + + The {0} option cannot be used with a resource argument. + The {0} option cannot be used with a resource argument. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ko.xlf index 78d60c7af4f..b69ab73a1ba 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ko.xlf @@ -2,6 +2,11 @@ + + Stop all running AppHosts. + Stop all running AppHosts. + + AppHost stopped successfully. AppHost가 중지되었습니다. @@ -17,6 +22,11 @@ AppHost를 중지하지 못했습니다. + + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + + Multiple AppHosts are running. Use --project to specify which one to stop, or select one: 여러 AppHost가 실행 중입니다. --project를 사용해 중지할 AppHost를 지정하거나 하나를 선택하세요. @@ -52,6 +62,16 @@ Aspire AppHost를 중지하는 중... + + The {0} and {1} options cannot be used together. + The {0} and {1} options cannot be used together. + + + + The {0} option cannot be used with a resource argument. + The {0} option cannot be used with a resource argument. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pl.xlf index 1d1d128ea0e..27771d85e00 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pl.xlf @@ -2,6 +2,11 @@ + + Stop all running AppHosts. + Stop all running AppHosts. + + AppHost stopped successfully. Host aplikacji został pomyślnie zatrzymany. @@ -17,6 +22,11 @@ Nie można zatrzymać hosta aplikacji. + + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + + Multiple AppHosts are running. Use --project to specify which one to stop, or select one: Uruchomiono wiele hostów aplikacji. Użyj opcji --project, aby określić, która ma zostać zatrzymana, lub wybierz jedną z nich: @@ -52,6 +62,16 @@ Trwa zatrzymywanie hosta Aspire... + + The {0} and {1} options cannot be used together. + The {0} and {1} options cannot be used together. + + + + The {0} option cannot be used with a resource argument. + The {0} option cannot be used with a resource argument. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pt-BR.xlf index 2bc61e88213..ca258adf2e8 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pt-BR.xlf @@ -2,6 +2,11 @@ + + Stop all running AppHosts. + Stop all running AppHosts. + + AppHost stopped successfully. AppHost interrompido com sucesso. @@ -17,6 +22,11 @@ Falha ao parar o AppHost. + + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + + Multiple AppHosts are running. Use --project to specify which one to stop, or select one: Vários AppHosts estão em execução. Use --project para especificar qual parar ou escolha um: @@ -52,6 +62,16 @@ Parando o apphost Aspire... + + The {0} and {1} options cannot be used together. + The {0} and {1} options cannot be used together. + + + + The {0} option cannot be used with a resource argument. + The {0} option cannot be used with a resource argument. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ru.xlf index 09328de1537..fc37708b67e 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ru.xlf @@ -2,6 +2,11 @@ + + Stop all running AppHosts. + Stop all running AppHosts. + + AppHost stopped successfully. Хост приложений остановлен. @@ -17,6 +22,11 @@ Не удалось остановить хост приложений. + + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + + Multiple AppHosts are running. Use --project to specify which one to stop, or select one: Запущено несколько хостов приложений. Используйте параметр --project, чтобы указать, какой остановить, или выберите один из них: @@ -52,6 +62,16 @@ Остановка хоста приложений Aspire... + + The {0} and {1} options cannot be used together. + The {0} and {1} options cannot be used together. + + + + The {0} option cannot be used with a resource argument. + The {0} option cannot be used with a resource argument. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.tr.xlf index 3e026afd632..b93c6e54303 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.tr.xlf @@ -2,6 +2,11 @@ + + Stop all running AppHosts. + Stop all running AppHosts. + + AppHost stopped successfully. AppHost başarıyla durduruldu. @@ -17,6 +22,11 @@ AppHost durdurulamadı. + + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + + Multiple AppHosts are running. Use --project to specify which one to stop, or select one: Birden çok AppHost çalışıyor. Durdurulacak AppHost'u belirtmek için --project komutunu kullanın veya birini seçin: @@ -52,6 +62,16 @@ Aspire apphost durduruluyor... + + The {0} and {1} options cannot be used together. + The {0} and {1} options cannot be used together. + + + + The {0} option cannot be used with a resource argument. + The {0} option cannot be used with a resource argument. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hans.xlf index 531a4c6514c..07c4a6649a9 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hans.xlf @@ -2,6 +2,11 @@ + + Stop all running AppHosts. + Stop all running AppHosts. + + AppHost stopped successfully. 已成功停止 AppHost。 @@ -17,6 +22,11 @@ 停止 AppHost 失败。 + + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + + Multiple AppHosts are running. Use --project to specify which one to stop, or select one: 多个 AppHost 正在运行。使用 --project 指定要停止哪一个,或者选择一个: @@ -52,6 +62,16 @@ 正在停止 Aspire AppHost... + + The {0} and {1} options cannot be used together. + The {0} and {1} options cannot be used together. + + + + The {0} option cannot be used with a resource argument. + The {0} option cannot be used with a resource argument. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hant.xlf index 1cb21ecb1f2..61441b9ab59 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hant.xlf @@ -2,6 +2,11 @@ + + Stop all running AppHosts. + Stop all running AppHosts. + + AppHost stopped successfully. 已成功停止 AppHost。 @@ -17,6 +22,11 @@ 無法停止 AppHost。 + + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + Multiple AppHosts are running. Use {0} to specify which one to stop, or use {1} to stop all of them. + + Multiple AppHosts are running. Use --project to specify which one to stop, or select one: 多個 AppHost 正在執行。請使用 --project 指定要停止的 AppHost,或從中選取一個: @@ -52,6 +62,16 @@ 正在停止 Aspire AppHost... + + The {0} and {1} options cannot be used together. + The {0} and {1} options cannot be used together. + + + + The {0} option cannot be used with a resource argument. + The {0} option cannot be used with a resource argument. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Utils/CliHostEnvironment.cs b/src/Aspire.Cli/Utils/CliHostEnvironment.cs index 8b03db5e69e..ffd66f43209 100644 --- a/src/Aspire.Cli/Utils/CliHostEnvironment.cs +++ b/src/Aspire.Cli/Utils/CliHostEnvironment.cs @@ -67,23 +67,21 @@ internal sealed class CliHostEnvironment : ICliHostEnvironment public CliHostEnvironment(IConfiguration configuration, bool nonInteractive) { + // If --non-interactive is explicitly set, disable interactive input and output. + // This takes precedence over all other settings including ASPIRE_PLAYGROUND. + if (nonInteractive) + { + SupportsInteractiveInput = false; + SupportsInteractiveOutput = false; + SupportsAnsi = DetectAnsiSupport(configuration); + } // Check if ASPIRE_PLAYGROUND is set to force interactive mode - var playgroundMode = IsPlaygroundMode(configuration); - - // If ASPIRE_PLAYGROUND is set, force interactive mode and ANSI support - if (playgroundMode) + else if (IsPlaygroundMode(configuration)) { SupportsInteractiveInput = true; SupportsInteractiveOutput = true; SupportsAnsi = true; } - // If --non-interactive is explicitly set, disable interactive input and output - else if (nonInteractive) - { - SupportsInteractiveInput = false; - SupportsInteractiveOutput = false; - SupportsAnsi = DetectAnsiSupport(configuration); - } else { SupportsInteractiveInput = DetectInteractiveInput(configuration); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index 9f8b7c9fe96..a58de694b39 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -218,6 +218,31 @@ internal static Hex1bTerminalInputSequenceBuilder WaitForSuccessPrompt( .IncrementSequence(counter); } + /// + /// Waits for any prompt (success or error) matching the current sequence counter. + /// Use this when the command is expected to return a non-zero exit code. + /// + internal static Hex1bTerminalInputSequenceBuilder WaitForAnyPrompt( + this Hex1bTerminalInputSequenceBuilder builder, + SequenceCounter counter, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); + + return builder.WaitUntil(snapshot => + { + var successSearcher = new CellPatternSearcher() + .FindPattern(counter.Value.ToString()) + .RightText(" OK] $ "); + var errorSearcher = new CellPatternSearcher() + .FindPattern(counter.Value.ToString()) + .RightText(" ERR:"); + + return successSearcher.Search(snapshot).Count > 0 || errorSearcher.Search(snapshot).Count > 0; + }, effectiveTimeout) + .IncrementSequence(counter); + } + internal static Hex1bTerminalInputSequenceBuilder IncrementSequence( this Hex1bTerminalInputSequenceBuilder builder, SequenceCounter counter) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs new file mode 100644 index 00000000000..a4e3dab3fff --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs @@ -0,0 +1,595 @@ +// 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; +using Aspire.Cli.Tests.Utils; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end tests for aspire stop in non-interactive mode. +/// Validates fix for https://github.com/dotnet/aspire/issues/14558. +/// +public sealed class StopNonInteractiveTests(ITestOutputHelper output) +{ + [Fact] + public async Task StopNonInteractiveSingleAppHost() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StopNonInteractiveSingleAppHost)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // Pattern searchers for aspire new prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find($"Enter the output path: (./TestStopApp): "); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find($"Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find($"Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find($"Do you want to create a test project?"); + + var waitForProjectCreatedSuccessfullyMessage = new CellPatternSearcher() + .Find("Project created successfully."); + + // Pattern searchers for start/stop commands + var waitForAppHostStartedSuccessfully = new CellPatternSearcher() + .Find("AppHost started successfully."); + + var waitForAppHostStoppedSuccessfully = new CellPatternSearcher() + .Find("AppHost stopped successfully."); + + var waitForNoRunningAppHostsFound = new CellPatternSearcher() + .Find("No running AppHosts found"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Create a new project using aspire new + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() // select first template (Starter App) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Type("TestStopApp") + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter); + + // Navigate to the AppHost directory + sequenceBuilder.Type("cd TestStopApp/TestStopApp.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Start the AppHost in the background using aspire run --detach + sequenceBuilder.Type("aspire run --detach") + .Enter() + .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Clear screen to avoid matching old patterns + sequenceBuilder.ClearScreen(counter); + + // Stop the AppHost using aspire stop --non-interactive --project (targets specific AppHost) + sequenceBuilder.Type("aspire stop --non-interactive --project TestStopApp.AppHost.csproj") + .Enter() + .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1)) + .WaitForSuccessPrompt(counter); + + // Clear screen + sequenceBuilder.ClearScreen(counter); + + // Verify that stop --non-interactive handles no running AppHosts gracefully + sequenceBuilder.Type("aspire stop --non-interactive") + .Enter() + .WaitUntil(s => waitForNoRunningAppHostsFound.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .WaitForAnyPrompt(counter, TimeSpan.FromSeconds(30)); + + // Exit the shell + sequenceBuilder.Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } + + [Fact] + public async Task StopAllAppHostsFromAppHostDirectory() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StopAllAppHostsFromAppHostDirectory)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // Pattern searchers for aspire new prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt1 = new CellPatternSearcher() + .Find($"Enter the output path: (./App1): "); + + var waitingForOutputPathPrompt2 = new CellPatternSearcher() + .Find($"Enter the output path: (./App2): "); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find($"Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find($"Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find($"Do you want to create a test project?"); + + // Pattern searchers for start/stop commands + var waitForAppHostStartedSuccessfully = new CellPatternSearcher() + .Find("AppHost started successfully."); + + var waitForAppHostStoppedSuccessfully = new CellPatternSearcher() + .Find("AppHost stopped successfully."); + + var waitForNoRunningAppHostsFound = new CellPatternSearcher() + .Find("No running AppHosts found"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Create first project + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Type("App1") + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt1.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter); + + // Clear screen before second project creation + sequenceBuilder.ClearScreen(counter); + + // Create second project + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Type("App2") + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt2.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter); + + // Start first AppHost in background + sequenceBuilder.Type("cd App1/App1.AppHost && aspire run --detach") + .Enter() + .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Clear screen before starting second apphost + sequenceBuilder.ClearScreen(counter); + + // Navigate back and start second AppHost in background + sequenceBuilder.Type("cd ../../App2/App2.AppHost && aspire run --detach") + .Enter() + .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Clear screen + sequenceBuilder.ClearScreen(counter); + + // Stop all AppHosts from within an AppHost directory using --non-interactive --all + sequenceBuilder.Type("aspire stop --non-interactive --all") + .Enter() + .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1)) + .WaitForSuccessPrompt(counter); + + // Clear screen + sequenceBuilder.ClearScreen(counter); + + // Verify no AppHosts are running + sequenceBuilder.Type("aspire stop --non-interactive") + .Enter() + .WaitUntil(s => waitForNoRunningAppHostsFound.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .WaitForAnyPrompt(counter, TimeSpan.FromSeconds(30)); + + // Exit the shell + sequenceBuilder.Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } + + [Fact] + public async Task StopAllAppHostsFromUnrelatedDirectory() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StopAllAppHostsFromUnrelatedDirectory)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // Pattern searchers for aspire new prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt1 = new CellPatternSearcher() + .Find($"Enter the output path: (./App1): "); + + var waitingForOutputPathPrompt2 = new CellPatternSearcher() + .Find($"Enter the output path: (./App2): "); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find($"Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find($"Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find($"Do you want to create a test project?"); + + // Pattern searchers for start/stop commands + var waitForAppHostStartedSuccessfully = new CellPatternSearcher() + .Find("AppHost started successfully."); + + var waitForAppHostStoppedSuccessfully = new CellPatternSearcher() + .Find("AppHost stopped successfully."); + + var waitForNoRunningAppHostsFound = new CellPatternSearcher() + .Find("No running AppHosts found"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Create first project + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Type("App1") + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt1.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter); + + // Clear screen before second project creation + sequenceBuilder.ClearScreen(counter); + + // Create second project + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Type("App2") + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt2.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter); + + // Start first AppHost in background + sequenceBuilder.Type("cd App1/App1.AppHost && aspire run --detach") + .Enter() + .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Clear screen before starting second apphost + sequenceBuilder.ClearScreen(counter); + + // Navigate back and start second AppHost in background + sequenceBuilder.Type("cd ../../App2/App2.AppHost && aspire run --detach") + .Enter() + .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Navigate to workspace root (unrelated to any AppHost directory) + sequenceBuilder.Type($"cd {workspace.WorkspaceRoot.FullName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Clear screen + sequenceBuilder.ClearScreen(counter); + + // Stop all AppHosts from an unrelated directory using --non-interactive --all + sequenceBuilder.Type("aspire stop --non-interactive --all") + .Enter() + .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1)) + .WaitForSuccessPrompt(counter); + + // Clear screen + sequenceBuilder.ClearScreen(counter); + + // Verify no AppHosts are running + sequenceBuilder.Type("aspire stop --non-interactive") + .Enter() + .WaitUntil(s => waitForNoRunningAppHostsFound.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .WaitForAnyPrompt(counter, TimeSpan.FromSeconds(30)); + + // Exit the shell + sequenceBuilder.Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } + + [Fact] + public async Task StopNonInteractiveMultipleAppHostsShowsError() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StopNonInteractiveMultipleAppHostsShowsError)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // Pattern searchers for aspire new prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + // First project prompts + var waitingForProjectNamePrompt1 = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt1 = new CellPatternSearcher() + .Find($"Enter the output path: (./App1): "); + + // Second project prompts + var waitingForProjectNamePrompt2 = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt2 = new CellPatternSearcher() + .Find($"Enter the output path: (./App2): "); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find($"Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find($"Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find($"Do you want to create a test project?"); + + // Pattern searchers for start/stop commands + var waitForAppHostStartedSuccessfully = new CellPatternSearcher() + .Find("AppHost started successfully."); + + var waitForMultipleAppHostsError = new CellPatternSearcher() + .Find("Multiple AppHosts are running"); + + var waitForAppHostStoppedSuccessfully = new CellPatternSearcher() + .Find("AppHost stopped successfully."); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Create first project + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() + .WaitUntil(s => waitingForProjectNamePrompt1.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Type("App1") + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt1.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter); + + // Clear screen before second project creation + sequenceBuilder.ClearScreen(counter); + + // Create second project + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() + .WaitUntil(s => waitingForProjectNamePrompt2.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Type("App2") + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt2.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter); + + // Start first AppHost in background + sequenceBuilder.Type("cd App1/App1.AppHost && aspire run --detach") + .Enter() + .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Clear screen before starting second apphost + sequenceBuilder.ClearScreen(counter); + + // Navigate back and start second AppHost in background + sequenceBuilder.Type("cd ../../App2/App2.AppHost && aspire run --detach") + .Enter() + .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Navigate to workspace root + sequenceBuilder.Type($"cd {workspace.WorkspaceRoot.FullName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Clear screen + sequenceBuilder.ClearScreen(counter); + + // Try to stop in non-interactive mode - should get an error about multiple AppHosts + sequenceBuilder.Type("aspire stop --non-interactive") + .Enter() + .WaitUntil(s => waitForMultipleAppHostsError.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .WaitForAnyPrompt(counter, TimeSpan.FromSeconds(30)); + + // Clear screen + sequenceBuilder.ClearScreen(counter); + + // Now use --all to stop all AppHosts + sequenceBuilder.Type("aspire stop --all") + .Enter() + .WaitUntil(s => waitForAppHostStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1)) + .WaitForSuccessPrompt(counter); + + // Exit the shell + sequenceBuilder.Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliHostEnvironmentTests.cs b/tests/Aspire.Cli.Tests/Utils/CliHostEnvironmentTests.cs index 2710a491e99..33d27a65c0e 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliHostEnvironmentTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliHostEnvironmentTests.cs @@ -285,9 +285,9 @@ public void SupportsInteractiveOutput_ReturnsTrue_WhenPlaygroundModeSet_EvenInCI } [Fact] - public void SupportsInteractiveInput_ReturnsTrue_WhenPlaygroundModeSet_ButNonInteractiveIsTrue() + public void SupportsInteractiveInput_ReturnsFalse_WhenNonInteractiveIsTrue_EvenWithPlaygroundMode() { - // Arrange - ASPIRE_PLAYGROUND should take precedence over --non-interactive flag + // Arrange - --non-interactive should take precedence over ASPIRE_PLAYGROUND var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { @@ -299,14 +299,14 @@ public void SupportsInteractiveInput_ReturnsTrue_WhenPlaygroundModeSet_ButNonInt var env = new CliHostEnvironment(configuration, nonInteractive: true); // Assert - // ASPIRE_PLAYGROUND takes precedence over the --non-interactive flag - Assert.True(env.SupportsInteractiveInput); + // --non-interactive takes precedence over ASPIRE_PLAYGROUND + Assert.False(env.SupportsInteractiveInput); } [Fact] - public void SupportsInteractiveOutput_ReturnsTrue_WhenPlaygroundModeSet_ButNonInteractiveIsTrue() + public void SupportsInteractiveOutput_ReturnsFalse_WhenNonInteractiveIsTrue_EvenWithPlaygroundMode() { - // Arrange - ASPIRE_PLAYGROUND should take precedence over --non-interactive flag + // Arrange - --non-interactive should take precedence over ASPIRE_PLAYGROUND var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { @@ -318,8 +318,8 @@ public void SupportsInteractiveOutput_ReturnsTrue_WhenPlaygroundModeSet_ButNonIn var env = new CliHostEnvironment(configuration, nonInteractive: true); // Assert - // ASPIRE_PLAYGROUND takes precedence over the --non-interactive flag - Assert.True(env.SupportsInteractiveOutput); + // --non-interactive takes precedence over ASPIRE_PLAYGROUND + Assert.False(env.SupportsInteractiveOutput); } [Fact] From faebb8c3940bd64e2bc93e8b27ce2dbd0fc764c3 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 23 Feb 2026 14:40:59 +1100 Subject: [PATCH 134/256] Stop command exits with 0 when no AppHost is running (#14570) * Stop command exits with 0 when no AppHost is running When running 'aspire stop' and no AppHost is running, the command now returns exit code 0 and displays an informational message instead of exit code 7 (FailedToFindProject) with an error message. This aligns with the expected behavior that stopping nothing is not an error. Fixes part of https://github.com/dotnet/aspire/issues/14238 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add E2E test for stop command with no running AppHost Validates that 'aspire stop' exits with code 0 and shows an informational message when no AppHost is running, rather than returning an error exit code. 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/StopCommand.cs | 4 +- .../StartStopTests.cs | 53 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Commands/StopCommand.cs b/src/Aspire.Cli/Commands/StopCommand.cs index 3fd69c8cd78..f74f1ae9a62 100644 --- a/src/Aspire.Cli/Commands/StopCommand.cs +++ b/src/Aspire.Cli/Commands/StopCommand.cs @@ -156,8 +156,8 @@ private async Task ExecuteInteractiveAsync(FileInfo? passedAppHostProjectFi if (!result.Success) { - _interactionService.DisplayError(result.ErrorMessage ?? StopCommandStrings.NoRunningAppHostsFound); - return ExitCodeConstants.FailedToFindProject; + _interactionService.DisplayMessage("information", StopCommandStrings.NoRunningAppHostsFound); + return ExitCodeConstants.Success; } var selectedConnection = result.Connection!; diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs index 371eab030c2..1838bff7230 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs @@ -121,4 +121,57 @@ public async Task CreateStartAndStopAspireProject() await pendingRun; } + + [Fact] + public async Task StopWithNoRunningAppHostExitsSuccessfully() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StopWithNoRunningAppHostExitsSuccessfully)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // Pattern searcher for the informational message (not an error) + var waitForNoRunningAppHosts = new CellPatternSearcher() + .Find("No running AppHosts found in scope."); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Run aspire stop with no running AppHost - should exit with code 0 + sequenceBuilder.Type("aspire stop") + .Enter() + .WaitUntil(s => waitForNoRunningAppHosts.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .WaitForSuccessPrompt(counter); + + // Exit the shell + sequenceBuilder.Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } } From ad197566765a4f810a73917f62b0896b186e2111 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 23 Feb 2026 11:54:35 +0800 Subject: [PATCH 135/256] Centralize some E2E test code (#14369) --- .github/skills/cli-e2e-testing/SKILL.md | 9 +- .../skills/deployment-e2e-testing/SKILL.md | 8 +- .../AgentCommandTests.cs | 34 +---- .../Aspire.Cli.EndToEnd.Tests.csproj | 1 + .../Aspire.Cli.EndToEnd.Tests/BannerTests.cs | 26 +--- .../BundleSmokeTests.cs | 11 +- .../CentralPackageManagementTests.cs | 11 +- .../DockerDeploymentTests.cs | 21 +-- .../DoctorCommandTests.cs | 23 +-- .../EmptyAppHostTemplateTests.cs | 11 +- .../Aspire.Cli.EndToEnd.Tests/GlobalUsings.cs | 4 + .../Helpers/CliE2ETestHelpers.cs | 81 ++--------- .../Helpers/SequenceCounter.cs | 14 -- .../JsReactTemplateTests.cs | 11 +- .../KubernetesPublishTests.cs | 10 +- .../LogsCommandTests.cs | 11 +- .../PsCommandTests.cs | 11 +- .../PythonReactTemplateTests.cs | 11 +- .../ResourcesCommandTests.cs | 11 +- tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs | 11 +- .../StagingChannelTests.cs | 11 +- .../StartStopTests.cs | 11 +- .../TypeScriptPolyglotTests.cs | 11 +- .../WaitCommandTests.cs | 11 +- .../AcaCompactNamingDeploymentTests.cs | 10 +- .../AcaCompactNamingUpgradeDeploymentTests.cs | 10 +- .../AcaCustomRegistryDeploymentTests.cs | 10 +- .../AcaExistingRegistryDeploymentTests.cs | 10 +- .../AcaStarterDeploymentTests.cs | 10 +- .../AksStarterDeploymentTests.cs | 10 +- .../AksStarterWithRedisDeploymentTests.cs | 10 +- .../AppServicePythonDeploymentTests.cs | 10 +- .../AppServiceReactDeploymentTests.cs | 10 +- .../Aspire.Deployment.EndToEnd.Tests.csproj | 1 + .../AzureAppConfigDeploymentTests.cs | 10 +- .../AzureContainerRegistryDeploymentTests.cs | 10 +- .../AzureEventHubsDeploymentTests.cs | 10 +- .../AzureKeyVaultDeploymentTests.cs | 10 +- .../AzureLogAnalyticsDeploymentTests.cs | 10 +- .../AzureServiceBusDeploymentTests.cs | 10 +- .../AzureStorageDeploymentTests.cs | 10 +- .../GlobalUsings.cs | 4 + .../Helpers/DeploymentE2ETestHelpers.cs | 79 +++------- .../Helpers/SequenceCounter.cs | 17 --- .../PythonFastApiDeploymentTests.cs | 10 +- ...VnetKeyVaultConnectivityDeploymentTests.cs | 10 +- .../VnetKeyVaultInfraDeploymentTests.cs | 10 +- ...netSqlServerConnectivityDeploymentTests.cs | 10 +- .../VnetSqlServerInfraDeploymentTests.cs | 10 +- ...tStorageBlobConnectivityDeploymentTests.cs | 10 +- .../VnetStorageBlobInfraDeploymentTests.cs | 10 +- tests/Shared/Hex1bTestHelpers.cs | 135 ++++++++++++++++++ 52 files changed, 225 insertions(+), 615 deletions(-) create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/GlobalUsings.cs delete mode 100644 tests/Aspire.Cli.EndToEnd.Tests/Helpers/SequenceCounter.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/GlobalUsings.cs delete mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/Helpers/SequenceCounter.cs create mode 100644 tests/Shared/Hex1bTestHelpers.cs diff --git a/.github/skills/cli-e2e-testing/SKILL.md b/.github/skills/cli-e2e-testing/SKILL.md index 02050f28783..8234bee8e3f 100644 --- a/.github/skills/cli-e2e-testing/SKILL.md +++ b/.github/skills/cli-e2e-testing/SKILL.md @@ -57,14 +57,8 @@ public sealed class SmokeTests : IAsyncDisposable var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(MyCliTest)); - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -275,7 +269,6 @@ Use `CliE2ETestHelpers` for CI environment variables: var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); // GITHUB_PR_NUMBER (0 when local) var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); // GITHUB_PR_HEAD_SHA ("local0000" when local) var isCI = CliE2ETestHelpers.IsRunningInCI; // true when both env vars set -var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath("test-name"); // Appropriate path for CI vs local ``` ## DON'T: Use Hard-coded Delays diff --git a/.github/skills/deployment-e2e-testing/SKILL.md b/.github/skills/deployment-e2e-testing/SKILL.md index 9784d408019..71687976658 100644 --- a/.github/skills/deployment-e2e-testing/SKILL.md +++ b/.github/skills/deployment-e2e-testing/SKILL.md @@ -83,18 +83,12 @@ public sealed class MyDeploymentTests(ITestOutputHelper output) // 2. Setup var resourceGroupName = AzureAuthenticationHelpers.GenerateResourceGroupName("my-scenario"); var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployMyScenario)); var startTime = DateTime.UtcNow; try { // 3. Build terminal and run deployment - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs index f3081afc8ac..633d3948da5 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -31,16 +30,7 @@ public async Task AgentCommands_AllHelpOutputs_AreCorrect() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath( - nameof(AgentCommands_AllHelpOutputs_AreCorrect)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -136,16 +126,7 @@ public async Task AgentInitCommand_MigratesDeprecatedConfig() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath( - nameof(AgentInitCommand_MigratesDeprecatedConfig)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -255,16 +236,7 @@ public async Task DoctorCommand_DetectsDeprecatedAgentConfig() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath( - nameof(DoctorCommand_DetectsDeprecatedAgentConfig)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj b/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj index 1e4801674a3..5bd4cf8e073 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj +++ b/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj @@ -50,6 +50,7 @@ + diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs index 8a2f19ed57d..5bbd4a2048e 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,8 @@ public async Task Banner_DisplayedOnFirstRun() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(Banner_DisplayedOnFirstRun)); - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -94,14 +86,8 @@ public async Task Banner_DisplayedWithExplicitFlag() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(Banner_DisplayedWithExplicitFlag)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -160,14 +146,8 @@ public async Task Banner_NotDisplayedWithNoLogoFlag() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(Banner_NotDisplayedWithNoLogoFlag)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs index 2a0bceb3862..b628c6defd0 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -24,15 +23,7 @@ public async Task CreateAndRunAspireStarterProjectWithBundle() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndRunAspireStarterProjectWithBundle)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs index 069baeae379..28551963c5f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -24,15 +23,7 @@ public async Task AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesP var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesProps)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs index a61ac5e0133..ea6cf828cab 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -26,15 +25,7 @@ public async Task CreateAndDeployToDockerCompose() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndDeployToDockerCompose)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -203,15 +194,7 @@ public async Task CreateAndDeployToDockerComposeInteractive() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndDeployToDockerComposeInteractive)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs index a9c51496d59..98bfa6bd535 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,16 +22,7 @@ public async Task DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath( - nameof(DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -87,16 +77,7 @@ public async Task DoctorCommand_WithSslCertDir_ShowsTrusted() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath( - nameof(DoctorCommand_WithSslCertDir_ShowsTrusted)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs index 384165d596e..de7aaf05c8c 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task CreateEmptyAppHostProject() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateEmptyAppHostProject)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/GlobalUsings.cs b/tests/Aspire.Cli.EndToEnd.Tests/GlobalUsings.cs new file mode 100644 index 00000000000..75730f250c7 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/GlobalUsings.cs @@ -0,0 +1,4 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +global using Aspire.Tests.Shared; diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index 9f8b7c9fe96..99c1c7a0a18 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.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. -#pragma warning disable IDE0005 // Incorrectly flagged as unused due to types spread across namespaces +using System.Runtime.CompilerServices; using Aspire.Cli.Tests.Utils; +using Hex1b; using Hex1b.Automation; -#pragma warning restore IDE0005 using Xunit; namespace Aspire.Cli.EndToEnd.Tests.Helpers; @@ -69,22 +69,20 @@ internal static string GetRequiredCommitSha() /// The full path to the .cast recording file. internal static string GetTestResultsRecordingPath(string testName) { - var githubWorkspace = Environment.GetEnvironmentVariable("GITHUB_WORKSPACE"); - string recordingsDir; - - if (!string.IsNullOrEmpty(githubWorkspace)) - { - // CI environment - write directly to test results for artifact upload - recordingsDir = Path.Combine(githubWorkspace, "testresults", "recordings"); - } - else - { - // Local development - use temp directory - recordingsDir = Path.Combine(Path.GetTempPath(), "aspire-cli-e2e", "recordings"); - } + return Hex1bTestHelpers.GetTestResultsRecordingPath(testName, "aspire-cli-e2e"); + } - Directory.CreateDirectory(recordingsDir); - return Path.Combine(recordingsDir, $"{testName}.cast"); + /// + /// Creates a headless Hex1b terminal configured for E2E testing with asciinema recording. + /// Uses default dimensions of 160x48 unless overridden. + /// + /// The test name used for the recording file path. Defaults to the calling method name. + /// The terminal width in columns. Defaults to 160. + /// The terminal height in rows. Defaults to 48. + /// A configured instance. Caller is responsible for disposal. + internal static Hex1bTerminal CreateTestTerminal(int width = 160, int height = 48, [CallerMemberName] string testName = "") + { + return Hex1bTestHelpers.CreateTestTerminal("aspire-cli-e2e", width, height, testName); } internal static Hex1bTerminalInputSequenceBuilder PrepareEnvironment( @@ -199,55 +197,6 @@ internal static Hex1bTerminalInputSequenceBuilder VerifyAspireCliVersion( .WaitForSuccessPrompt(counter); } - internal static Hex1bTerminalInputSequenceBuilder WaitForSuccessPrompt( - this Hex1bTerminalInputSequenceBuilder builder, - SequenceCounter counter, - TimeSpan? timeout = null) - { - var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); - - return builder.WaitUntil(snapshot => - { - var successPromptSearcher = new CellPatternSearcher() - .FindPattern(counter.Value.ToString()) - .RightText(" OK] $ "); - - var result = successPromptSearcher.Search(snapshot); - return result.Count > 0; - }, effectiveTimeout) - .IncrementSequence(counter); - } - - internal static Hex1bTerminalInputSequenceBuilder IncrementSequence( - this Hex1bTerminalInputSequenceBuilder builder, - SequenceCounter counter) - { - return builder.WaitUntil(s => - { - // Hack to pump the counter fluently. - counter.Increment(); - return true; - }, TimeSpan.FromSeconds(1)); - } - - /// - /// Executes an arbitrary callback action during the sequence execution. - /// This is useful for performing file modifications or other side effects between terminal commands. - /// - /// The sequence builder. - /// The callback action to execute. - /// The builder for chaining. - internal static Hex1bTerminalInputSequenceBuilder ExecuteCallback( - this Hex1bTerminalInputSequenceBuilder builder, - Action callback) - { - return builder.WaitUntil(s => - { - callback(); - return true; - }, TimeSpan.FromSeconds(1)); - } - /// /// Enables polyglot support feature flag using the aspire config set command. /// This allows the CLI to create TypeScript and Python AppHosts. diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SequenceCounter.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SequenceCounter.cs deleted file mode 100644 index 3582322067d..00000000000 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SequenceCounter.cs +++ /dev/null @@ -1,14 +0,0 @@ -// 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; - -public class SequenceCounter -{ - public int Value { get; private set; } = 1; - - public int Increment() - { - return ++Value; - } -} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs index 978af50c2c4..f0c5eef3a52 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task CreateAndRunJsReactProject() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndRunJsReactProject)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs index 42b57f0ecb4..e39899f2b5d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -34,20 +33,13 @@ public async Task CreateAndPublishToKubernetes() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndPublishToKubernetes)); var clusterName = GenerateUniqueClusterName(); output.WriteLine($"Using KinD version: {KindVersion}"); output.WriteLine($"Using Helm version: {HelmVersion}"); output.WriteLine($"Using cluster name: {clusterName}"); - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs index 30ffe3adabd..680fe95fd2b 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task LogsCommandShowsResourceLogs() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(LogsCommandShowsResourceLogs)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs index 78a98c7d392..4d7e422e26f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task PsCommandListsRunningAppHost() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(PsCommandListsRunningAppHost)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs index 684bab355f8..683afa46241 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task CreateAndRunPythonReactProject() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndRunPythonReactProject)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ResourcesCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ResourcesCommandTests.cs index 41edab5154e..1a257d051d6 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ResourcesCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ResourcesCommandTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task ResourcesCommandShowsRunningResources() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(ResourcesCommandShowsRunningResources)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs index e85f579a1c4..7903092841d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task CreateAndRunAspireStarterProject() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndRunAspireStarterProject)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs index 14498306c9d..eb5a9b163bc 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -24,15 +23,7 @@ public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs index 371eab030c2..4c0702aec6e 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task CreateStartAndStopAspireProject() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateStartAndStopAspireProject)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs index 348df5ee732..46a6576354e 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task CreateTypeScriptAppHostWithViteApp() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateTypeScriptAppHostWithViteApp)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs index e0fab0a036b..1c6e3bbd301 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task CreateStartWaitAndStopAspireProject() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateStartWaitAndStopAspireProject)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs index b8945597d64..074a3e04df7 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -54,7 +53,6 @@ private async Task DeployWithCompactNamingFixesStorageCollisionCore(Cancellation } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployWithCompactNamingFixesStorageCollision)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("compact"); @@ -65,13 +63,7 @@ private async Task DeployWithCompactNamingFixesStorageCollisionCore(Cancellation try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var waitingForInitComplete = new CellPatternSearcher() diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs index c0f39d3175c..0cfc7290cca 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -53,7 +52,6 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(UpgradeFromGaToDevDoesNotDuplicateStorageAccounts)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("upgrade"); @@ -64,13 +62,7 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var waitingForInitComplete = new CellPatternSearcher() diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs index 9f89389e4a2..0e74570250f 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -52,7 +51,6 @@ private async Task DeployStarterTemplateWithCustomRegistryCore(CancellationToken } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithCustomRegistry)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("aca-custom-acr"); @@ -66,13 +64,7 @@ private async Task DeployStarterTemplateWithCustomRegistryCore(CancellationToken try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire new interactive prompts diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs index b93678ac62b..f16553ca300 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployStarterTemplateWithExistingRegistryCore(CancellationTok } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithExistingRegistry)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("aca-existing-acr"); @@ -76,13 +74,7 @@ private async Task DeployStarterTemplateWithExistingRegistryCore(CancellationTok try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire new interactive prompts diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs index 6f7488c993f..5bf5a2c7af1 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateToAzureContainerApps)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt] @@ -67,13 +65,7 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire new interactive prompts diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index fc21e5ccfc0..2beebd9a6b7 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -50,7 +49,6 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateToAks)); var startTime = DateTime.UtcNow; // Generate unique names for Azure resources @@ -74,13 +72,7 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var counter = new SequenceCounter(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs index f597e557da7..236cd5672d5 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithRedisToAks)); var startTime = DateTime.UtcNow; // Generate unique names for Azure resources @@ -75,13 +73,7 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var counter = new SequenceCounter(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs index e4887306758..c15d910933f 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployPythonFastApiTemplateToAzureAppServiceCore(Cancellation } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployPythonFastApiTemplateToAzureAppService)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt] @@ -67,13 +65,7 @@ private async Task DeployPythonFastApiTemplateToAzureAppServiceCore(Cancellation try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire new interactive prompts diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs index cd0509f3b69..f006cd1475b 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployReactTemplateToAzureAppServiceCore(CancellationToken ca } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployReactTemplateToAzureAppService)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt] @@ -67,13 +65,7 @@ private async Task DeployReactTemplateToAzureAppServiceCore(CancellationToken ca try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire new interactive prompts diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj b/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj index 2c6d3eb61bf..9ce58253a7d 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj +++ b/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj @@ -50,6 +50,7 @@ + diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs index c89e886df2f..529e7cd22cb 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployAzureAppConfigResourceCore(CancellationToken cancellati } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureAppConfigResource)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("appconfig"); @@ -62,13 +60,7 @@ private async Task DeployAzureAppConfigResourceCore(CancellationToken cancellati try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire init diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs index b27221ad519..b387dcc5cf1 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployAzureContainerRegistryResourceCore(CancellationToken ca } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureContainerRegistryResource)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("acr"); @@ -62,13 +60,7 @@ private async Task DeployAzureContainerRegistryResourceCore(CancellationToken ca try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire init diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs index f551407bf2f..eaea7798804 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployAzureEventHubsResourceCore(CancellationToken cancellati } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureEventHubsResource)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("eventhubs"); @@ -62,13 +60,7 @@ private async Task DeployAzureEventHubsResourceCore(CancellationToken cancellati try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire init diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs index 035b280a7df..ec82296d081 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployAzureKeyVaultResourceCore(CancellationToken cancellatio } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureKeyVaultResource)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("keyvault"); @@ -62,13 +60,7 @@ private async Task DeployAzureKeyVaultResourceCore(CancellationToken cancellatio try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire init diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs index c0a14e97dfb..f26c51ce2b0 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployAzureLogAnalyticsResourceCore(CancellationToken cancell } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureLogAnalyticsResource)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("logs"); @@ -62,13 +60,7 @@ private async Task DeployAzureLogAnalyticsResourceCore(CancellationToken cancell try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire init diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs index c3083e8d9a4..0378cf537e9 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployAzureServiceBusResourceCore(CancellationToken cancellat } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureServiceBusResource)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("servicebus"); @@ -62,13 +60,7 @@ private async Task DeployAzureServiceBusResourceCore(CancellationToken cancellat try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire init diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs index 3c004591f74..d309d8337ad 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployAzureStorageResourceCore(CancellationToken cancellation } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureStorageResource)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("storage"); @@ -62,13 +60,7 @@ private async Task DeployAzureStorageResourceCore(CancellationToken cancellation try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire init diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/GlobalUsings.cs b/tests/Aspire.Deployment.EndToEnd.Tests/GlobalUsings.cs new file mode 100644 index 00000000000..75730f250c7 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/GlobalUsings.cs @@ -0,0 +1,4 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +global using Aspire.Tests.Shared; diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs index 905b001719b..9d1a305d9b0 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Runtime.CompilerServices; using Aspire.Cli.Tests.Utils; +using Hex1b; using Hex1b.Automation; namespace Aspire.Deployment.EndToEnd.Tests.Helpers; @@ -75,25 +77,25 @@ internal static string GenerateResourceGroupName(string testCaseName) return $"e2e-{testCaseName}-{runId}-{attempt}"; } + /// + /// Creates a headless Hex1b terminal configured for deployment E2E testing with asciinema recording. + /// Uses default dimensions of 160x48 unless overridden. + /// + /// The test name used for the recording file path. Defaults to the calling method name. + /// The terminal width in columns. Defaults to 160. + /// The terminal height in rows. Defaults to 48. + /// A configured instance. Caller is responsible for disposal. + internal static Hex1bTerminal CreateTestTerminal(int width = 160, int height = 48, [CallerMemberName] string testName = "") + { + return Hex1bTestHelpers.CreateTestTerminal("aspire-deployment-e2e", width, height, testName); + } + /// /// Gets the path for storing asciinema recordings that will be uploaded as CI artifacts. /// internal static string GetTestResultsRecordingPath(string testName) { - var githubWorkspace = Environment.GetEnvironmentVariable("GITHUB_WORKSPACE"); - string recordingsDir; - - if (!string.IsNullOrEmpty(githubWorkspace)) - { - recordingsDir = Path.Combine(githubWorkspace, "testresults", "recordings"); - } - else - { - recordingsDir = Path.Combine(Path.GetTempPath(), "aspire-deployment-e2e", "recordings"); - } - - Directory.CreateDirectory(recordingsDir); - return Path.Combine(recordingsDir, $"{testName}.cast"); + return Hex1bTestHelpers.GetTestResultsRecordingPath(testName, "aspire-deployment-e2e"); } /// @@ -172,53 +174,4 @@ internal static Hex1bTerminalInputSequenceBuilder SourceAspireCliEnvironment( .WaitForSuccessPrompt(counter); } - /// - /// Waits for a successful command prompt with the expected sequence number. - /// - internal static Hex1bTerminalInputSequenceBuilder WaitForSuccessPrompt( - this Hex1bTerminalInputSequenceBuilder builder, - SequenceCounter counter, - TimeSpan? timeout = null) - { - var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); - - return builder.WaitUntil(snapshot => - { - var successPromptSearcher = new CellPatternSearcher() - .FindPattern(counter.Value.ToString()) - .RightText(" OK] $ "); - - var result = successPromptSearcher.Search(snapshot); - return result.Count > 0; - }, effectiveTimeout) - .IncrementSequence(counter); - } - - /// - /// Increments the sequence counter. - /// - internal static Hex1bTerminalInputSequenceBuilder IncrementSequence( - this Hex1bTerminalInputSequenceBuilder builder, - SequenceCounter counter) - { - return builder.WaitUntil(s => - { - counter.Increment(); - return true; - }, TimeSpan.FromSeconds(1)); - } - - /// - /// Executes an arbitrary callback action during the sequence execution. - /// - internal static Hex1bTerminalInputSequenceBuilder ExecuteCallback( - this Hex1bTerminalInputSequenceBuilder builder, - Action callback) - { - return builder.WaitUntil(s => - { - callback(); - return true; - }, TimeSpan.FromSeconds(1)); - } } diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/SequenceCounter.cs b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/SequenceCounter.cs deleted file mode 100644 index 0fd8b5cc93c..00000000000 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/SequenceCounter.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Aspire.Deployment.EndToEnd.Tests.Helpers; - -/// -/// Tracks the sequence number for shell prompt detection in Hex1b terminal sessions. -/// -public class SequenceCounter -{ - public int Value { get; private set; } = 1; - - public int Increment() - { - return ++Value; - } -} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs index 71537a5f54e..3a7255a684c 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployPythonFastApiTemplateToAzureContainerAppsCore(Cancellat } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployPythonFastApiTemplateToAzureContainerApps)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt] @@ -67,13 +65,7 @@ private async Task DeployPythonFastApiTemplateToAzureContainerAppsCore(Cancellat try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire new interactive prompts diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs index 4971f8bf592..77e21948e90 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -49,7 +48,6 @@ private async Task DeployStarterTemplateWithKeyVaultPrivateEndpointCore(Cancella } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithKeyVaultPrivateEndpoint)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-kv-l23"); @@ -63,13 +61,7 @@ private async Task DeployStarterTemplateWithKeyVaultPrivateEndpointCore(Cancella try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var waitingForTemplateSelectionPrompt = new CellPatternSearcher() diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs index 6f85e18d287..13c82e0fd3c 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -49,7 +48,6 @@ private async Task DeployVnetKeyVaultInfrastructureCore(CancellationToken cancel } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployVnetKeyVaultInfrastructure)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-kv-l1"); @@ -60,13 +58,7 @@ private async Task DeployVnetKeyVaultInfrastructureCore(CancellationToken cancel try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var waitingForInitComplete = new CellPatternSearcher() diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs index 9616dc53e25..61b9a4d9846 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -50,7 +49,6 @@ private async Task DeployStarterTemplateWithSqlServerPrivateEndpointCore(Cancell } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithSqlServerPrivateEndpoint)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-sql-l23"); @@ -64,13 +62,7 @@ private async Task DeployStarterTemplateWithSqlServerPrivateEndpointCore(Cancell try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var waitingForTemplateSelectionPrompt = new CellPatternSearcher() diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs index 9f15d2288e1..badfc76ce63 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -49,7 +48,6 @@ private async Task DeployVnetSqlServerInfrastructureCore(CancellationToken cance } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployVnetSqlServerInfrastructure)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-sql-l1"); @@ -60,13 +58,7 @@ private async Task DeployVnetSqlServerInfrastructureCore(CancellationToken cance try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var waitingForInitComplete = new CellPatternSearcher() diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs index f06017ebb95..294de71c57c 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -49,7 +48,6 @@ private async Task DeployStarterTemplateWithStorageBlobPrivateEndpointCore(Cance } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithStorageBlobPrivateEndpoint)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-blob-l23"); @@ -63,13 +61,7 @@ private async Task DeployStarterTemplateWithStorageBlobPrivateEndpointCore(Cance try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire new interactive prompts diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs index 07346fde16d..9dcab71af68 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -49,7 +48,6 @@ private async Task DeployVnetStorageBlobInfrastructureCore(CancellationToken can } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployVnetStorageBlobInfrastructure)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-blob-l1"); @@ -60,13 +58,7 @@ private async Task DeployVnetStorageBlobInfrastructureCore(CancellationToken can try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var waitingForInitComplete = new CellPatternSearcher() diff --git a/tests/Shared/Hex1bTestHelpers.cs b/tests/Shared/Hex1bTestHelpers.cs new file mode 100644 index 00000000000..9e0d21aa26b --- /dev/null +++ b/tests/Shared/Hex1bTestHelpers.cs @@ -0,0 +1,135 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; +using Hex1b; +using Hex1b.Automation; + +namespace Aspire.Tests.Shared; + +/// +/// Tracks the sequence number for shell prompt detection in Hex1b terminal sessions. +/// +internal sealed class SequenceCounter +{ + public int Value { get; private set; } = 1; + + public int Increment() + { + return ++Value; + } +} + +/// +/// Shared helper methods for creating and managing Hex1b terminal sessions across E2E test projects. +/// +internal static class Hex1bTestHelpers +{ + /// + /// Creates a headless Hex1b terminal configured for E2E testing with asciinema recording. + /// Uses default dimensions of 160x48 unless overridden. + /// + /// The test name used for the recording file path. Defaults to the calling method name. + /// The subdirectory name under the temp folder for local (non-CI) recordings. + /// The terminal width in columns. Defaults to 160. + /// The terminal height in rows. Defaults to 48. + /// A configured instance. Caller is responsible for disposal. + internal static Hex1bTerminal CreateTestTerminal( + string localSubDir, + int width = 160, + int height = 48, + [CallerMemberName] string testName = "") + { + var recordingPath = GetTestResultsRecordingPath(testName, localSubDir); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(width, height) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + return builder.Build(); + } + + /// + /// Gets the path for storing asciinema recordings that will be uploaded as CI artifacts. + /// In CI, this returns a path under $GITHUB_WORKSPACE/testresults/recordings/. + /// Locally, this returns a path under the system temp directory. + /// + /// The name of the test (used as the recording filename). + /// The subdirectory name under the temp folder for local (non-CI) recordings. + /// The full path to the .cast recording file. + internal static string GetTestResultsRecordingPath(string testName, string localSubDir) + { + var githubWorkspace = Environment.GetEnvironmentVariable("GITHUB_WORKSPACE"); + string recordingsDir; + + if (!string.IsNullOrEmpty(githubWorkspace)) + { + // CI environment - write directly to test results for artifact upload + recordingsDir = Path.Combine(githubWorkspace, "testresults", "recordings"); + } + else + { + // Local development - use temp directory + recordingsDir = Path.Combine(Path.GetTempPath(), localSubDir, "recordings"); + } + + Directory.CreateDirectory(recordingsDir); + return Path.Combine(recordingsDir, $"{testName}.cast"); + } + + /// + /// Waits for a successful command prompt with the expected sequence number. + /// + internal static Hex1bTerminalInputSequenceBuilder WaitForSuccessPrompt( + this Hex1bTerminalInputSequenceBuilder builder, + SequenceCounter counter, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); + + return builder.WaitUntil(snapshot => + { + var successPromptSearcher = new CellPatternSearcher() + .FindPattern(counter.Value.ToString()) + .RightText(" OK] $ "); + + var result = successPromptSearcher.Search(snapshot); + return result.Count > 0; + }, effectiveTimeout) + .IncrementSequence(counter); + } + + /// + /// Increments the sequence counter. + /// + internal static Hex1bTerminalInputSequenceBuilder IncrementSequence( + this Hex1bTerminalInputSequenceBuilder builder, + SequenceCounter counter) + { + return builder.WaitUntil(s => + { + counter.Increment(); + return true; + }, TimeSpan.FromSeconds(1)); + } + + /// + /// Executes an arbitrary callback action during the sequence execution. + /// This is useful for performing file modifications or other side effects between terminal commands. + /// + /// The sequence builder. + /// The callback action to execute. + /// The builder for chaining. + internal static Hex1bTerminalInputSequenceBuilder ExecuteCallback( + this Hex1bTerminalInputSequenceBuilder builder, + Action callback) + { + return builder.WaitUntil(s => + { + callback(); + return true; + }, TimeSpan.FromSeconds(1)); + } +} From 535f8ebc557e2f3b8c97efe551d27a17fda950a7 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 23 Feb 2026 15:33:24 +1100 Subject: [PATCH 136/256] Fix AKS deployment test timeouts and ACR token expiration (#14591) * Fix AKS deployment test timeouts and ACR token expiration - Increase az aks update --attach-acr timeout from 3 to 5 minutes (ReconcilingAddons phase can take several minutes) - Increase kubectl wait pod readiness timeout from 120s to 300s (pods need time to pull images from ACR and start) - Add ACR re-login step after AKS creation to refresh Docker credentials that may have expired during 10-15 min provisioning (OIDC federated tokens expire after ~5 minutes) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix WaitForSuccessPrompt timeout for chained kubectl waits The Redis test chains 3 kubectl wait commands (300s each), so the worst-case total is 900s (15 min). Increase WaitForSuccessPrompt from 6 min to 16 min to provide adequate buffer. 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> --- .../AksStarterDeploymentTests.cs | 18 ++++++++++----- .../AksStarterWithRedisDeploymentTests.cs | 22 +++++++++++++------ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index fc21e5ccfc0..6f5a46903fb 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -165,11 +165,12 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(20)); // Step 6: Ensure AKS can pull from ACR (update attachment to ensure role propagation) + // ReconcilingAddons can take several minutes after role assignment updates output.WriteLine("Step 6: Verifying AKS-ACR integration..."); sequenceBuilder .Type($"az aks update --resource-group {resourceGroupName} --name {clusterName} --attach-acr {acrName}") .Enter() - .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); // Step 7: Configure kubectl credentials output.WriteLine("Step 7: Configuring kubectl credentials..."); @@ -283,8 +284,14 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .Enter() .WaitForSuccessPrompt(counter); - // Step 16: ACR login was already done in Step 4b (before AKS creation). - // Docker credentials persist in ~/.docker/config.json. + // Step 16: Re-login to ACR after AKS creation to refresh Docker credentials. + // The initial login (Step 4b) may have expired during the 10-15 min AKS provisioning + // because OIDC federated tokens have a short lifetime (~5 min). + output.WriteLine("Step 16: Refreshing ACR login..."); + sequenceBuilder + .Type($"az acr login --name {acrName}") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); // Step 17: Build and push container images to ACR // The starter template creates webfrontend and apiservice projects @@ -348,11 +355,12 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(12)); // Step 22: Wait for pods to be ready + // Pods may need time to pull images from ACR and start the application output.WriteLine("Step 22: Waiting for pods to be ready..."); sequenceBuilder - .Type("kubectl wait --for=condition=ready pod --all -n default --timeout=120s") + .Type("kubectl wait --for=condition=ready pod --all -n default --timeout=300s") .Enter() - .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(6)); // Step 23: Verify pods are running output.WriteLine("Step 23: Verifying pods are running..."); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs index f597e557da7..9c3b0332ff7 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs @@ -165,11 +165,12 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(20)); // Step 6: Ensure AKS can pull from ACR + // ReconcilingAddons can take several minutes after role assignment updates output.WriteLine("Step 6: Verifying AKS-ACR integration..."); sequenceBuilder .Type($"az aks update --resource-group {resourceGroupName} --name {clusterName} --attach-acr {acrName}") .Enter() - .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); // Step 7: Configure kubectl credentials output.WriteLine("Step 7: Configuring kubectl credentials..."); @@ -281,8 +282,14 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can .Enter() .WaitForSuccessPrompt(counter); - // Step 16: ACR login was already done in Step 4b (before AKS creation). - // Docker credentials persist in ~/.docker/config.json. + // Step 16: Re-login to ACR after AKS creation to refresh Docker credentials. + // The initial login (Step 4b) may have expired during the 10-15 min AKS provisioning + // because OIDC federated tokens have a short lifetime (~5 min). + output.WriteLine("Step 16: Refreshing ACR login..."); + sequenceBuilder + .Type($"az acr login --name {acrName}") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(60)); // Step 17: Build and push container images to ACR // Only project resources need to be built — Redis uses a public container image @@ -354,13 +361,14 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(12)); // Step 22: Wait for all pods to be ready (including Redis cache) + // Pods may need time to pull images from ACR and start the application output.WriteLine("Step 22: Waiting for all pods to be ready..."); sequenceBuilder - .Type("kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=apiservice --timeout=120s -n default && " + - "kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=webfrontend --timeout=120s -n default && " + - "kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=cache --timeout=120s -n default") + .Type("kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=apiservice --timeout=300s -n default && " + + "kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=webfrontend --timeout=300s -n default && " + + "kubectl wait --for=condition=ready pod -l app.kubernetes.io/component=cache --timeout=300s -n default") .Enter() - .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(3)); + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(16)); // Step 22b: Verify Redis container is running and stable (no restarts) output.WriteLine("Step 22b: Verifying Redis container is stable..."); From 439a56519802a99c97b8b2f684d73f294b848a0f Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Mon, 23 Feb 2026 16:38:52 +1100 Subject: [PATCH 137/256] Stop running AppHost before adding packages (#14573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stop running AppHost before adding packages When running 'aspire add' while an AppHost is running in detached mode, the project file is locked by the build server, causing 'dotnet add package' to fail. The add command now stops any running AppHost instance before attempting to add the package, using the same running instance detection pattern used by the run command. Fixes part of https://github.com/dotnet/aspire/issues/14238 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add E2E test for adding package while AppHost is running Validates that 'aspire add' succeeds when an AppHost is running in detached mode. The add command should automatically stop the running AppHost to release file locks before modifying the project. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix E2E test to handle version selection prompt The aspire add --non-interactive flag doesn't suppress the version selection prompt. Updated the test to wait for and accept the default version before continuing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR feedback and add interactive add E2E test - Gate CheckAndHandleRunningInstanceAsync behind KnownFeatures.RunningInstanceDetectionEnabled feature flag, matching RunCommand behavior - Check RunningInstanceResult.StopFailed return value and fail early with a clear error message instead of proceeding - Add E2E test for interactive aspire add flow (no integration argument) while AppHost is running in detached mode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix E2E test: accept any exit code from aspire stop cleanup The aspire add command now auto-stops the running AppHost, so the subsequent aspire stop cleanup command returns exit code 7 (no running instances found). Use a generic prompt pattern that accepts both OK and ERR exit codes for the cleanup step. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix E2E test: wait for stop output text instead of prompt pattern The RightText() method checks for immediately-adjacent text, but the ERR prompt has intermediate text between the counter and '] $'. Instead, wait for the known aspire stop output messages which correctly handles both 'no running instances' and 'stopped successfully' outcomes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename CheckAndHandleRunningInstanceAsync to FindAndStopRunningInstanceAsync Address PR feedback from JamesNK: the method name is more descriptive about what the method actually does — finds running instances and stops them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add info message when running instance is successfully stopped Addresses review feedback: log on both success and failure of stopping a running AppHost, not just failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Retrigger CI * Fix invalid Spectre emoji name in DisplayMessage Use 'information_source' instead of 'info' which is not a valid Spectre Console emoji name, causing garbled terminal output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix E2E test: --non-interactive skips version selection prompt The AddPackageWhileAppHostRunningDetached test was waiting for a version selection prompt that doesn't appear with --non-interactive flag. Remove the intermediate wait and directly wait for the package added success message. 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/AddCommand.cs | 21 ++ src/Aspire.Cli/Commands/RunCommand.cs | 2 +- .../Projects/DotNetAppHostProject.cs | 2 +- .../Projects/GuestAppHostProject.cs | 2 +- src/Aspire.Cli/Projects/IAppHostProject.cs | 6 +- .../Resources/AddCommandStrings.Designer.cs | 16 ++ .../Resources/AddCommandStrings.resx | 6 + .../Resources/xlf/AddCommandStrings.cs.xlf | 9 + .../Resources/xlf/AddCommandStrings.de.xlf | 9 + .../Resources/xlf/AddCommandStrings.es.xlf | 9 + .../Resources/xlf/AddCommandStrings.fr.xlf | 9 + .../Resources/xlf/AddCommandStrings.it.xlf | 9 + .../Resources/xlf/AddCommandStrings.ja.xlf | 9 + .../Resources/xlf/AddCommandStrings.ko.xlf | 9 + .../Resources/xlf/AddCommandStrings.pl.xlf | 9 + .../Resources/xlf/AddCommandStrings.pt-BR.xlf | 9 + .../Resources/xlf/AddCommandStrings.ru.xlf | 9 + .../Resources/xlf/AddCommandStrings.tr.xlf | 9 + .../xlf/AddCommandStrings.zh-Hans.xlf | 9 + .../xlf/AddCommandStrings.zh-Hant.xlf | 9 + .../StartStopTests.cs | 249 ++++++++++++++++++ .../TestServices/TestAppHostProjectFactory.cs | 2 +- 22 files changed, 416 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index c9682c1fb34..319f65f3ae3 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -208,6 +208,27 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => Source = source }; + // Stop any running AppHost instance before adding the package. + // A running AppHost (especially in detach mode) locks project files, + // which prevents 'dotnet add package' from modifying the project. + if (_features.IsFeatureEnabled(KnownFeatures.RunningInstanceDetectionEnabled, defaultValue: true)) + { + var runningInstanceResult = await project.FindAndStopRunningInstanceAsync( + effectiveAppHostProjectFile, + ExecutionContext.HomeDirectory, + cancellationToken); + + if (runningInstanceResult == RunningInstanceResult.InstanceStopped) + { + InteractionService.DisplayMessage("information_source", AddCommandStrings.StoppedRunningInstance); + } + else if (runningInstanceResult == RunningInstanceResult.StopFailed) + { + InteractionService.DisplayError(AddCommandStrings.UnableToStopRunningInstances); + return ExitCodeConstants.FailedToAddPackage; + } + } + var success = await InteractionService.ShowStatusAsync( AddCommandStrings.AddingAspireIntegration, async () => await project.AddPackageAsync(context, cancellationToken) diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 1157323f66d..cb752d79f70 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -228,7 +228,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Even if we fail to stop we won't block the apphost starting // to make sure we don't ever break flow. It should mostly stop // just fine though. - var runningInstanceResult = await project.CheckAndHandleRunningInstanceAsync(effectiveAppHostFile, ExecutionContext.HomeDirectory, cancellationToken); + var runningInstanceResult = await project.FindAndStopRunningInstanceAsync(effectiveAppHostFile, ExecutionContext.HomeDirectory, cancellationToken); // If in isolated mode and a running instance was stopped, warn the user if (isolated && runningInstanceResult == RunningInstanceResult.InstanceStopped) diff --git a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs index ea56782b2b0..51e36a16fd5 100644 --- a/src/Aspire.Cli/Projects/DotNetAppHostProject.cs +++ b/src/Aspire.Cli/Projects/DotNetAppHostProject.cs @@ -486,7 +486,7 @@ public async Task UpdatePackagesAsync(UpdatePackagesContex } /// - public async Task CheckAndHandleRunningInstanceAsync(FileInfo appHostFile, DirectoryInfo homeDirectory, CancellationToken cancellationToken) + public async Task FindAndStopRunningInstanceAsync(FileInfo appHostFile, DirectoryInfo homeDirectory, CancellationToken cancellationToken) { var matchingSockets = AppHostHelper.FindMatchingSockets(appHostFile.FullName, homeDirectory.FullName); diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 4fa1af18f8f..807754eb156 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -951,7 +951,7 @@ public async Task UpdatePackagesAsync(UpdatePackagesContex } /// - public async Task CheckAndHandleRunningInstanceAsync(FileInfo appHostFile, DirectoryInfo homeDirectory, CancellationToken cancellationToken) + public async Task FindAndStopRunningInstanceAsync(FileInfo appHostFile, DirectoryInfo homeDirectory, CancellationToken cancellationToken) { // For guest projects, we use the AppHost server's path to compute the socket path // The AppHost server is created in a subdirectory of the guest apphost directory diff --git a/src/Aspire.Cli/Projects/IAppHostProject.cs b/src/Aspire.Cli/Projects/IAppHostProject.cs index f2d7b59a1f8..1f2f67cb1a5 100644 --- a/src/Aspire.Cli/Projects/IAppHostProject.cs +++ b/src/Aspire.Cli/Projects/IAppHostProject.cs @@ -215,17 +215,17 @@ internal interface IAppHostProject Task UpdatePackagesAsync(UpdatePackagesContext context, CancellationToken cancellationToken); /// - /// Checks for and handles any running instance of this AppHost. + /// Finds any running instance of this AppHost and stops it. /// /// The AppHost file to check for running instances. /// The user's home directory for computing socket paths. /// A cancellation token. /// The result indicating what happened with the running instance check. - Task CheckAndHandleRunningInstanceAsync(FileInfo appHostFile, DirectoryInfo homeDirectory, CancellationToken cancellationToken); + Task FindAndStopRunningInstanceAsync(FileInfo appHostFile, DirectoryInfo homeDirectory, CancellationToken cancellationToken); } /// -/// Result of checking for and handling a running instance. +/// Result of finding and stopping a running instance. /// internal enum RunningInstanceResult { diff --git a/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs index d9ac607879f..9f5e8514b27 100644 --- a/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/AddCommandStrings.Designer.cs @@ -188,5 +188,21 @@ public static string UsePrereleasePackages return ResourceManager.GetString("UsePrereleasePackages", resourceCulture); } } + + public static string StoppedRunningInstance + { + get + { + return ResourceManager.GetString("StoppedRunningInstance", resourceCulture); + } + } + + public static string UnableToStopRunningInstances + { + get + { + return ResourceManager.GetString("UnableToStopRunningInstances", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/AddCommandStrings.resx b/src/Aspire.Cli/Resources/AddCommandStrings.resx index be4dc72e55e..fd33eb60df7 100644 --- a/src/Aspire.Cli/Resources/AddCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AddCommandStrings.resx @@ -172,4 +172,10 @@ Use pre-release packages + + Stopped a running Aspire AppHost instance to allow package modification. + + + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf index a04ad12acff..5036ddf9990 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf @@ -72,6 +72,15 @@ Zdroj NuGet, který se má použít pro integraci + + Stopped a running Aspire AppHost instance to allow package modification. + Stopped a running Aspire AppHost instance to allow package modification. + + + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + + Unexpected number of packages found. Byl nalezen neočekávaný počet balíčků. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf index 1f261ba44d4..59536adfd77 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf @@ -72,6 +72,15 @@ Die NuGet-Quelle, die für die Integration verwendet werden soll. + + Stopped a running Aspire AppHost instance to allow package modification. + Stopped a running Aspire AppHost instance to allow package modification. + + + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + + Unexpected number of packages found. Unerwartete Anzahl gefundener Pakete. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf index 750beed2d51..194e818c0a8 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf @@ -72,6 +72,15 @@ El origen de NuGet que se utilizará para la integración. + + Stopped a running Aspire AppHost instance to allow package modification. + Stopped a running Aspire AppHost instance to allow package modification. + + + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + + Unexpected number of packages found. Se encontró un número inesperado de paquetes. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf index 8ea37530273..8ff5b50ac4b 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf @@ -72,6 +72,15 @@ Source NuGet à utiliser pour l’intégration. + + Stopped a running Aspire AppHost instance to allow package modification. + Stopped a running Aspire AppHost instance to allow package modification. + + + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + + Unexpected number of packages found. Nombre inattendu de packages trouvés. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf index 5fddb87f242..c8ca4a9f65f 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf @@ -72,6 +72,15 @@ Origine NuGet da usare per l'integrazione. + + Stopped a running Aspire AppHost instance to allow package modification. + Stopped a running Aspire AppHost instance to allow package modification. + + + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + + Unexpected number of packages found. È stato trovato un numero imprevisto di pacchetti. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf index 16f473080e3..553faaeaf53 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf @@ -72,6 +72,15 @@ 統合に使用する NuGet ソース。 + + Stopped a running Aspire AppHost instance to allow package modification. + Stopped a running Aspire AppHost instance to allow package modification. + + + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + + Unexpected number of packages found. 予期しない数のパッケージが見つかりました。 diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf index 85e30c2b5e7..4b5250ba7bf 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf @@ -72,6 +72,15 @@ 통합에 사용할 NuGet 원본입니다. + + Stopped a running Aspire AppHost instance to allow package modification. + Stopped a running Aspire AppHost instance to allow package modification. + + + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + + Unexpected number of packages found. 예기치 않은 수의 패키지를 발견했습니다. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf index 8e4c9649b77..a9e812592b8 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf @@ -72,6 +72,15 @@ Źródło NuGet do użycia na potrzeby integracji. + + Stopped a running Aspire AppHost instance to allow package modification. + Stopped a running Aspire AppHost instance to allow package modification. + + + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + + Unexpected number of packages found. Znaleziono nieoczekiwaną liczbę pakietów. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf index dc788080830..e6b5a80f657 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf @@ -72,6 +72,15 @@ A fonte do NuGet a ser usada para a integração. + + Stopped a running Aspire AppHost instance to allow package modification. + Stopped a running Aspire AppHost instance to allow package modification. + + + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + + Unexpected number of packages found. Número inesperado de pacotes encontrados. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf index e7dc36b31d3..337ff2394b2 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf @@ -72,6 +72,15 @@ Источник NuGet, который следует использовать для интеграции. + + Stopped a running Aspire AppHost instance to allow package modification. + Stopped a running Aspire AppHost instance to allow package modification. + + + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + + Unexpected number of packages found. Обнаружено неожиданное количество пакетов. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf index ddac3946d36..9f9933c4dbe 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf @@ -72,6 +72,15 @@ Tümleştirme için kullanılacak NuGet kaynağı. + + Stopped a running Aspire AppHost instance to allow package modification. + Stopped a running Aspire AppHost instance to allow package modification. + + + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + + Unexpected number of packages found. Beklenmeyen sayıda paket bulundu. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf index 17833c3a399..8586d7619ac 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf @@ -72,6 +72,15 @@ 用于集成的 NuGet 源。 + + Stopped a running Aspire AppHost instance to allow package modification. + Stopped a running Aspire AppHost instance to allow package modification. + + + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + + Unexpected number of packages found. 找到意外数量的包。 diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf index 04f4af8fd21..5324918af99 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf @@ -72,6 +72,15 @@ 用於整合的 NuGet 來源。 + + Stopped a running Aspire AppHost instance to allow package modification. + Stopped a running Aspire AppHost instance to allow package modification. + + + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + Unable to stop one or more running Aspire AppHost instances. Please stop the application and try again. + + Unexpected number of packages found. 找到意外的套件數量。 diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs index 1838bff7230..65519da5a55 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs @@ -174,4 +174,253 @@ public async Task StopWithNoRunningAppHostExitsSuccessfully() await pendingRun; } + + [Fact] + public async Task AddPackageWhileAppHostRunningDetached() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(AddPackageWhileAppHostRunningDetached)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // Pattern searchers for aspire new prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .Find("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find("Enter the output path:"); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find("Do you want to create a test project?"); + + // Pattern searchers for detach/add/stop + var waitForAppHostStartedSuccessfully = new CellPatternSearcher() + .Find("AppHost started successfully."); + + var waitForPackageAddedSuccessfully = new CellPatternSearcher() + .Find("was added successfully."); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Create a new project using aspire new + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() // select first template (Starter App) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Type("AspireAddTestApp") + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter); + + // Navigate to the AppHost directory + sequenceBuilder.Type("cd AspireAddTestApp/AspireAddTestApp.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Start the AppHost in detached mode (locks the project file) + sequenceBuilder.Type("aspire run --detach") + .Enter() + .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Add a package while the AppHost is running - this should auto-stop the + // running instance before modifying the project, then succeed. + // --non-interactive skips the version selection prompt. + sequenceBuilder.Type("aspire add mongodb --non-interactive") + .Enter() + .WaitUntil(s => waitForPackageAddedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Clean up: stop if still running (the add command may have stopped it) + // aspire stop may return a non-zero exit code if no instances are found + // (already stopped by aspire add), so wait for known output patterns. + var waitForStopResult = new CellPatternSearcher() + .Find("No running AppHosts found"); + var waitForStoppedSuccessfully = new CellPatternSearcher() + .Find("AppHost stopped successfully."); + + sequenceBuilder.Type("aspire stop") + .Enter() + .WaitUntil(s => waitForStopResult.Search(s).Count > 0 || waitForStoppedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(1)) + .IncrementSequence(counter); + + // Exit the shell + sequenceBuilder.Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } + + [Fact] + public async Task AddPackageInteractiveWhileAppHostRunningDetached() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(AddPackageInteractiveWhileAppHostRunningDetached)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // Pattern searchers for aspire new prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .Find("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find("Enter the output path:"); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find("Do you want to create a test project?"); + + // Pattern searchers for detach/add/stop + var waitForAppHostStartedSuccessfully = new CellPatternSearcher() + .Find("AppHost started successfully."); + + var waitForIntegrationSelectionPrompt = new CellPatternSearcher() + .Find("Select an integration to add:"); + + var waitForVersionSelectionPrompt = new CellPatternSearcher() + .Find("Select a version of"); + + var waitForPackageAddedSuccessfully = new CellPatternSearcher() + .Find("was added successfully."); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Create a new project using aspire new + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() // select first template (Starter App) + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Type("AspireAddInteractiveApp") + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter); + + // Navigate to the AppHost directory + sequenceBuilder.Type("cd AspireAddInteractiveApp/AspireAddInteractiveApp.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Start the AppHost in detached mode (locks the project file) + sequenceBuilder.Type("aspire run --detach") + .Enter() + .WaitUntil(s => waitForAppHostStartedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(3)) + .WaitForSuccessPrompt(counter); + + // Run aspire add interactively (no integration argument) while AppHost is running. + // This exercises the interactive package selection flow and verifies the + // running instance is auto-stopped before modifying the project. + sequenceBuilder.Type("aspire add") + .Enter() + .WaitUntil(s => waitForIntegrationSelectionPrompt.Search(s).Count > 0, TimeSpan.FromMinutes(1)) + .Type("mongodb") // type to filter the list + .Enter() // select the filtered result + .WaitUntil(s => waitForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() // Accept the default version + .WaitUntil(s => waitForPackageAddedSuccessfully.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter); + + // Clean up: stop if still running + // aspire stop may return a non-zero exit code if no instances are found + // (already stopped by aspire add), so wait for known output patterns. + var waitForStopResult2 = new CellPatternSearcher() + .Find("No running AppHosts found"); + var waitForStoppedSuccessfully2 = new CellPatternSearcher() + .Find("AppHost stopped successfully."); + + sequenceBuilder.Type("aspire stop") + .Enter() + .WaitUntil(s => waitForStopResult2.Search(s).Count > 0 || waitForStoppedSuccessfully2.Search(s).Count > 0, TimeSpan.FromMinutes(1)) + .IncrementSequence(counter); + + // Exit the shell + sequenceBuilder.Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs b/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs index 3c99f10bc7c..70f981fcd33 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestAppHostProjectFactory.cs @@ -173,7 +173,7 @@ public Task AddPackageAsync(AddPackageContext context, CancellationToken c public Task UpdatePackagesAsync(UpdatePackagesContext context, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task CheckAndHandleRunningInstanceAsync(FileInfo appHostFile, DirectoryInfo homeDirectory, CancellationToken cancellationToken) + public Task FindAndStopRunningInstanceAsync(FileInfo appHostFile, DirectoryInfo homeDirectory, CancellationToken cancellationToken) => Task.FromResult(RunningInstanceResult.NoRunningInstance); private static bool IsValidSingleFileAppHost(FileInfo candidateFile) From 98d4769bc06152637119cabfa8fdd6896e881e74 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 05:52:37 +0000 Subject: [PATCH 138/256] Update dependencies from https://github.com/dotnet/arcade build 20260219.2 (#14616) [main] Update dependencies from dotnet/arcade --- eng/Version.Details.xml | 30 +++++++++---------- eng/Versions.props | 6 ++-- eng/common/templates/steps/vmr-sync.yml | 21 -------------- eng/common/templates/vmr-build-pr.yml | 1 + eng/common/tools.ps1 | 5 ++++ eng/common/tools.sh | 8 +++++- eng/common/vmr-sync.ps1 | 38 +++++++++++++++++++++---- eng/common/vmr-sync.sh | 30 +++++++++++++++---- global.json | 6 ++-- 9 files changed, 91 insertions(+), 54 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 6e938847991..2939c20d53c 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,7 +1,7 @@ - + https://github.com/microsoft/dcp 9585d3bbfad8a356770096fcda944349da4145f1 @@ -179,33 +179,33 @@ - + https://github.com/dotnet/arcade - 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d + 062ea8ab27da7dbb7d4f630bd50aeab94c9ffb93 - + https://github.com/dotnet/arcade - 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d + 062ea8ab27da7dbb7d4f630bd50aeab94c9ffb93 - + https://github.com/dotnet/arcade - 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d + 062ea8ab27da7dbb7d4f630bd50aeab94c9ffb93 - + https://github.com/dotnet/arcade - 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d + 062ea8ab27da7dbb7d4f630bd50aeab94c9ffb93 - + https://github.com/dotnet/arcade - 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d + 062ea8ab27da7dbb7d4f630bd50aeab94c9ffb93 - + https://github.com/dotnet/arcade - 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d + 062ea8ab27da7dbb7d4f630bd50aeab94c9ffb93 - + https://github.com/dotnet/arcade - 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d + 062ea8ab27da7dbb7d4f630bd50aeab94c9ffb93 diff --git a/eng/Versions.props b/eng/Versions.props index 0dc60eb7c0f..3845d154491 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -38,9 +38,9 @@ 0.22.6 0.22.6 - 10.0.0-beta.26110.1 - 10.0.0-beta.26110.1 - 10.0.0-beta.26110.1 + 10.0.0-beta.26119.2 + 10.0.0-beta.26119.2 + 10.0.0-beta.26119.2 10.0.2 10.2.0 diff --git a/eng/common/templates/steps/vmr-sync.yml b/eng/common/templates/steps/vmr-sync.yml index 599afb6186b..eb619c50268 100644 --- a/eng/common/templates/steps/vmr-sync.yml +++ b/eng/common/templates/steps/vmr-sync.yml @@ -38,27 +38,6 @@ steps: displayName: Label PR commit workingDirectory: $(Agent.BuildDirectory)/repo -- script: | - vmr_sha=$(grep -oP '(?<=Sha=")[^"]*' $(Agent.BuildDirectory)/repo/eng/Version.Details.xml) - echo "##vso[task.setvariable variable=vmr_sha]$vmr_sha" - displayName: Obtain the vmr sha from Version.Details.xml (Unix) - condition: ne(variables['Agent.OS'], 'Windows_NT') - workingDirectory: $(Agent.BuildDirectory)/repo - -- powershell: | - [xml]$xml = Get-Content -Path $(Agent.BuildDirectory)/repo/eng/Version.Details.xml - $vmr_sha = $xml.SelectSingleNode("//Source").Sha - Write-Output "##vso[task.setvariable variable=vmr_sha]$vmr_sha" - displayName: Obtain the vmr sha from Version.Details.xml (Windows) - condition: eq(variables['Agent.OS'], 'Windows_NT') - workingDirectory: $(Agent.BuildDirectory)/repo - -- script: | - git fetch --all - git checkout $(vmr_sha) - displayName: Checkout VMR at correct sha for repo flow - workingDirectory: ${{ parameters.vmrPath }} - - script: | git config --global user.name "dotnet-maestro[bot]" git config --global user.email "dotnet-maestro[bot]@users.noreply.github.com" diff --git a/eng/common/templates/vmr-build-pr.yml b/eng/common/templates/vmr-build-pr.yml index ce3c29a62fa..2f3694fa132 100644 --- a/eng/common/templates/vmr-build-pr.yml +++ b/eng/common/templates/vmr-build-pr.yml @@ -34,6 +34,7 @@ resources: type: github name: dotnet/dotnet endpoint: dotnet + ref: refs/heads/main # Set to whatever VMR branch the PR build should insert into stages: - template: /eng/pipelines/templates/stages/vmr-build.yml@vmr diff --git a/eng/common/tools.ps1 b/eng/common/tools.ps1 index 049fe6db994..977a2d4b103 100644 --- a/eng/common/tools.ps1 +++ b/eng/common/tools.ps1 @@ -824,6 +824,11 @@ function MSBuild-Core() { $cmdArgs = "$($buildTool.Command) /m /nologo /clp:Summary /v:$verbosity /nr:$nodeReuse /p:ContinuousIntegrationBuild=$ci" + # Add -mt flag for MSBuild multithreaded mode if enabled via environment variable + if ($env:MSBUILD_MT_ENABLED -eq "1") { + $cmdArgs += ' -mt' + } + if ($warnAsError) { $cmdArgs += ' /warnaserror /p:TreatWarningsAsErrors=true' } diff --git a/eng/common/tools.sh b/eng/common/tools.sh index c1841c9dfd0..1b296f646c2 100755 --- a/eng/common/tools.sh +++ b/eng/common/tools.sh @@ -526,7 +526,13 @@ function MSBuild-Core { } } - RunBuildTool "$_InitializeBuildToolCommand" /m /nologo /clp:Summary /v:$verbosity /nr:$node_reuse $warnaserror_switch /p:TreatWarningsAsErrors=$warn_as_error /p:ContinuousIntegrationBuild=$ci "$@" + # Add -mt flag for MSBuild multithreaded mode if enabled via environment variable + local mt_switch="" + if [[ "${MSBUILD_MT_ENABLED:-}" == "1" ]]; then + mt_switch="-mt" + fi + + RunBuildTool "$_InitializeBuildToolCommand" /m /nologo /clp:Summary /v:$verbosity /nr:$node_reuse $warnaserror_switch $mt_switch /p:TreatWarningsAsErrors=$warn_as_error /p:ContinuousIntegrationBuild=$ci "$@" } function GetDarc { diff --git a/eng/common/vmr-sync.ps1 b/eng/common/vmr-sync.ps1 index 97302f3205b..b37992d91cf 100644 --- a/eng/common/vmr-sync.ps1 +++ b/eng/common/vmr-sync.ps1 @@ -103,12 +103,20 @@ Set-StrictMode -Version Latest Highlight 'Installing .NET, preparing the tooling..' . .\eng\common\tools.ps1 $dotnetRoot = InitializeDotNetCli -install:$true +$env:DOTNET_ROOT = $dotnetRoot $darc = Get-Darc -$dotnet = "$dotnetRoot\dotnet.exe" Highlight "Starting the synchronization of VMR.." # Synchronize the VMR +$versionDetailsPath = Resolve-Path (Join-Path $PSScriptRoot '..\Version.Details.xml') | Select-Object -ExpandProperty Path +[xml]$versionDetails = Get-Content -Path $versionDetailsPath +$repoName = $versionDetails.SelectSingleNode('//Source').Mapping +if (-not $repoName) { + Fail "Failed to resolve repo mapping from $versionDetailsPath" + exit 1 +} + $darcArgs = ( "vmr", "forwardflow", "--tmp", $tmpDir, @@ -130,9 +138,27 @@ if ($LASTEXITCODE -eq 0) { Highlight "Synchronization succeeded" } else { - Fail "Synchronization of repo to VMR failed!" - Fail "'$vmrDir' is left in its last state (re-run of this script will reset it)." - Fail "Please inspect the logs which contain path to the failing patch file (use -debugOutput to get all the details)." - Fail "Once you make changes to the conflicting VMR patch, commit it locally and re-run this script." - exit 1 + Highlight "Failed to flow code into the local VMR. Falling back to resetting the VMR to match repo contents..." + git -C $vmrDir reset --hard + + $resetArgs = ( + "vmr", "reset", + "${repoName}:HEAD", + "--vmr", $vmrDir, + "--tmp", $tmpDir, + "--additional-remotes", "${repoName}:${repoRoot}" + ) + + & "$darc" $resetArgs + + if ($LASTEXITCODE -eq 0) { + Highlight "Successfully reset the VMR using 'darc vmr reset'" + } + else { + Fail "Synchronization of repo to VMR failed!" + Fail "'$vmrDir' is left in its last state (re-run of this script will reset it)." + Fail "Please inspect the logs which contain path to the failing patch file (use -debugOutput to get all the details)." + Fail "Once you make changes to the conflicting VMR patch, commit it locally and re-run this script." + exit 1 + } } diff --git a/eng/common/vmr-sync.sh b/eng/common/vmr-sync.sh index 44239e331c0..198caec59bd 100644 --- a/eng/common/vmr-sync.sh +++ b/eng/common/vmr-sync.sh @@ -186,6 +186,13 @@ fi # Synchronize the VMR +version_details_path=$(cd "$scriptroot/.."; pwd -P)/Version.Details.xml +repo_name=$(grep -m 1 ' Date: Mon, 23 Feb 2026 14:08:37 +0800 Subject: [PATCH 139/256] Fix dashboard notifications HTML escaping (#14615) --- .../Components/Interactions/InteractionsProvider.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs b/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs index cf19e288748..d3497d946cb 100644 --- a/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs +++ b/src/Aspire.Dashboard/Components/Interactions/InteractionsProvider.cs @@ -388,6 +388,10 @@ await InvokeAsync(async () => { options.Title = WebUtility.HtmlEncode(item.Title); options.Body = GetMessageHtml(item); + // Allow for HTML in title and body. This is needed to support Markdown output. + // It's safe to enable because content is always either HTML-encoded or generated from Markdown which is sanitized. + options.UseMarkupString = true; + options.Intent = MapMessageIntent(notification.Intent); options.Section = DashboardUIHelpers.MessageBarSection; options.AllowDismiss = item.ShowDismiss; From ac3a8cf9f7c7fb69328164b54de37c8e67446e94 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 23 Feb 2026 15:08:21 +0800 Subject: [PATCH 140/256] Update resource JSON export to use name/value dictionaries where possible (#14610) --- src/Aspire.Cli/Aspire.Cli.csproj | 1 + .../Backchannel/ResourceSnapshotMapper.cs | 57 ++++++------- src/Aspire.Cli/Commands/ResourcesCommand.cs | 7 +- src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs | 7 +- src/Aspire.Dashboard/Aspire.Dashboard.csproj | 1 + .../ResourceJsonSerializerContext.cs | 7 +- .../Model/TelemetryExportService.cs | 42 +++++----- src/Shared/EnumerableExtensions.cs | 26 ++++++ .../Model/Serialization/ResourceJson.cs | 80 ++++--------------- .../ResourceSnapshotMapperTests.cs | 75 +---------------- .../Model/TelemetryExportServiceTests.cs | 14 ++-- 11 files changed, 109 insertions(+), 208 deletions(-) create mode 100644 src/Shared/EnumerableExtensions.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index af551b28c50..8eedc788dfd 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -65,6 +65,7 @@ + diff --git a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs index d78265455f8..906c8430dde 100644 --- a/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs +++ b/src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Utils; +using Aspire.Shared; using Aspire.Shared.Model; using Aspire.Shared.Model.Serialization; @@ -33,7 +34,7 @@ public static List MapToResourceJsonList(IEnumerableWhether to include environment variable values. Defaults to true. Set to false to exclude values for security reasons. public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnlyList allSnapshots, string? dashboardBaseUrl = null, bool includeEnvironmentVariableValues = true) { - var urls = (snapshot.Urls ?? []) + var urls = snapshot.Urls .Select(u => new ResourceUrlJson { Name = u.Name, @@ -43,7 +44,7 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl }) .ToArray(); - var volumes = (snapshot.Volumes ?? []) + var volumes = snapshot.Volumes .Select(v => new ResourceVolumeJson { Source = v.Source, @@ -53,36 +54,29 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl }) .ToArray(); - var healthReports = (snapshot.HealthReports ?? []) - .Select(h => new ResourceHealthReportJson + var healthReports = snapshot.HealthReports.OrderBy(h => h.Name).ToDistinctDictionary( + h => h.Name, + h => new ResourceHealthReportJson { - Name = h.Name, Status = h.Status, Description = h.Description, ExceptionMessage = h.ExceptionText - }) - .ToArray(); + }); - var environment = (snapshot.EnvironmentVariables ?? []) + var environment = snapshot.EnvironmentVariables .Where(e => e.IsFromSpec) - .Select(e => new ResourceEnvironmentVariableJson - { - Name = e.Name, - Value = includeEnvironmentVariableValues ? e.Value : null - }) - .ToArray(); + .OrderBy(e => e.Name) + .ToDistinctDictionary( + e => e.Name, + e => includeEnvironmentVariableValues ? e.Value : null); - var properties = (snapshot.Properties ?? []) - .Select(p => new ResourcePropertyJson - { - Name = p.Key, - Value = p.Value - }) - .ToArray(); + var properties = snapshot.Properties.OrderBy(p => p.Key).ToDistinctDictionary( + p => p.Key, + p => p.Value); // Build relationships by matching DisplayName var relationships = new List(); - foreach (var relationship in snapshot.Relationships ?? []) + foreach (var relationship in snapshot.Relationships) { var matches = allSnapshots .Where(r => string.Equals(r.DisplayName, relationship.ResourceName, StringComparisons.ResourceName)) @@ -99,19 +93,18 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl } // Only include enabled commands - var commands = (snapshot.Commands ?? []) + var commands = snapshot.Commands .Where(c => string.Equals(c.State, "Enabled", StringComparison.OrdinalIgnoreCase)) - .Select(c => new ResourceCommandJson - { - Name = c.Name, - Description = c.Description - }) - .ToArray(); + .OrderBy(c => c.Name) + .ToDistinctDictionary( + c => c.Name, + c => new ResourceCommandJson + { + Description = c.Description + }); // Get source information using the shared ResourceSourceViewModel - var sourceViewModel = snapshot.Properties is not null - ? ResourceSource.GetSourceModel(snapshot.ResourceType, snapshot.Properties) - : null; + var sourceViewModel = ResourceSource.GetSourceModel(snapshot.ResourceType, snapshot.Properties); // Generate dashboard URL for this resource if a base URL is provided string? dashboardUrl = null; diff --git a/src/Aspire.Cli/Commands/ResourcesCommand.cs b/src/Aspire.Cli/Commands/ResourcesCommand.cs index 52ec48e2325..96de50e814f 100644 --- a/src/Aspire.Cli/Commands/ResourcesCommand.cs +++ b/src/Aspire.Cli/Commands/ResourcesCommand.cs @@ -29,11 +29,10 @@ internal sealed class ResourcesOutput [JsonSerializable(typeof(ResourceJson))] [JsonSerializable(typeof(ResourceUrlJson))] [JsonSerializable(typeof(ResourceVolumeJson))] -[JsonSerializable(typeof(ResourceEnvironmentVariableJson))] -[JsonSerializable(typeof(ResourceHealthReportJson))] -[JsonSerializable(typeof(ResourcePropertyJson))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(ResourceRelationshipJson))] -[JsonSerializable(typeof(ResourceCommandJson))] +[JsonSerializable(typeof(Dictionary))] [JsonSourceGenerationOptions( WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, diff --git a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs index 1a55f716492..8ba6db60122 100644 --- a/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs +++ b/src/Aspire.Cli/Mcp/Tools/ListResourcesTool.cs @@ -15,11 +15,10 @@ namespace Aspire.Cli.Mcp.Tools; [JsonSerializable(typeof(ResourceJson[]))] [JsonSerializable(typeof(ResourceUrlJson))] [JsonSerializable(typeof(ResourceVolumeJson))] -[JsonSerializable(typeof(ResourceEnvironmentVariableJson))] -[JsonSerializable(typeof(ResourceHealthReportJson))] -[JsonSerializable(typeof(ResourcePropertyJson))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(ResourceRelationshipJson))] -[JsonSerializable(typeof(ResourceCommandJson))] +[JsonSerializable(typeof(Dictionary))] [JsonSourceGenerationOptions( WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index c2502681dcc..363e6c47721 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -280,6 +280,7 @@ + diff --git a/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs b/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs index ee006d8c3bf..e7a6d3fb4b8 100644 --- a/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs +++ b/src/Aspire.Dashboard/Model/Serialization/ResourceJsonSerializerContext.cs @@ -19,11 +19,10 @@ namespace Aspire.Dashboard.Model.Serialization; [JsonSerializable(typeof(ResourceJson))] [JsonSerializable(typeof(ResourceUrlJson))] [JsonSerializable(typeof(ResourceVolumeJson))] -[JsonSerializable(typeof(ResourceEnvironmentVariableJson))] -[JsonSerializable(typeof(ResourceHealthReportJson))] -[JsonSerializable(typeof(ResourcePropertyJson))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(ResourceRelationshipJson))] -[JsonSerializable(typeof(ResourceCommandJson))] +[JsonSerializable(typeof(Dictionary))] internal sealed partial class ResourceJsonSerializerContext : JsonSerializerContext { /// diff --git a/src/Aspire.Dashboard/Model/TelemetryExportService.cs b/src/Aspire.Dashboard/Model/TelemetryExportService.cs index 20584c2d8b7..2aebb55af40 100644 --- a/src/Aspire.Dashboard/Model/TelemetryExportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryExportService.cs @@ -13,6 +13,7 @@ using Aspire.Dashboard.Otlp.Model.Serialization; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Utils; +using Aspire.Shared; using Aspire.Shared.Model.Serialization; namespace Aspire.Dashboard.Model; @@ -766,37 +767,34 @@ internal static string ConvertResourceToJson(ResourceViewModel resource, IReadOn }).ToArray() : null, Environment = resource.Environment.Length > 0 - ? resource.Environment.Where(e => e.FromSpec).Select(e => new ResourceEnvironmentVariableJson - { - Name = e.Name, - Value = e.Value - }).ToArray() + ? resource.Environment.Where(e => e.FromSpec).OrderBy(e => e.Name).ToDistinctDictionary(e => e.Name, e => e.Value) : null, HealthReports = resource.HealthReports.Length > 0 - ? resource.HealthReports.Select(h => new ResourceHealthReportJson - { - Name = h.Name, - Status = h.HealthStatus?.ToString(), - Description = h.Description, - ExceptionMessage = h.ExceptionText - }).ToArray() + ? resource.HealthReports.OrderBy(h => h.Name).ToDistinctDictionary( + h => h.Name, + h => new ResourceHealthReportJson + { + Status = h.HealthStatus?.ToString(), + Description = h.Description, + ExceptionMessage = h.ExceptionText + }) : null, Properties = resource.Properties.Count > 0 - ? resource.Properties.Select(p => new ResourcePropertyJson - { - Name = p.Key, - Value = p.Value.Value.TryConvertToString(out var value) ? value : null - }).ToArray() + ? resource.Properties.OrderBy(p => p.Key).ToDistinctDictionary( + p => p.Key, + p => p.Value.Value.TryConvertToString(out var value) ? value : null) : null, Relationships = relationshipsJson, Commands = resource.Commands.Length > 0 ? resource.Commands .Where(c => c.State == CommandViewModelState.Enabled) - .Select(c => new ResourceCommandJson - { - Name = c.Name, - Description = c.GetDisplayDescription() - }).ToArray() + .OrderBy(c => c.Name) + .ToDistinctDictionary( + c => c.Name, + c => new ResourceCommandJson + { + Description = c.GetDisplayDescription() + }) : null, Source = ResourceSourceViewModel.GetSourceViewModel(resource)?.Value }; diff --git a/src/Shared/EnumerableExtensions.cs b/src/Shared/EnumerableExtensions.cs new file mode 100644 index 00000000000..9c99a3559bf --- /dev/null +++ b/src/Shared/EnumerableExtensions.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Shared; + +/// +/// Extension methods for . +/// +internal static class EnumerableExtensions +{ + /// + /// Creates a dictionary from a sequence, keeping the first element for each key when duplicates exist. + /// + public static Dictionary ToDistinctDictionary( + this IEnumerable source, + Func keySelector, + Func valueSelector) where TKey : notnull + { + var dictionary = new Dictionary(); + foreach (var item in source) + { + dictionary.TryAdd(keySelector(item), valueSelector(item)); + } + return dictionary; + } +} diff --git a/src/Shared/Model/Serialization/ResourceJson.cs b/src/Shared/Model/Serialization/ResourceJson.cs index fc59ee520fa..d07b007bb89 100644 --- a/src/Shared/Model/Serialization/ResourceJson.cs +++ b/src/Shared/Model/Serialization/ResourceJson.cs @@ -76,6 +76,11 @@ internal sealed class ResourceJson /// public string? DashboardUrl { get; set; } + /// + /// The relationships of the resource. + /// + public ResourceRelationshipJson[]? Relationships { get; set; } + /// /// The URLs/endpoints associated with the resource. /// @@ -87,29 +92,28 @@ internal sealed class ResourceJson public ResourceVolumeJson[]? Volumes { get; set; } /// - /// The environment variables associated with the resource. - /// - public ResourceEnvironmentVariableJson[]? Environment { get; set; } - - /// - /// The health reports associated with the resource. + /// The properties of the resource. + /// Dictionary key is the property name, value is the property value. /// - public ResourceHealthReportJson[]? HealthReports { get; set; } + public Dictionary? Properties { get; set; } /// - /// The properties of the resource. + /// The environment variables associated with the resource. + /// Dictionary key is the environment variable name, value is the environment variable value. /// - public ResourcePropertyJson[]? Properties { get; set; } + public Dictionary? Environment { get; set; } /// - /// The relationships of the resource. + /// The health reports associated with the resource. + /// Dictionary key is the health report name. /// - public ResourceRelationshipJson[]? Relationships { get; set; } + public Dictionary? HealthReports { get; set; } /// /// The commands available for the resource. + /// Dictionary key is the command name. /// - public ResourceCommandJson[]? Commands { get; set; } + public Dictionary? Commands { get; set; } } /// @@ -118,7 +122,7 @@ internal sealed class ResourceJson internal sealed class ResourceUrlJson { /// - /// The name of the URL/endpoint. + /// The name of the endpoint. /// public string? Name { get; set; } @@ -166,33 +170,11 @@ internal sealed class ResourceVolumeJson public bool IsReadOnly { get; set; } } -/// -/// Represents an environment variable in JSON format. -/// -internal sealed class ResourceEnvironmentVariableJson -{ - /// - /// The name of the environment variable. - /// - public string? Name { get; set; } - - /// - /// The value of the environment variable. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string? Value { get; set; } -} - /// /// Represents a health report in JSON format. /// internal sealed class ResourceHealthReportJson { - /// - /// The name of the health report. - /// - public string? Name { get; set; } - /// /// The health status. /// @@ -209,29 +191,6 @@ internal sealed class ResourceHealthReportJson public string? ExceptionMessage { get; set; } } -/// -/// Represents a property in JSON format. -/// -internal sealed class ResourcePropertyJson -{ - /// - /// The name of the property. - /// - public string? Name { get; set; } - - /// - /// The value of the property. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string? Value { get; set; } - - /// - /// Whether this property contains sensitive data. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - public bool IsSensitive { get; set; } -} - /// /// Represents a relationship in JSON format. /// @@ -253,11 +212,6 @@ internal sealed class ResourceRelationshipJson /// internal sealed class ResourceCommandJson { - /// - /// The name of the command. - /// - public string? Name { get; set; } - /// /// The description of the command. /// diff --git a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs index 11bbdd1f332..3b8ecb37b80 100644 --- a/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs +++ b/tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs @@ -7,41 +7,6 @@ namespace Aspire.Cli.Tests.Backchannel; public class ResourceSnapshotMapperTests { - [Fact] - public void MapToResourceJson_WithNullCollectionProperties_DoesNotThrow() - { - // Arrange - simulate a snapshot deserialized from JSON where collection properties are null - var snapshot = new ResourceSnapshot - { - Name = "test-resource", - DisplayName = "test-resource", - ResourceType = "Project", - State = "Running", - Urls = null!, - Volumes = null!, - HealthReports = null!, - EnvironmentVariables = null!, - Properties = null!, - Relationships = null!, - Commands = null! - }; - - var allSnapshots = new List { snapshot }; - - // Act & Assert - should not throw NullReferenceException - var result = ResourceSnapshotMapper.MapToResourceJson(snapshot, allSnapshots); - - Assert.NotNull(result); - Assert.Equal("test-resource", result.Name); - Assert.Empty(result.Urls!); - Assert.Empty(result.Volumes!); - Assert.Empty(result.HealthReports!); - Assert.Empty(result.Environment!); - Assert.Empty(result.Properties!); - Assert.Empty(result.Relationships!); - Assert.Empty(result.Commands!); - } - [Fact] public void MapToResourceJson_WithPopulatedProperties_MapsCorrectly() { @@ -80,51 +45,15 @@ public void MapToResourceJson_WithPopulatedProperties_MapsCorrectly() // Only enabled commands should be included Assert.Single(result.Commands!); - Assert.Equal("resource-stop", result.Commands![0].Name); + Assert.True(result.Commands!.ContainsKey("resource-stop")); // Only IsFromSpec environment variables should be included Assert.Single(result.Environment!); - Assert.Equal("ASPNETCORE_ENVIRONMENT", result.Environment![0].Name); + Assert.Equal("Development", result.Environment!["ASPNETCORE_ENVIRONMENT"]); // Dashboard URL should be generated Assert.NotNull(result.DashboardUrl); Assert.Contains("localhost:18080", result.DashboardUrl); } - [Fact] - public void MapToResourceJsonList_WithNullCollectionProperties_DoesNotThrow() - { - // Arrange - var snapshots = new List - { - new ResourceSnapshot - { - Name = "resource1", - DisplayName = "resource1", - ResourceType = "Project", - State = "Running", - Urls = null!, - Volumes = null!, - HealthReports = null!, - EnvironmentVariables = null!, - Properties = null!, - Relationships = null!, - Commands = null! - }, - new ResourceSnapshot - { - Name = "resource2", - DisplayName = "resource2", - ResourceType = "Container", - State = "Starting" - } - }; - - // Act & Assert - should not throw - var result = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardBaseUrl: "http://localhost:18080"); - - Assert.Equal(2, result.Count); - Assert.Equal("resource1", result[0].Name); - Assert.Equal("resource2", result[1].Name); - } } diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs index e9220633c4f..39af42eafdb 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs @@ -1205,7 +1205,7 @@ public void ConvertResourceToJson_ReturnsExpectedJson() Assert.NotNull(deserialized.Environment); Assert.Single(deserialized.Environment); - Assert.Equal("MY_VAR", deserialized.Environment[0].Name); + Assert.True(deserialized.Environment.ContainsKey("MY_VAR")); // Relationships are resolved by matching DisplayName. Since there's only one resource // with that display name (not a replica), the display name is used as the resource name. @@ -1238,10 +1238,12 @@ public void ConvertResourceToJson_OnlyIncludesFromSpecEnvironmentVariables() var deserialized = JsonSerializer.Deserialize(json, ResourceJsonSerializerContext.Default.ResourceJson); Assert.NotNull(deserialized); Assert.NotNull(deserialized.Environment); - Assert.Equal(2, deserialized.Environment.Length); - Assert.Contains(deserialized.Environment, e => e.Name == "FROM_SPEC_VAR" && e.Value == "spec-value"); - Assert.Contains(deserialized.Environment, e => e.Name == "ANOTHER_SPEC_VAR" && e.Value == "another-spec-value"); - Assert.DoesNotContain(deserialized.Environment, e => e.Name == "NOT_FROM_SPEC_VAR"); + Assert.Equal(2, deserialized.Environment.Count); + Assert.Contains("FROM_SPEC_VAR", deserialized.Environment.Keys); + Assert.Equal("spec-value", deserialized.Environment["FROM_SPEC_VAR"]); + Assert.Contains("ANOTHER_SPEC_VAR", deserialized.Environment.Keys); + Assert.Equal("another-spec-value", deserialized.Environment["ANOTHER_SPEC_VAR"]); + Assert.DoesNotContain("NOT_FROM_SPEC_VAR", deserialized.Environment.Keys); } [Fact] @@ -1275,6 +1277,6 @@ public void ConvertResourceToJson_NonAsciiContent_IsNotEscaped() Assert.NotNull(deserialized.Environment); Assert.Single(deserialized.Environment); - Assert.Equal(japaneseEnvValue, deserialized.Environment[0].Value); + Assert.Equal(japaneseEnvValue, deserialized.Environment["JAPANESE_VAR"]); } } From d4f30cf0b5280619c837e2b22f1578086a300988 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 23 Feb 2026 17:15:43 +0800 Subject: [PATCH 141/256] Update Aspire skill to use isolated mode only when needed (#14620) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Agents/CommonAgentApplicators.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs index 198858b20ad..271c5db22c8 100644 --- a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs +++ b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs @@ -167,13 +167,26 @@ 4. Use the Aspire MCP tools to check the status of resources and debug issues. Agent environments may terminate foreground processes when a command finishes. Use detached mode: ```bash - aspire run --detach --isolated + aspire run --detach ``` This starts the AppHost in the background and returns immediately. The CLI will: - Automatically stop any existing running instance before starting a new one - Display a summary with the Dashboard URL and resource endpoints + ### Running with isolation + + The `--isolated` flag starts the AppHost with randomized port numbers and its own copy of user secrets. + + ```bash + aspire run --detach --isolated + ``` + + Isolation should be used when: + - When AppHosts are started by background agents + - When agents are using source code from a work tree + - There are port conflicts when starting the AppHost without isolation + ### Stopping the application To stop a running AppHost: From 2fceb30fafe19abcec449f7f1cb9eb3efdadddf3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 07:45:22 -0800 Subject: [PATCH 142/256] Add `WithMcpServer` extension method for fluent MCP server endpoint configuration (#14618) * Add WithMcpServer extension method to ResourceBuilderExtensions Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> * Move WithMcpServer to dedicated class with improved API - Move WithMcpServer from ResourceBuilderExtensions to new McpServerResourceBuilderExtensions class - Make endpointName optional, defaulting to https then http fallback (consistent with WithHttpHealthCheck pattern) - Add [Experimental("ASPIREMCP001")] and [EndpointName] attributes - Update PostgresBuilderExtensions to use new extension method - Add tests for default endpoint selection and fallback behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: David Fowler * Update src/Aspire.Hosting/McpServerResourceBuilderExtensions.cs --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> Co-authored-by: David Fowler Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: James Newton-King --- .../PostgresBuilderExtensions.cs | 4 +- .../McpServerResourceBuilderExtensions.cs | 108 +++++++++++ .../WithMcpServerTests.cs | 177 ++++++++++++++++++ 3 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 src/Aspire.Hosting/McpServerResourceBuilderExtensions.cs create mode 100644 tests/Aspire.Hosting.Tests/WithMcpServerTests.cs diff --git a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs index 55388a22d1c..526c3c34264 100644 --- a/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs +++ b/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.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 ASPIREMCP001 + using System.Text; using System.Text.Json; using System.Diagnostics.CodeAnalysis; @@ -383,7 +385,7 @@ public static IResourceBuilder WithPostgresMcp( { context.EnvironmentVariables[PostgresMcpDatabaseUriEnvVarName] = builder.Resource.UriExpression; }) - .WithAnnotation(McpServerEndpointAnnotation.FromEndpoint(PostgresMcpContainerResource.PrimaryEndpointName, "/sse")) + .WithMcpServer("/sse", endpointName: PostgresMcpContainerResource.PrimaryEndpointName) .WithIconName("BrainCircuit") // Show a BrainCircuit icon for MCP resources in the dashboard .WaitFor(builder); diff --git a/src/Aspire.Hosting/McpServerResourceBuilderExtensions.cs b/src/Aspire.Hosting/McpServerResourceBuilderExtensions.cs new file mode 100644 index 00000000000..969fd291d3f --- /dev/null +++ b/src/Aspire.Hosting/McpServerResourceBuilderExtensions.cs @@ -0,0 +1,108 @@ +// 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; + +/// +/// Provides extension methods for configuring MCP (Model Context Protocol) server endpoints on resources. +/// +public static class McpServerResourceBuilderExtensions +{ + private static readonly string[] s_httpSchemes = ["https", "http"]; + + /// + /// Marks the resource as hosting a Model Context Protocol (MCP) server on the specified endpoint. + /// + /// The resource type. + /// The resource builder. + /// An optional path to append to the endpoint URL when forming the MCP server address. Defaults to "/mcp". + /// An optional name of the endpoint that hosts the MCP server. If not specified, defaults to the first HTTPS or HTTP endpoint. + /// A reference to the for chaining additional configuration. + /// + /// This method adds an to the resource, enabling the Aspire tooling + /// to discover and proxy the MCP server exposed by the resource. + /// + /// + /// Mark a resource as hosting an MCP server using the default endpoint: + /// + /// var api = builder.AddProject<Projects.MyApi>("api") + /// .WithMcpServer(); + /// + /// Mark a resource as hosting an MCP server with a custom path and endpoint: + /// + /// var api = builder.AddProject<Projects.MyApi>("api") + /// .WithMcpServer("/sse", endpointName: "https"); + /// + /// + [Experimental("ASPIREMCP001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceBuilder WithMcpServer( + this IResourceBuilder builder, + string? path = "/mcp", + [EndpointName] string? endpointName = null) + where T : IResourceWithEndpoints + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithAnnotation(new McpServerEndpointAnnotation(async (resource, cancellationToken) => + { + var endpoints = resource.GetEndpoints(); + EndpointReference? endpoint = null; + + if (endpointName is not null) + { + endpoint = endpoints.FirstOrDefault(e => string.Equals(e.EndpointName, endpointName, StringComparisons.EndpointAnnotationName)); + + if (endpoint is null) + { + throw new DistributedApplicationException( + $"Could not create MCP server for resource '{resource.Name}' as no endpoint was found with name '{endpointName}'."); + } + } + else + { + foreach (var scheme in s_httpSchemes) + { + endpoint = endpoints.FirstOrDefault(e => string.Equals(e.EndpointName, scheme, StringComparisons.EndpointAnnotationName)); + if (endpoint is not null) + { + break; + } + } + + if (endpoint is null) + { + throw new DistributedApplicationException( + $"Could not create MCP server for resource '{resource.Name}' as no endpoint was found matching one of the specified names: {string.Join(", ", s_httpSchemes)}"); + } + } + + if (!endpoint.Exists) + { + return null; + } + + var baseUrl = await endpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrEmpty(baseUrl)) + { + return null; + } + + if (string.IsNullOrEmpty(path)) + { + return new Uri(baseUrl, UriKind.Absolute); + } + + var normalizedPath = path; + if (!normalizedPath.StartsWith("/", StringComparison.Ordinal)) + { + normalizedPath = "/" + normalizedPath; + } + + var combined = baseUrl.TrimEnd('/') + normalizedPath; + return new Uri(combined, UriKind.Absolute); + })); + } +} diff --git a/tests/Aspire.Hosting.Tests/WithMcpServerTests.cs b/tests/Aspire.Hosting.Tests/WithMcpServerTests.cs new file mode 100644 index 00000000000..121aa29752b --- /dev/null +++ b/tests/Aspire.Hosting.Tests/WithMcpServerTests.cs @@ -0,0 +1,177 @@ +// 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 ASPIREMCP001 + +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Tests; + +public class WithMcpServerTests +{ + [Fact] + public void WithMcpServer_ThrowsArgumentNullException_WhenBuilderIsNull() + { + IResourceBuilder builder = null!; + Assert.Throws(() => builder.WithMcpServer()); + } + + [Fact] + public async Task WithMcpServer_AddsMcpServerEndpointAnnotation() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + appBuilder.AddContainer("app", "image") + .WithHttpEndpoint(name: "http") + .WithMcpServer(endpointName: "http"); + + using var app = await appBuilder.BuildAsync(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var mcpAnnotation = Assert.Single(resource.Annotations.OfType()); + Assert.NotNull(mcpAnnotation.EndpointUrlResolver); + } + + [Fact] + public async Task WithMcpServer_DefaultsToHttpsEndpoint() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + appBuilder.AddContainer("app", "image") + .WithEndpoint("https", e => + { + e.UriScheme = "https"; + e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 8443); + }) + .WithHttpEndpoint(name: "http") + .WithMcpServer(); + + using var app = await appBuilder.BuildAsync(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + var mcpAnnotation = Assert.Single(resource.Annotations.OfType()); + + var resolvedUri = await mcpAnnotation.EndpointUrlResolver(resource, CancellationToken.None); + + Assert.NotNull(resolvedUri); + Assert.Equal("https://localhost:8443/mcp", resolvedUri!.ToString()); + } + + [Fact] + public async Task WithMcpServer_FallsBackToHttpEndpoint() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + appBuilder.AddContainer("app", "image") + .WithEndpoint("http", e => + { + e.UriScheme = "http"; + e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 8080); + }) + .WithMcpServer(); + + using var app = await appBuilder.BuildAsync(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + var mcpAnnotation = Assert.Single(resource.Annotations.OfType()); + + var resolvedUri = await mcpAnnotation.EndpointUrlResolver(resource, CancellationToken.None); + + Assert.NotNull(resolvedUri); + Assert.Equal("http://localhost:8080/mcp", resolvedUri!.ToString()); + } + + [Fact] + public async Task WithMcpServer_ResolvesDefaultMcpPath() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + appBuilder.AddContainer("app", "image") + .WithEndpoint("http", e => + { + e.UriScheme = "http"; + e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 8080); + }) + .WithMcpServer(endpointName: "http"); + + using var app = await appBuilder.BuildAsync(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + var mcpAnnotation = Assert.Single(resource.Annotations.OfType()); + + var resolvedUri = await mcpAnnotation.EndpointUrlResolver(resource, CancellationToken.None); + + Assert.NotNull(resolvedUri); + Assert.Equal("http://localhost:8080/mcp", resolvedUri!.ToString()); + } + + [Fact] + public async Task WithMcpServer_ResolvesCustomPath() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + appBuilder.AddContainer("app", "image") + .WithEndpoint("http", e => + { + e.UriScheme = "http"; + e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 8080); + }) + .WithMcpServer("/sse", endpointName: "http"); + + using var app = await appBuilder.BuildAsync(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + var mcpAnnotation = Assert.Single(resource.Annotations.OfType()); + + var resolvedUri = await mcpAnnotation.EndpointUrlResolver(resource, CancellationToken.None); + + Assert.NotNull(resolvedUri); + Assert.Equal("http://localhost:8080/sse", resolvedUri!.ToString()); + } + + [Fact] + public async Task WithMcpServer_ResolvesNullPath() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + appBuilder.AddContainer("app", "image") + .WithEndpoint("http", e => + { + e.UriScheme = "http"; + e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 8080); + }) + .WithMcpServer(path: null, endpointName: "http"); + + using var app = await appBuilder.BuildAsync(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + var mcpAnnotation = Assert.Single(resource.Annotations.OfType()); + + var resolvedUri = await mcpAnnotation.EndpointUrlResolver(resource, CancellationToken.None); + + Assert.NotNull(resolvedUri); + // Uri normalizes to include trailing slash for absolute URIs without path + Assert.Equal("http://localhost:8080/", resolvedUri!.ToString()); + } + + [Fact] + public void WithMcpServer_ReturnsBuilderForChaining() + { + using var appBuilder = TestDistributedApplicationBuilder.Create(); + + var container = appBuilder.AddContainer("app", "image") + .WithHttpEndpoint(name: "http"); + + var result = container.WithMcpServer(endpointName: "http"); + + Assert.Same(container, result); + } +} From f9f7acd5c48054cac27920b8124c4cfe11fcf166 Mon Sep 17 00:00:00 2001 From: "Maddy Montaquila (Leger)" Date: Mon, 23 Feb 2026 11:27:34 -0500 Subject: [PATCH 143/256] Improve aspire config list output formatting (#14600) - Render local and global configuration in Spectre.Console tables with rounded borders and titled headers for visual distinction - Show each available feature on its own line with description and default value instead of a comma-separated list - Add hint text showing how to enable/disable features via aspire config set Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/ConfigCommand.cs | 85 +++++++++++-------- .../ConfigCommandStrings.Designer.cs | 9 ++ .../Resources/ConfigCommandStrings.resx | 3 + .../Resources/xlf/ConfigCommandStrings.cs.xlf | 5 ++ .../Resources/xlf/ConfigCommandStrings.de.xlf | 5 ++ .../Resources/xlf/ConfigCommandStrings.es.xlf | 5 ++ .../Resources/xlf/ConfigCommandStrings.fr.xlf | 5 ++ .../Resources/xlf/ConfigCommandStrings.it.xlf | 5 ++ .../Resources/xlf/ConfigCommandStrings.ja.xlf | 5 ++ .../Resources/xlf/ConfigCommandStrings.ko.xlf | 5 ++ .../Resources/xlf/ConfigCommandStrings.pl.xlf | 5 ++ .../xlf/ConfigCommandStrings.pt-BR.xlf | 5 ++ .../Resources/xlf/ConfigCommandStrings.ru.xlf | 5 ++ .../Resources/xlf/ConfigCommandStrings.tr.xlf | 5 ++ .../xlf/ConfigCommandStrings.zh-Hans.xlf | 5 ++ .../xlf/ConfigCommandStrings.zh-Hant.xlf | 5 ++ 16 files changed, 127 insertions(+), 35 deletions(-) diff --git a/src/Aspire.Cli/Commands/ConfigCommand.cs b/src/Aspire.Cli/Commands/ConfigCommand.cs index 3f2ef7e7cd5..a02bccabb5b 100644 --- a/src/Aspire.Cli/Commands/ConfigCommand.cs +++ b/src/Aspire.Cli/Commands/ConfigCommand.cs @@ -230,53 +230,68 @@ public override async Task InteractiveExecuteAsync(CancellationToken cancel var featurePrefix = $"{KnownFeatures.FeaturePrefix}."; - // Display Local Configuration (including features) - if (localConfig.Count > 0) - { - InteractionService.DisplayMarkdown($"**{ConfigCommandStrings.LocalConfigurationHeader}:**"); - foreach (var kvp in localConfig.OrderBy(k => k.Key)) - { - InteractionService.DisplayMarkupLine($" [cyan]{kvp.Key.EscapeMarkup()}[/] = [yellow]{kvp.Value.EscapeMarkup()}[/]"); - } - } - else if (globalConfig.Count > 0) - { - // Only show "no local config" message if we have global config - InteractionService.DisplayMarkdown($"**{ConfigCommandStrings.LocalConfigurationHeader}:**"); - InteractionService.DisplayPlainText($" {ConfigCommandStrings.NoLocalConfigurationFound}"); - } + // Display Local Configuration + RenderConfigTable( + ConfigCommandStrings.LocalConfigurationHeader, + localConfig, + ConfigCommandStrings.NoLocalConfigurationFound); + + // Display Global Configuration + RenderConfigTable( + ConfigCommandStrings.GlobalConfigurationHeader, + globalConfig, + ConfigCommandStrings.NoGlobalConfigurationFound); + + // Display Available Features + var allConfiguredFeatures = localConfig.Concat(globalConfig) + .Where(kvp => kvp.Key.StartsWith(featurePrefix, StringComparison.Ordinal)) + .Select(kvp => kvp.Key.Substring(featurePrefix.Length)) + .ToHashSet(StringComparer.Ordinal); + + var unconfiguredFeatures = KnownFeatures.GetAllFeatureMetadata() + .Where(f => !allConfiguredFeatures.Contains(f.Name)) + .ToList(); - // Display Global Configuration (including features) - if (globalConfig.Count > 0) + if (unconfiguredFeatures.Count > 0) { InteractionService.DisplayEmptyLine(); - InteractionService.DisplayMarkdown($"**{ConfigCommandStrings.GlobalConfigurationHeader}:**"); - foreach (var kvp in globalConfig.OrderBy(k => k.Key)) + InteractionService.DisplayMarkdown($"**{ConfigCommandStrings.AvailableFeaturesHeader}:**"); + foreach (var feature in unconfiguredFeatures) { - InteractionService.DisplayMarkupLine($" [cyan]{kvp.Key.EscapeMarkup()}[/] = [yellow]{kvp.Value.EscapeMarkup()}[/]"); + var defaultText = feature.DefaultValue ? "true" : "false"; + InteractionService.DisplayMarkupLine($" [cyan]{feature.Name.EscapeMarkup()}[/] [dim](default: {defaultText})[/]"); + InteractionService.DisplayMarkupLine($" [dim]{feature.Description.EscapeMarkup()}[/]"); } - } - else if (localConfig.Count > 0) - { - // Only show "no global config" message if we have local config InteractionService.DisplayEmptyLine(); - InteractionService.DisplayMarkdown($"**{ConfigCommandStrings.GlobalConfigurationHeader}:**"); - InteractionService.DisplayPlainText($" {ConfigCommandStrings.NoGlobalConfigurationFound}"); + InteractionService.DisplayMarkupLine($" [dim]{ConfigCommandStrings.SetFeatureHint.EscapeMarkup()}[/]"); } - // Display Available Features - var allConfiguredFeatures = localConfig.Concat(globalConfig).Where(kvp => kvp.Key.StartsWith(featurePrefix, StringComparison.Ordinal)).Select(kvp => kvp.Key.Substring(featurePrefix.Length)).ToHashSet(StringComparer.Ordinal); - var availableFeatures = KnownFeatures.GetAllFeatureNames().ToList(); - var unconfiguredFeatures = availableFeatures.Where(f => !allConfiguredFeatures.Contains(f)).ToList(); + return ExitCodeConstants.Success; + } - if (unconfiguredFeatures.Count > 0) + private static void RenderConfigTable(string title, Dictionary config, string emptyMessage) + { + var table = new Table(); + table.Title = new TableTitle($"[bold]{title.EscapeMarkup()}[/]"); + table.Border(TableBorder.Rounded); + table.AddColumn(new TableColumn("[bold]Key[/]").NoWrap()); + table.AddColumn(new TableColumn("[bold]Value[/]")); + + if (config.Count > 0) { - InteractionService.DisplayEmptyLine(); - InteractionService.DisplayMarkdown($"**{ConfigCommandStrings.AvailableFeaturesHeader}:**"); - InteractionService.DisplayPlainText($" {string.Join(", ", unconfiguredFeatures)}"); + foreach (var kvp in config.OrderBy(k => k.Key)) + { + table.AddRow( + $"[cyan]{kvp.Key.EscapeMarkup()}[/]", + $"[yellow]{kvp.Value.EscapeMarkup()}[/]"); + } + } + else + { + table.AddRow($"[dim]{emptyMessage.EscapeMarkup()}[/]", ""); } - return ExitCodeConstants.Success; + AnsiConsole.Write(table); } } diff --git a/src/Aspire.Cli/Resources/ConfigCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/ConfigCommandStrings.Designer.cs index 98549a299fa..0b0f0af33dd 100644 --- a/src/Aspire.Cli/Resources/ConfigCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/ConfigCommandStrings.Designer.cs @@ -382,5 +382,14 @@ public static string InfoCommand_SettingsProperties { return ResourceManager.GetString("InfoCommand_SettingsProperties", resourceCulture); } } + + /// + /// Looks up a localized string similar to Use 'aspire config set features.<name> true|false' to enable or disable a feature.. + /// + public static string SetFeatureHint { + get { + return ResourceManager.GetString("SetFeatureHint", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/ConfigCommandStrings.resx b/src/Aspire.Cli/Resources/ConfigCommandStrings.resx index dd838efa3da..ec4f96eb718 100644 --- a/src/Aspire.Cli/Resources/ConfigCommandStrings.resx +++ b/src/Aspire.Cli/Resources/ConfigCommandStrings.resx @@ -216,4 +216,7 @@ Settings file properties + + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.cs.xlf index b6c1287b682..8cd9e1dff6a 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.cs.xlf @@ -182,6 +182,11 @@ Hodnota konfigurace, která se má nastavit + + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.de.xlf index fe5960df195..c2f42bf0470 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.de.xlf @@ -182,6 +182,11 @@ Der festzulegende Konfigurationswert. + + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.es.xlf index 0bb0dedb7ff..595f4cd722c 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.es.xlf @@ -182,6 +182,11 @@ Valor de configuración que se va a establecer. + + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.fr.xlf index ba35f3d7e8c..ebb09caf14f 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.fr.xlf @@ -182,6 +182,11 @@ La valeur de configuration à définir. + + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.it.xlf index 60e8e5b73bf..65bcd81b85a 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.it.xlf @@ -182,6 +182,11 @@ Valore di configurazione da impostare. + + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ja.xlf index 0c9d7aa0a5e..c3af2170747 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ja.xlf @@ -182,6 +182,11 @@ 設定する構成値。 + + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ko.xlf index b1f392431b4..4641da08f13 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ko.xlf @@ -182,6 +182,11 @@ 설정할 구성 값입니다. + + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pl.xlf index 0011ce03bf2..c65864490bf 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pl.xlf @@ -182,6 +182,11 @@ Wartość konfiguracji do ustawienia. + + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pt-BR.xlf index 429142d6b16..5313d5e0ab3 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pt-BR.xlf @@ -182,6 +182,11 @@ O valor de configuração a ser definido. + + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ru.xlf index 4dfe010e1b7..7a364e99268 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ru.xlf @@ -182,6 +182,11 @@ Значение конфигурации, которое следует задать. + + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.tr.xlf index 369dcbdc755..9b61096e8e4 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.tr.xlf @@ -182,6 +182,11 @@ Ayarlanacak yapılandırma değeri. + + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hans.xlf index cf2988efbdc..6984e750d63 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hans.xlf @@ -182,6 +182,11 @@ 要设置的配置值。 + + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hant.xlf index 4a3ccdccb66..fc053a9a95f 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hant.xlf @@ -182,6 +182,11 @@ 要設定的設定值。 + + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + Use 'aspire config set features.<name> true|false' to enable or disable a feature. + + \ No newline at end of file From 1de5e43900ad668355c57c4c3b97967db11028f6 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 23 Feb 2026 11:38:24 -0600 Subject: [PATCH 144/256] Remove vendored OpenTelemetry.Instrumentation.SqlClient code (#14621) The package has now shipped stable, so we can take a dependency on the official package. --- Directory.Packages.props | 3 +- .../Aspire.Microsoft.Data.SqlClient.csproj | 5 +- ...osoft.EntityFrameworkCore.SqlServer.csproj | 5 +- .../Implementation/SqlActivitySourceHelper.cs | 29 -- .../SqlClientDiagnosticListener.cs | 206 ------------ .../SqlClientInstrumentationEventSource.cs | 85 ----- .../SqlEventSourceListener.netfx.cs | 191 ----------- .../DiagnosticSourceListener.cs | 49 --- .../DiagnosticSourceSubscriber.cs | 105 ------ .../ListenerHandler.cs | 40 --- .../PropertyFetcher.cs | 218 ------------- .../Shared/ExceptionExtensions.cs | 32 -- .../Shared/Guard.cs | 203 ------------ .../Shared/SemanticConventions.cs | 121 ------- .../SqlClientInstrumentation.cs | 72 ----- .../SqlClientTraceInstrumentationOptions.cs | 303 ------------------ .../TracerProviderBuilderExtensions.cs | 82 ----- src/Vendoring/README.md | 23 -- 18 files changed, 4 insertions(+), 1768 deletions(-) delete mode 100644 src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlActivitySourceHelper.cs delete mode 100644 src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlClientDiagnosticListener.cs delete mode 100644 src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlClientInstrumentationEventSource.cs delete mode 100644 src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlEventSourceListener.netfx.cs delete mode 100644 src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/DiagnosticSourceListener.cs delete mode 100644 src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/DiagnosticSourceSubscriber.cs delete mode 100644 src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/ListenerHandler.cs delete mode 100644 src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/PropertyFetcher.cs delete mode 100644 src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/ExceptionExtensions.cs delete mode 100644 src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/Guard.cs delete mode 100644 src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/SemanticConventions.cs delete mode 100644 src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/SqlClientInstrumentation.cs delete mode 100644 src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/SqlClientTraceInstrumentationOptions.cs delete mode 100644 src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/TracerProviderBuilderExtensions.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 4f04edc0fb1..429e049f460 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -139,10 +139,11 @@ + - + diff --git a/src/Components/Aspire.Microsoft.Data.SqlClient/Aspire.Microsoft.Data.SqlClient.csproj b/src/Components/Aspire.Microsoft.Data.SqlClient/Aspire.Microsoft.Data.SqlClient.csproj index 76b8b982e9f..b3d90700d78 100644 --- a/src/Components/Aspire.Microsoft.Data.SqlClient/Aspire.Microsoft.Data.SqlClient.csproj +++ b/src/Components/Aspire.Microsoft.Data.SqlClient/Aspire.Microsoft.Data.SqlClient.csproj @@ -15,10 +15,6 @@ - - - - @@ -26,6 +22,7 @@ + diff --git a/src/Components/Aspire.Microsoft.EntityFrameworkCore.SqlServer/Aspire.Microsoft.EntityFrameworkCore.SqlServer.csproj b/src/Components/Aspire.Microsoft.EntityFrameworkCore.SqlServer/Aspire.Microsoft.EntityFrameworkCore.SqlServer.csproj index 1e48e912e5b..8fa86d6642e 100644 --- a/src/Components/Aspire.Microsoft.EntityFrameworkCore.SqlServer/Aspire.Microsoft.EntityFrameworkCore.SqlServer.csproj +++ b/src/Components/Aspire.Microsoft.EntityFrameworkCore.SqlServer/Aspire.Microsoft.EntityFrameworkCore.SqlServer.csproj @@ -16,16 +16,13 @@ - - - - + diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlActivitySourceHelper.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlActivitySourceHelper.cs deleted file mode 100644 index e4b4f73bddd..00000000000 --- a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlActivitySourceHelper.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -#nullable disable - -using System.Diagnostics; -using System.Reflection; -using OpenTelemetry.Trace; - -namespace OpenTelemetry.Instrumentation.SqlClient.Implementation; - -/// -/// Helper class to hold common properties used by both SqlClientDiagnosticListener on .NET Core -/// and SqlEventSourceListener on .NET Framework. -/// -internal static class SqlActivitySourceHelper -{ - public const string MicrosoftSqlServerDatabaseSystemName = "mssql"; - - public const string ActivitySourceName = "OpenTelemetry.Instrumentation.SqlClient"; - public static readonly Version Version = new Version(1, 7, 0, 1173); - public static readonly ActivitySource ActivitySource = new(ActivitySourceName, Version.ToString()); - public const string ActivityName = ActivitySourceName + ".Execute"; - - public static readonly IEnumerable> CreationTags = new[] - { - new KeyValuePair(SemanticConventions.AttributeDbSystem, MicrosoftSqlServerDatabaseSystemName), - }; -} diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlClientDiagnosticListener.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlClientDiagnosticListener.cs deleted file mode 100644 index 423d00e7eec..00000000000 --- a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlClientDiagnosticListener.cs +++ /dev/null @@ -1,206 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -#nullable disable - -#if !NETFRAMEWORK -using System.Data; -using System.Diagnostics; -using OpenTelemetry.Trace; -#if NET6_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; -#endif - -namespace OpenTelemetry.Instrumentation.SqlClient.Implementation; - -#if NET6_0_OR_GREATER -[RequiresUnreferencedCode(SqlClientInstrumentation.SqlClientTrimmingUnsupportedMessage)] -#endif -internal sealed class SqlClientDiagnosticListener : ListenerHandler -{ - public const string SqlDataBeforeExecuteCommand = "System.Data.SqlClient.WriteCommandBefore"; - public const string SqlMicrosoftBeforeExecuteCommand = "Microsoft.Data.SqlClient.WriteCommandBefore"; - - public const string SqlDataAfterExecuteCommand = "System.Data.SqlClient.WriteCommandAfter"; - public const string SqlMicrosoftAfterExecuteCommand = "Microsoft.Data.SqlClient.WriteCommandAfter"; - - public const string SqlDataWriteCommandError = "System.Data.SqlClient.WriteCommandError"; - public const string SqlMicrosoftWriteCommandError = "Microsoft.Data.SqlClient.WriteCommandError"; - - private readonly PropertyFetcher commandFetcher = new("Command"); - private readonly PropertyFetcher connectionFetcher = new("Connection"); - private readonly PropertyFetcher dataSourceFetcher = new("DataSource"); - private readonly PropertyFetcher databaseFetcher = new("Database"); - private readonly PropertyFetcher commandTypeFetcher = new("CommandType"); - private readonly PropertyFetcher commandTextFetcher = new("CommandText"); - private readonly PropertyFetcher exceptionFetcher = new("Exception"); - private readonly SqlClientTraceInstrumentationOptions options; - - public SqlClientDiagnosticListener(string sourceName, SqlClientTraceInstrumentationOptions options) - : base(sourceName) - { - this.options = options ?? new SqlClientTraceInstrumentationOptions(); - } - - public override bool SupportsNullActivity => true; - - public override void OnEventWritten(string name, object payload) - { - var activity = Activity.Current; - switch (name) - { - case SqlDataBeforeExecuteCommand: - case SqlMicrosoftBeforeExecuteCommand: - { - // SqlClient does not create an Activity. So the activity coming in here will be null or the root span. - activity = SqlActivitySourceHelper.ActivitySource.StartActivity( - SqlActivitySourceHelper.ActivityName, - ActivityKind.Client, - default(ActivityContext), - SqlActivitySourceHelper.CreationTags); - - if (activity == null) - { - // There is no listener or it decided not to sample the current request. - return; - } - - _ = this.commandFetcher.TryFetch(payload, out var command); - if (command == null) - { - SqlClientInstrumentationEventSource.Log.NullPayload(nameof(SqlClientDiagnosticListener), name); - activity.Stop(); - return; - } - - if (activity.IsAllDataRequested) - { - try - { - if (this.options.Filter?.Invoke(command) == false) - { - SqlClientInstrumentationEventSource.Log.CommandIsFilteredOut(activity.OperationName); - activity.IsAllDataRequested = false; - activity.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded; - return; - } - } - catch (Exception ex) - { - SqlClientInstrumentationEventSource.Log.CommandFilterException(ex); - activity.IsAllDataRequested = false; - activity.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded; - return; - } - - _ = this.connectionFetcher.TryFetch(command, out var connection); - _ = this.databaseFetcher.TryFetch(connection, out var database); - - activity.DisplayName = (string)database; - - _ = this.dataSourceFetcher.TryFetch(connection, out var dataSource); - _ = this.commandTextFetcher.TryFetch(command, out var commandText); - - activity.SetTag(SemanticConventions.AttributeDbName, (string)database); - - this.options.AddConnectionLevelDetailsToActivity((string)dataSource, activity); - - if (this.commandTypeFetcher.TryFetch(command, out CommandType commandType)) - { - switch (commandType) - { - case CommandType.StoredProcedure: - if (this.options.SetDbStatementForStoredProcedure) - { - activity.SetTag(SemanticConventions.AttributeDbStatement, (string)commandText); - } - - break; - - case CommandType.Text: - if (this.options.SetDbStatementForText) - { - activity.SetTag(SemanticConventions.AttributeDbStatement, (string)commandText); - } - - break; - - case CommandType.TableDirect: - break; - } - } - - try - { - this.options.Enrich?.Invoke(activity, "OnCustom", command); - } - catch (Exception ex) - { - SqlClientInstrumentationEventSource.Log.EnrichmentException(ex); - } - } - } - - break; - case SqlDataAfterExecuteCommand: - case SqlMicrosoftAfterExecuteCommand: - { - if (activity == null) - { - SqlClientInstrumentationEventSource.Log.NullActivity(name); - return; - } - - if (activity.Source != SqlActivitySourceHelper.ActivitySource) - { - return; - } - - activity.Stop(); - } - - break; - case SqlDataWriteCommandError: - case SqlMicrosoftWriteCommandError: - { - if (activity == null) - { - SqlClientInstrumentationEventSource.Log.NullActivity(name); - return; - } - - if (activity.Source != SqlActivitySourceHelper.ActivitySource) - { - return; - } - - try - { - if (activity.IsAllDataRequested) - { - if (this.exceptionFetcher.TryFetch(payload, out Exception exception) && exception != null) - { - activity.SetStatus(ActivityStatusCode.Error, exception.Message); - - if (this.options.RecordException) - { - activity.AddException(exception); - } - } - else - { - SqlClientInstrumentationEventSource.Log.NullPayload(nameof(SqlClientDiagnosticListener), name); - } - } - } - finally - { - activity.Stop(); - } - } - - break; - } - } -} -#endif diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlClientInstrumentationEventSource.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlClientInstrumentationEventSource.cs deleted file mode 100644 index ccd86471d78..00000000000 --- a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlClientInstrumentationEventSource.cs +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -using System.Diagnostics.Tracing; -using OpenTelemetry.Internal; - -namespace OpenTelemetry.Instrumentation.SqlClient.Implementation; - -/// -/// EventSource events emitted from the project. -/// -[EventSource(Name = "OpenTelemetry-Instrumentation-SqlClient")] -internal sealed class SqlClientInstrumentationEventSource : EventSource -{ - public static SqlClientInstrumentationEventSource Log = new(); - - [NonEvent] - public void UnknownErrorProcessingEvent(string handlerName, string eventName, Exception ex) - { - if (this.IsEnabled(EventLevel.Error, EventKeywords.All)) - { - this.UnknownErrorProcessingEvent(handlerName, eventName, ex.ToInvariantString()); - } - } - - [Event(1, Message = "Unknown error processing event '{1}' from handler '{0}', Exception: {2}", Level = EventLevel.Error)] - public void UnknownErrorProcessingEvent(string handlerName, string eventName, string ex) - { - this.WriteEvent(1, handlerName, eventName, ex); - } - - [Event(2, Message = "Current Activity is NULL in the '{0}' callback. Span will not be recorded.", Level = EventLevel.Warning)] - public void NullActivity(string eventName) - { - this.WriteEvent(2, eventName); - } - - [Event(3, Message = "Payload is NULL in event '{1}' from handler '{0}', span will not be recorded.", Level = EventLevel.Warning)] - public void NullPayload(string handlerName, string eventName) - { - this.WriteEvent(3, handlerName, eventName); - } - - [Event(4, Message = "Payload is invalid in event '{1}' from handler '{0}', span will not be recorded.", Level = EventLevel.Warning)] - public void InvalidPayload(string handlerName, string eventName) - { - this.WriteEvent(4, handlerName, eventName); - } - - [NonEvent] - public void EnrichmentException(Exception ex) - { - if (this.IsEnabled(EventLevel.Error, EventKeywords.All)) - { - this.EnrichmentException(ex.ToInvariantString()); - } - } - - [Event(5, Message = "Enrichment threw exception. Exception {0}.", Level = EventLevel.Error)] - public void EnrichmentException(string exception) - { - this.WriteEvent(5, exception); - } - - [Event(6, Message = "Command is filtered out. Activity {0}", Level = EventLevel.Verbose)] - public void CommandIsFilteredOut(string activityName) - { - this.WriteEvent(6, activityName); - } - - [NonEvent] - public void CommandFilterException(Exception ex) - { - if (this.IsEnabled(EventLevel.Error, EventKeywords.All)) - { - this.CommandFilterException(ex.ToInvariantString()); - } - } - - [Event(7, Message = "Command filter threw exception. Command will not be collected. Exception {0}.", Level = EventLevel.Error)] - public void CommandFilterException(string exception) - { - this.WriteEvent(7, exception); - } -} diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlEventSourceListener.netfx.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlEventSourceListener.netfx.cs deleted file mode 100644 index 111e4878d3c..00000000000 --- a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlEventSourceListener.netfx.cs +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -#if NETFRAMEWORK -using System.Diagnostics; -using System.Diagnostics.Tracing; -using OpenTelemetry.Trace; - -namespace OpenTelemetry.Instrumentation.SqlClient.Implementation; - -/// -/// On .NET Framework, neither System.Data.SqlClient nor Microsoft.Data.SqlClient emit DiagnosticSource events. -/// Instead they use EventSource: -/// For System.Data.SqlClient see: reference source. -/// For Microsoft.Data.SqlClient see: SqlClientEventSource. -/// -/// We hook into these event sources and process their BeginExecute/EndExecute events. -/// -/// -/// Note that before version 2.0.0, Microsoft.Data.SqlClient used -/// "Microsoft-AdoNet-SystemData" (same as System.Data.SqlClient), but since -/// 2.0.0 has switched to "Microsoft.Data.SqlClient.EventSource". -/// -internal sealed class SqlEventSourceListener : EventListener -{ - internal const string AdoNetEventSourceName = "Microsoft-AdoNet-SystemData"; - internal const string MdsEventSourceName = "Microsoft.Data.SqlClient.EventSource"; - - internal const int BeginExecuteEventId = 1; - internal const int EndExecuteEventId = 2; - - private readonly SqlClientTraceInstrumentationOptions options; - private EventSource adoNetEventSource; - private EventSource mdsEventSource; - - public SqlEventSourceListener(SqlClientTraceInstrumentationOptions options = null) - { - this.options = options ?? new SqlClientTraceInstrumentationOptions(); - } - - public override void Dispose() - { - if (this.adoNetEventSource != null) - { - this.DisableEvents(this.adoNetEventSource); - } - - if (this.mdsEventSource != null) - { - this.DisableEvents(this.mdsEventSource); - } - - base.Dispose(); - } - - protected override void OnEventSourceCreated(EventSource eventSource) - { - if (eventSource?.Name.StartsWith(AdoNetEventSourceName, StringComparison.Ordinal) == true) - { - this.adoNetEventSource = eventSource; - this.EnableEvents(eventSource, EventLevel.Informational, EventKeywords.All); - } - else if (eventSource?.Name.StartsWith(MdsEventSourceName, StringComparison.Ordinal) == true) - { - this.mdsEventSource = eventSource; - this.EnableEvents(eventSource, EventLevel.Informational, EventKeywords.All); - } - - base.OnEventSourceCreated(eventSource); - } - - protected override void OnEventWritten(EventWrittenEventArgs eventData) - { - try - { - if (eventData.EventId == BeginExecuteEventId) - { - this.OnBeginExecute(eventData); - } - else if (eventData.EventId == EndExecuteEventId) - { - this.OnEndExecute(eventData); - } - } - catch (Exception exc) - { - SqlClientInstrumentationEventSource.Log.UnknownErrorProcessingEvent(nameof(SqlEventSourceListener), nameof(this.OnEventWritten), exc); - } - } - - private void OnBeginExecute(EventWrittenEventArgs eventData) - { - /* - Expected payload: - [0] -> ObjectId - [1] -> DataSource - [2] -> Database - [3] -> CommandText - - Note: - - For "Microsoft-AdoNet-SystemData" v1.0: [3] CommandText = CommandType == CommandType.StoredProcedure ? CommandText : string.Empty; (so it is set for only StoredProcedure command types) - (https://github.com/dotnet/SqlClient/blob/v1.0.19239.1/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlCommand.cs#L6369) - - For "Microsoft-AdoNet-SystemData" v1.1: [3] CommandText = sqlCommand.CommandText (so it is set for all command types) - (https://github.com/dotnet/SqlClient/blob/v1.1.0/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlCommand.cs#L7459) - - For "Microsoft.Data.SqlClient.EventSource" v2.0+: [3] CommandText = sqlCommand.CommandText (so it is set for all command types). - (https://github.com/dotnet/SqlClient/blob/f4568ce68da21db3fe88c0e72e1287368aaa1dc8/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlCommand.cs#L6641) - */ - - if ((eventData?.Payload?.Count ?? 0) < 4) - { - SqlClientInstrumentationEventSource.Log.InvalidPayload(nameof(SqlEventSourceListener), nameof(this.OnBeginExecute)); - return; - } - - var activity = SqlActivitySourceHelper.ActivitySource.StartActivity( - SqlActivitySourceHelper.ActivityName, - ActivityKind.Client, - default(ActivityContext), - SqlActivitySourceHelper.CreationTags); - - if (activity == null) - { - // There is no listener or it decided not to sample the current request. - return; - } - - string databaseName = (string)eventData.Payload[2]; - - activity.DisplayName = databaseName; - - if (activity.IsAllDataRequested) - { - activity.SetTag(SemanticConventions.AttributeDbName, databaseName); - - this.options.AddConnectionLevelDetailsToActivity((string)eventData.Payload[1], activity); - - string commandText = (string)eventData.Payload[3]; - if (!string.IsNullOrEmpty(commandText) && this.options.SetDbStatementForText) - { - activity.SetTag(SemanticConventions.AttributeDbStatement, commandText); - } - } - } - - private void OnEndExecute(EventWrittenEventArgs eventData) - { - /* - Expected payload: - [0] -> ObjectId - [1] -> CompositeState bitmask (0b001 -> successFlag, 0b010 -> isSqlExceptionFlag , 0b100 -> synchronousFlag) - [2] -> SqlExceptionNumber - */ - - if ((eventData?.Payload?.Count ?? 0) < 3) - { - SqlClientInstrumentationEventSource.Log.InvalidPayload(nameof(SqlEventSourceListener), nameof(this.OnEndExecute)); - return; - } - - var activity = Activity.Current; - if (activity?.Source != SqlActivitySourceHelper.ActivitySource) - { - return; - } - - try - { - if (activity.IsAllDataRequested) - { - int compositeState = (int)eventData.Payload[1]; - if ((compositeState & 0b001) != 0b001) - { - if ((compositeState & 0b010) == 0b010) - { - var errorText = $"SqlExceptionNumber {eventData.Payload[2]} thrown."; - activity.SetStatus(ActivityStatusCode.Error, errorText); - } - else - { - activity.SetStatus(ActivityStatusCode.Error, "Unknown Sql failure."); - } - } - } - } - finally - { - activity.Stop(); - } - } -} -#endif diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/DiagnosticSourceListener.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/DiagnosticSourceListener.cs deleted file mode 100644 index 53d7e990d25..00000000000 --- a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/DiagnosticSourceListener.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -#nullable disable - -using System.Diagnostics; -using OpenTelemetry.Internal; - -namespace OpenTelemetry.Instrumentation; - -internal sealed class DiagnosticSourceListener : IObserver> -{ - private readonly ListenerHandler handler; - - private readonly Action logUnknownException; - - public DiagnosticSourceListener(ListenerHandler handler, Action logUnknownException) - { - Guard.ThrowIfNull(handler); - - this.handler = handler; - this.logUnknownException = logUnknownException; - } - - public void OnCompleted() - { - } - - public void OnError(Exception error) - { - } - - public void OnNext(KeyValuePair value) - { - if (!this.handler.SupportsNullActivity && Activity.Current == null) - { - return; - } - - try - { - this.handler.OnEventWritten(value.Key, value.Value); - } - catch (Exception ex) - { - this.logUnknownException?.Invoke(this.handler?.SourceName, value.Key, ex); - } - } -} diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/DiagnosticSourceSubscriber.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/DiagnosticSourceSubscriber.cs deleted file mode 100644 index 49528fea476..00000000000 --- a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/DiagnosticSourceSubscriber.cs +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -#nullable disable - -using System.Diagnostics; -using OpenTelemetry.Internal; - -namespace OpenTelemetry.Instrumentation; - -internal sealed class DiagnosticSourceSubscriber : IDisposable, IObserver -{ - private readonly List listenerSubscriptions; - private readonly Func handlerFactory; - private readonly Func diagnosticSourceFilter; - private readonly Func isEnabledFilter; - private readonly Action logUnknownException; - private long disposed; - private IDisposable allSourcesSubscription; - - public DiagnosticSourceSubscriber( - ListenerHandler handler, - Func isEnabledFilter, - Action logUnknownException) - : this(_ => handler, value => handler.SourceName == value.Name, isEnabledFilter, logUnknownException) - { - } - - public DiagnosticSourceSubscriber( - Func handlerFactory, - Func diagnosticSourceFilter, - Func isEnabledFilter, - Action logUnknownException) - { - Guard.ThrowIfNull(handlerFactory); - - this.listenerSubscriptions = new List(); - this.handlerFactory = handlerFactory; - this.diagnosticSourceFilter = diagnosticSourceFilter; - this.isEnabledFilter = isEnabledFilter; - this.logUnknownException = logUnknownException; - } - - public void Subscribe() - { - if (this.allSourcesSubscription == null) - { - this.allSourcesSubscription = DiagnosticListener.AllListeners.Subscribe(this); - } - } - - public void OnNext(DiagnosticListener value) - { - if ((Interlocked.Read(ref this.disposed) == 0) && - this.diagnosticSourceFilter(value)) - { - var handler = this.handlerFactory(value.Name); - var listener = new DiagnosticSourceListener(handler, this.logUnknownException); - var subscription = this.isEnabledFilter == null ? - value.Subscribe(listener) : - value.Subscribe(listener, this.isEnabledFilter); - - lock (this.listenerSubscriptions) - { - this.listenerSubscriptions.Add(subscription); - } - } - } - - public void OnCompleted() - { - } - - public void OnError(Exception error) - { - } - - /// - public void Dispose() - { - this.Dispose(true); - GC.SuppressFinalize(this); - } - - private void Dispose(bool disposing) - { - if (Interlocked.CompareExchange(ref this.disposed, 1, 0) == 1) - { - return; - } - - lock (this.listenerSubscriptions) - { - foreach (var listenerSubscription in this.listenerSubscriptions) - { - listenerSubscription?.Dispose(); - } - - this.listenerSubscriptions.Clear(); - } - - this.allSourcesSubscription?.Dispose(); - this.allSourcesSubscription = null; - } -} diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/ListenerHandler.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/ListenerHandler.cs deleted file mode 100644 index 98c55107a67..00000000000 --- a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/ListenerHandler.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -using System.Diagnostics; - -namespace OpenTelemetry.Instrumentation; - -/// -/// ListenerHandler base class. -/// -internal abstract class ListenerHandler -{ - /// - /// Initializes a new instance of the class. - /// - /// The name of the . - public ListenerHandler(string sourceName) - { - this.SourceName = sourceName; - } - - /// - /// Gets the name of the . - /// - public string SourceName { get; } - - /// - /// Gets a value indicating whether the supports NULL . - /// - public virtual bool SupportsNullActivity { get; } - - /// - /// Method called for an event which does not have 'Start', 'Stop' or 'Exception' as suffix. - /// - /// Custom name. - /// An object that represent the value being passed as a payload for the event. - public virtual void OnEventWritten(string name, object payload) - { - } -} diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/PropertyFetcher.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/PropertyFetcher.cs deleted file mode 100644 index a2ac797d8a3..00000000000 --- a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/DiagnosticSourceInstrumentation/PropertyFetcher.cs +++ /dev/null @@ -1,218 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -#nullable enable - -#if NETSTANDARD2_1_0_OR_GREATER || NET6_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; -#endif -using System.Reflection; - -namespace OpenTelemetry.Instrumentation; - -/// -/// PropertyFetcher fetches a property from an object. -/// -/// The type of the property being fetched. -internal sealed class PropertyFetcher -{ -#if NET6_0_OR_GREATER - private const string TrimCompatibilityMessage = "PropertyFetcher is used to access properties on objects dynamically by design and cannot be made trim compatible."; -#endif - private readonly string propertyName; - private PropertyFetch? innerFetcher; - - /// - /// Initializes a new instance of the class. - /// - /// Property name to fetch. - public PropertyFetcher(string propertyName) - { - this.propertyName = propertyName; - } - - public int NumberOfInnerFetchers => this.innerFetcher == null - ? 0 - : 1 + this.innerFetcher.NumberOfInnerFetchers; - - /// - /// Try to fetch the property from the object. - /// - /// Object to be fetched. - /// Fetched value. - /// if the property was fetched. -#if NET6_0_OR_GREATER - [RequiresUnreferencedCode(TrimCompatibilityMessage)] -#endif - public bool TryFetch( -#if NETSTANDARD2_1_0_OR_GREATER || NET6_0_OR_GREATER - [NotNullWhen(true)] -#endif - object? obj, - out T? value) - { - var innerFetcher = this.innerFetcher; - if (innerFetcher is null) - { - return TryFetchRare(obj, this.propertyName, ref this.innerFetcher, out value); - } - - return innerFetcher.TryFetch(obj, out value); - } - -#if NET6_0_OR_GREATER - [RequiresUnreferencedCode(TrimCompatibilityMessage)] -#endif - private static bool TryFetchRare(object? obj, string propertyName, ref PropertyFetch? destination, out T? value) - { - if (obj is null) - { - value = default; - return false; - } - - var fetcher = PropertyFetch.Create(obj.GetType().GetTypeInfo(), propertyName); - - if (fetcher is null) - { - value = default; - return false; - } - - destination = fetcher; - - return fetcher.TryFetch(obj, out value); - } - - // see https://github.com/dotnet/corefx/blob/master/src/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/DiagnosticSourceEventSource.cs -#if NET6_0_OR_GREATER - [RequiresUnreferencedCode(TrimCompatibilityMessage)] -#endif - private abstract class PropertyFetch - { - public abstract int NumberOfInnerFetchers { get; } - - public static PropertyFetch? Create(TypeInfo type, string propertyName) - { - var property = type.DeclaredProperties.FirstOrDefault(p => string.Equals(p.Name, propertyName, StringComparison.OrdinalIgnoreCase)) ?? type.GetProperty(propertyName); - return CreateFetcherForProperty(property); - - static PropertyFetch? CreateFetcherForProperty(PropertyInfo? propertyInfo) - { - if (propertyInfo == null || !typeof(T).IsAssignableFrom(propertyInfo.PropertyType)) - { - // returns null and wait for a valid payload to arrive. - return null; - } - - var declaringType = propertyInfo.DeclaringType; - if (declaringType!.IsValueType) - { - throw new NotSupportedException( - $"Type: {declaringType.FullName} is a value type. PropertyFetcher can only operate on reference payload types."); - } - - if (declaringType == typeof(object)) - { - // TODO: REMOVE this if branch when .NET 7 is out of support. - // This branch is never executed and is only needed for .NET 7 AOT-compiler at trimming stage; i.e., - // this is not needed in .NET 8, because the compiler is improved and call into MakeGenericMethod will be AOT-compatible. - // It is used to force the AOT compiler to create an instantiation of the method with a reference type. - // The code for that instantiation can then be reused at runtime to create instantiation over any other reference. - return CreateInstantiated(propertyInfo); - } - else - { - return DynamicInstantiationHelper(declaringType, propertyInfo); - } - - // Separated as a local function to be able to target the suppression to just this call. - // IL3050 was generated here because of the call to MakeGenericType, which is problematic in AOT if one of the type parameters is a value type; - // because the compiler might need to generate code specific to that type. - // If the type parameter is a reference type, there will be no problem; because the generated code can be shared among all reference type instantiations. -#if NET6_0_OR_GREATER - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "The code guarantees that all the generic parameters are reference types.")] -#endif - static PropertyFetch? DynamicInstantiationHelper(Type declaringType, PropertyInfo propertyInfo) - { - return (PropertyFetch?)typeof(PropertyFetch) - .GetMethod(nameof(CreateInstantiated), BindingFlags.NonPublic | BindingFlags.Static)! - .MakeGenericMethod(declaringType) // This is validated in the earlier call chain to be a reference type. - .Invoke(null, new object[] { propertyInfo })!; - } - } - } - - public abstract bool TryFetch( -#if NETSTANDARD2_1_0_OR_GREATER || NET6_0_OR_GREATER - [NotNullWhen(true)] -#endif - object? obj, - out T? value); - - // Goal: make PropertyFetcher AOT-compatible. - // AOT compiler can't guarantee correctness when call into MakeGenericType or MakeGenericMethod - // if one of the generic parameters is a value type (reference types are OK.) - // For PropertyFetcher, the decision was made to only support reference type payloads, i.e.: - // the object from which to get the property value MUST be a reference type. - // Create generics with the declared object type as a generic parameter is OK, but we need the return type - // of the property to be a value type (on top of reference types.) - // Normally, we would have a helper class like `PropertyFetchInstantiated` that takes 2 generic parameters, - // the declared object type, and the type of the property value. - // But that would mean calling MakeGenericType, with value type parameters which AOT won't support. - // - // As a workaround, Generic instantiation was split into: - // 1. The object type comes from the PropertyFetcher generic parameter. - // Compiler supports it even if it is a value type; the type is known statically during compilation - // since PropertyFetcher is used with it. - // 2. Then, the declared object type is passed as a generic parameter to a generic method on PropertyFetcher (or nested type.) - // Therefore, calling into MakeGenericMethod will only require specifying one parameter - the declared object type. - // The declared object type is guaranteed to be a reference type (throw on value type.) Thus, MakeGenericMethod is AOT compatible. - private static PropertyFetch CreateInstantiated(PropertyInfo propertyInfo) - where TDeclaredObject : class - => new PropertyFetchInstantiated(propertyInfo); - -#if NET6_0_OR_GREATER - [RequiresUnreferencedCode(TrimCompatibilityMessage)] -#endif - private sealed class PropertyFetchInstantiated : PropertyFetch - where TDeclaredObject : class - { - private readonly string propertyName; - private readonly Func propertyFetch; - private PropertyFetch? innerFetcher; - - public PropertyFetchInstantiated(PropertyInfo property) - { - this.propertyName = property.Name; - this.propertyFetch = (Func)property.GetMethod!.CreateDelegate(typeof(Func)); - } - - public override int NumberOfInnerFetchers => this.innerFetcher == null - ? 0 - : 1 + this.innerFetcher.NumberOfInnerFetchers; - - public override bool TryFetch( -#if NETSTANDARD2_1_0_OR_GREATER || NET6_0_OR_GREATER - [NotNullWhen(true)] -#endif - object? obj, - out T? value) - { - if (obj is TDeclaredObject o) - { - value = this.propertyFetch(o); - return true; - } - - var innerFetcher = this.innerFetcher; - if (innerFetcher is null) - { - return TryFetchRare(obj, this.propertyName, ref this.innerFetcher, out value); - } - - return innerFetcher.TryFetch(obj, out value); - } - } - } -} diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/ExceptionExtensions.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/ExceptionExtensions.cs deleted file mode 100644 index 9070b59c206..00000000000 --- a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/ExceptionExtensions.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -#nullable enable - -using System.Globalization; - -namespace OpenTelemetry.Internal; - -internal static class ExceptionExtensions -{ - /// - /// Returns a culture-independent string representation of the given object, - /// appropriate for diagnostics tracing. - /// - /// Exception to convert to string. - /// Exception as string with no culture. - public static string ToInvariantString(this Exception exception) - { - var originalUICulture = Thread.CurrentThread.CurrentUICulture; - - try - { - Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; - return exception.ToString(); - } - finally - { - Thread.CurrentThread.CurrentUICulture = originalUICulture; - } - } -} diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/Guard.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/Guard.cs deleted file mode 100644 index 889969ae333..00000000000 --- a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/Guard.cs +++ /dev/null @@ -1,203 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -#nullable enable - -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Runtime.CompilerServices; - -#pragma warning disable SA1402 // File may only contain a single type -#pragma warning disable SA1403 // File may only contain a single namespace -#pragma warning disable SA1649 // File name should match first type name - -#if !NET6_0_OR_GREATER -namespace System.Runtime.CompilerServices -{ - /// Allows capturing of the expressions passed to a method. - [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] - internal sealed class CallerArgumentExpressionAttribute : Attribute - { - public CallerArgumentExpressionAttribute(string parameterName) - { - this.ParameterName = parameterName; - } - - public string ParameterName { get; } - } -} -#endif - -#if !NET6_0_OR_GREATER && !NETSTANDARD2_1_OR_GREATER -namespace System.Diagnostics.CodeAnalysis -{ - /// Specifies that an output is not even if - /// the corresponding type allows it. Specifies that an input argument was - /// not when the call returns. - [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.ReturnValue, Inherited = false)] - internal sealed class NotNullAttribute : Attribute - { - } -} -#endif - -#pragma warning disable IDE0161 // Convert to file-scoped namespace -namespace OpenTelemetry.Internal -{ - /// - /// Methods for guarding against exception throwing values. - /// - internal static class Guard - { - /// - /// Throw an exception if the value is null. - /// - /// The value to check. - /// The parameter name to use in the thrown exception. - [DebuggerHidden] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void ThrowIfNull([NotNull] object? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) - { - if (value is null) - { - throw new ArgumentNullException(paramName, "Must not be null"); - } - } - - /// - /// Throw an exception if the value is null or empty. - /// - /// The value to check. - /// The parameter name to use in the thrown exception. - [DebuggerHidden] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void ThrowIfNullOrEmpty([NotNull] string? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) -#pragma warning disable CS8777 // Parameter must have a non-null value when exiting. - { - if (string.IsNullOrEmpty(value)) - { - throw new ArgumentException("Must not be null or empty", paramName); - } - } -#pragma warning restore CS8777 // Parameter must have a non-null value when exiting. - - /// - /// Throw an exception if the value is null or whitespace. - /// - /// The value to check. - /// The parameter name to use in the thrown exception. - [DebuggerHidden] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void ThrowIfNullOrWhitespace([NotNull] string? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) -#pragma warning disable CS8777 // Parameter must have a non-null value when exiting. - { - if (string.IsNullOrWhiteSpace(value)) - { - throw new ArgumentException("Must not be null or whitespace", paramName); - } - } -#pragma warning restore CS8777 // Parameter must have a non-null value when exiting. - - /// - /// Throw an exception if the value is zero. - /// - /// The value to check. - /// The message to use in the thrown exception. - /// The parameter name to use in the thrown exception. - [DebuggerHidden] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void ThrowIfZero(int value, string message = "Must not be zero", [CallerArgumentExpression(nameof(value))] string? paramName = null) - { - if (value == 0) - { - throw new ArgumentException(message, paramName); - } - } - - /// - /// Throw an exception if the value is not considered a valid timeout. - /// - /// The value to check. - /// The parameter name to use in the thrown exception. - [DebuggerHidden] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void ThrowIfInvalidTimeout(int value, [CallerArgumentExpression(nameof(value))] string? paramName = null) - { - ThrowIfOutOfRange(value, paramName, min: Timeout.Infinite, message: $"Must be non-negative or '{nameof(Timeout)}.{nameof(Timeout.Infinite)}'"); - } - - /// - /// Throw an exception if the value is not within the given range. - /// - /// The value to check. - /// The parameter name to use in the thrown exception. - /// The inclusive lower bound. - /// The inclusive upper bound. - /// The name of the lower bound. - /// The name of the upper bound. - /// An optional custom message to use in the thrown exception. - [DebuggerHidden] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void ThrowIfOutOfRange(int value, [CallerArgumentExpression(nameof(value))] string? paramName = null, int min = int.MinValue, int max = int.MaxValue, string? minName = null, string? maxName = null, string? message = null) - { - Range(value, paramName, min, max, minName, maxName, message); - } - - /// - /// Throw an exception if the value is not within the given range. - /// - /// The value to check. - /// The parameter name to use in the thrown exception. - /// The inclusive lower bound. - /// The inclusive upper bound. - /// The name of the lower bound. - /// The name of the upper bound. - /// An optional custom message to use in the thrown exception. - [DebuggerHidden] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void ThrowIfOutOfRange(double value, [CallerArgumentExpression(nameof(value))] string? paramName = null, double min = double.MinValue, double max = double.MaxValue, string? minName = null, string? maxName = null, string? message = null) - { - Range(value, paramName, min, max, minName, maxName, message); - } - - /// - /// Throw an exception if the value is not of the expected type. - /// - /// The value to check. - /// The parameter name to use in the thrown exception. - /// The type attempted to convert to. - /// The value casted to the specified type. - [DebuggerHidden] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static T ThrowIfNotOfType([NotNull] object? value, [CallerArgumentExpression(nameof(value))] string? paramName = null) - { - if (value is not T result) - { - throw new InvalidCastException($"Cannot cast '{paramName}' from '{value?.GetType().ToString() ?? "null"}' to '{typeof(T)}'"); - } - - return result; - } - - [DebuggerHidden] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void Range(T value, string? paramName, T min, T max, string? minName, string? maxName, string? message) - where T : IComparable - { - if (value.CompareTo(min) < 0 || value.CompareTo(max) > 0) - { - var minMessage = minName != null ? $": {minName}" : string.Empty; - var maxMessage = maxName != null ? $": {maxName}" : string.Empty; - var exMessage = message ?? string.Format( - CultureInfo.InvariantCulture, - "Must be in the range: [{0}{1}, {2}{3}]", - min, - minMessage, - max, - maxMessage); - throw new ArgumentOutOfRangeException(paramName, value, exMessage); - } - } - } -} diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/SemanticConventions.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/SemanticConventions.cs deleted file mode 100644 index 8628b79d237..00000000000 --- a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared/SemanticConventions.cs +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -#nullable enable - -namespace OpenTelemetry.Trace; - -/// -/// Constants for semantic attribute names outlined by the OpenTelemetry specifications. -/// and -/// . -/// -internal static class SemanticConventions -{ - // The set of constants matches the specification as of this commit. - // https://github.com/open-telemetry/semantic-conventions/blob/main/docs/general/trace.md - // https://github.com/open-telemetry/semantic-conventions/blob/main/docs/exceptions/exceptions-spans.md - public const string AttributeNetTransport = "net.transport"; - public const string AttributeNetPeerIp = "net.peer.ip"; - public const string AttributeNetPeerPort = "net.peer.port"; - public const string AttributeNetPeerName = "net.peer.name"; - public const string AttributeNetHostIp = "net.host.ip"; - public const string AttributeNetHostPort = "net.host.port"; - public const string AttributeNetHostName = "net.host.name"; - - public const string AttributeEnduserId = "enduser.id"; - public const string AttributeEnduserRole = "enduser.role"; - public const string AttributeEnduserScope = "enduser.scope"; - - public const string AttributePeerService = "peer.service"; - - public const string AttributeHttpMethod = "http.method"; - public const string AttributeHttpUrl = "http.url"; - public const string AttributeHttpTarget = "http.target"; - public const string AttributeHttpHost = "http.host"; - public const string AttributeHttpScheme = "http.scheme"; - public const string AttributeHttpStatusCode = "http.status_code"; - public const string AttributeHttpStatusText = "http.status_text"; - public const string AttributeHttpFlavor = "http.flavor"; - public const string AttributeHttpServerName = "http.server_name"; - public const string AttributeHttpRoute = "http.route"; - public const string AttributeHttpClientIP = "http.client_ip"; - public const string AttributeHttpUserAgent = "http.user_agent"; - public const string AttributeHttpRequestContentLength = "http.request_content_length"; - public const string AttributeHttpRequestContentLengthUncompressed = "http.request_content_length_uncompressed"; - public const string AttributeHttpResponseContentLength = "http.response_content_length"; - public const string AttributeHttpResponseContentLengthUncompressed = "http.response_content_length_uncompressed"; - - public const string AttributeDbSystem = "db.system"; - public const string AttributeDbConnectionString = "db.connection_string"; - public const string AttributeDbUser = "db.user"; - public const string AttributeDbMsSqlInstanceName = "db.mssql.instance_name"; - public const string AttributeDbJdbcDriverClassName = "db.jdbc.driver_classname"; - public const string AttributeDbName = "db.name"; - public const string AttributeDbStatement = "db.statement"; - public const string AttributeDbOperation = "db.operation"; - public const string AttributeDbInstance = "db.instance"; - public const string AttributeDbUrl = "db.url"; - public const string AttributeDbCassandraKeyspace = "db.cassandra.keyspace"; - public const string AttributeDbHBaseNamespace = "db.hbase.namespace"; - public const string AttributeDbRedisDatabaseIndex = "db.redis.database_index"; - public const string AttributeDbMongoDbCollection = "db.mongodb.collection"; - - public const string AttributeRpcSystem = "rpc.system"; - public const string AttributeRpcService = "rpc.service"; - public const string AttributeRpcMethod = "rpc.method"; - public const string AttributeRpcGrpcStatusCode = "rpc.grpc.status_code"; - - public const string AttributeMessageType = "message.type"; - public const string AttributeMessageId = "message.id"; - public const string AttributeMessageCompressedSize = "message.compressed_size"; - public const string AttributeMessageUncompressedSize = "message.uncompressed_size"; - - public const string AttributeFaasTrigger = "faas.trigger"; - public const string AttributeFaasExecution = "faas.execution"; - public const string AttributeFaasDocumentCollection = "faas.document.collection"; - public const string AttributeFaasDocumentOperation = "faas.document.operation"; - public const string AttributeFaasDocumentTime = "faas.document.time"; - public const string AttributeFaasDocumentName = "faas.document.name"; - public const string AttributeFaasTime = "faas.time"; - public const string AttributeFaasCron = "faas.cron"; - - public const string AttributeMessagingSystem = "messaging.system"; - public const string AttributeMessagingDestination = "messaging.destination"; - public const string AttributeMessagingDestinationKind = "messaging.destination_kind"; - public const string AttributeMessagingTempDestination = "messaging.temp_destination"; - public const string AttributeMessagingProtocol = "messaging.protocol"; - public const string AttributeMessagingProtocolVersion = "messaging.protocol_version"; - public const string AttributeMessagingUrl = "messaging.url"; - public const string AttributeMessagingMessageId = "messaging.message_id"; - public const string AttributeMessagingConversationId = "messaging.conversation_id"; - public const string AttributeMessagingPayloadSize = "messaging.message_payload_size_bytes"; - public const string AttributeMessagingPayloadCompressedSize = "messaging.message_payload_compressed_size_bytes"; - public const string AttributeMessagingOperation = "messaging.operation"; - - public const string AttributeExceptionEventName = "exception"; - public const string AttributeExceptionType = "exception.type"; - public const string AttributeExceptionMessage = "exception.message"; - public const string AttributeExceptionStacktrace = "exception.stacktrace"; - public const string AttributeErrorType = "error.type"; - - // v1.21.0 - // https://github.com/open-telemetry/semantic-conventions/blob/v1.21.0/docs/http/http-spans.md - // https://github.com/open-telemetry/semantic-conventions/blob/v1.21.0/docs/database/database-spans.md - // https://github.com/open-telemetry/semantic-conventions/blob/v1.21.0/docs/rpc/rpc-spans.md - public const string AttributeClientAddress = "client.address"; - public const string AttributeClientPort = "client.port"; - public const string AttributeHttpRequestMethod = "http.request.method"; // replaces: "http.method" (AttributeHttpMethod) - public const string AttributeHttpResponseStatusCode = "http.response.status_code"; // replaces: "http.status_code" (AttributeHttpStatusCode) - public const string AttributeNetworkProtocolVersion = "network.protocol.version"; // replaces: "http.flavor" (AttributeHttpFlavor) - public const string AttributeNetworkProtocolName = "network.protocol.name"; - public const string AttributeServerAddress = "server.address"; // replaces: "net.host.name" (AttributeNetHostName) and "net.peer.name" (AttributeNetPeerName) - public const string AttributeServerPort = "server.port"; // replaces: "net.host.port" (AttributeNetHostPort) and "net.peer.port" (AttributeNetPeerPort) - public const string AttributeServerSocketAddress = "server.socket.address"; // replaces: "net.peer.ip" (AttributeNetPeerIp) - public const string AttributeUrlFull = "url.full"; // replaces: "http.url" (AttributeHttpUrl) - public const string AttributeUrlPath = "url.path"; // replaces: "http.target" (AttributeHttpTarget) - public const string AttributeUrlScheme = "url.scheme"; // replaces: "http.scheme" (AttributeHttpScheme) - public const string AttributeUrlQuery = "url.query"; - public const string AttributeUserAgentOriginal = "user_agent.original"; // replaces: "http.user_agent" (AttributeHttpUserAgent) - public const string AttributeHttpRequestMethodOriginal = "http.request.method_original"; -} diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/SqlClientInstrumentation.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/SqlClientInstrumentation.cs deleted file mode 100644 index 3ba553e2690..00000000000 --- a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/SqlClientInstrumentation.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -#nullable disable - -#if NET6_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; -using OpenTelemetry.Instrumentation.SqlClient.Implementation; -#endif - -namespace OpenTelemetry.Instrumentation.SqlClient; - -/// -/// SqlClient instrumentation. -/// -internal sealed class SqlClientInstrumentation : IDisposable -{ - internal const string SqlClientDiagnosticListenerName = "SqlClientDiagnosticListener"; -#if NET6_0_OR_GREATER - internal const string SqlClientTrimmingUnsupportedMessage = "Trimming is not yet supported with SqlClient instrumentation."; -#endif -#if NETFRAMEWORK - private readonly SqlEventSourceListener sqlEventSourceListener; -#else - private static readonly HashSet DiagnosticSourceEvents = new() - { - "System.Data.SqlClient.WriteCommandBefore", - "Microsoft.Data.SqlClient.WriteCommandBefore", - "System.Data.SqlClient.WriteCommandAfter", - "Microsoft.Data.SqlClient.WriteCommandAfter", - "System.Data.SqlClient.WriteCommandError", - "Microsoft.Data.SqlClient.WriteCommandError", - }; - - private readonly Func isEnabled = (eventName, _, _) - => DiagnosticSourceEvents.Contains(eventName); - - private readonly DiagnosticSourceSubscriber diagnosticSourceSubscriber; -#endif - - /// - /// Initializes a new instance of the class. - /// - /// Configuration options for sql instrumentation. -#if NET6_0_OR_GREATER - [RequiresUnreferencedCode(SqlClientTrimmingUnsupportedMessage)] -#endif - public SqlClientInstrumentation( - SqlClientTraceInstrumentationOptions options = null) - { -#if NETFRAMEWORK - this.sqlEventSourceListener = new SqlEventSourceListener(options); -#else - this.diagnosticSourceSubscriber = new DiagnosticSourceSubscriber( - name => new SqlClientDiagnosticListener(name, options), - listener => listener.Name == SqlClientDiagnosticListenerName, - this.isEnabled, - SqlClientInstrumentationEventSource.Log.UnknownErrorProcessingEvent); - this.diagnosticSourceSubscriber.Subscribe(); -#endif - } - - /// - public void Dispose() - { -#if NETFRAMEWORK - this.sqlEventSourceListener?.Dispose(); -#else - this.diagnosticSourceSubscriber?.Dispose(); -#endif - } -} diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/SqlClientTraceInstrumentationOptions.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/SqlClientTraceInstrumentationOptions.cs deleted file mode 100644 index 7f62149b140..00000000000 --- a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/SqlClientTraceInstrumentationOptions.cs +++ /dev/null @@ -1,303 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 -#nullable disable - -using System.Collections.Concurrent; -using System.Data; -using System.Diagnostics; -using System.Text.RegularExpressions; -using OpenTelemetry.Trace; - -namespace OpenTelemetry.Instrumentation.SqlClient; - -/// -/// Options for . -/// -/// -/// For help and examples see: . -/// -internal class SqlClientTraceInstrumentationOptions -{ - /* - * Match... - * protocol[ ]:[ ]serverName - * serverName - * serverName[ ]\[ ]instanceName - * serverName[ ],[ ]port - * serverName[ ]\[ ]instanceName[ ],[ ]port - * - * [ ] can be any number of white-space, SQL allows it for some reason. - * - * Optional "protocol" can be "tcp", "lpc" (shared memory), or "np" (named pipes). See: - * https://docs.microsoft.com/troubleshoot/sql/connect/use-server-name-parameter-connection-string, and - * https://docs.microsoft.com/dotnet/api/system.data.sqlclient.sqlconnection.connectionstring?view=dotnet-plat-ext-5.0 - * - * In case of named pipes the Data Source string can take form of: - * np:serverName\instanceName, or - * np:\\serverName\pipe\pipeName, or - * np:\\serverName\pipe\MSSQL$instanceName\pipeName - in this case a separate regex (see NamedPipeRegex below) - * is used to extract instanceName - */ - private static readonly Regex DataSourceRegex = new("^(.*\\s*:\\s*\\\\{0,2})?(.*?)\\s*(?:[\\\\,]|$)\\s*(.*?)\\s*(?:,|$)\\s*(.*)$", RegexOptions.Compiled); - - /// - /// In a Data Source string like "np:\\serverName\pipe\MSSQL$instanceName\pipeName" match the - /// "pipe\MSSQL$instanceName" segment to extract instanceName if it is available. - /// - /// - /// - /// - private static readonly Regex NamedPipeRegex = new("pipe\\\\MSSQL\\$(.*?)\\\\", RegexOptions.Compiled); - - private static readonly ConcurrentDictionary ConnectionDetailCache = new(StringComparer.OrdinalIgnoreCase); - - /// - /// Gets or sets a value indicating whether or not the should add the names of commands as the tag. Default - /// value: . - /// - /// - /// SetDbStatementForStoredProcedure is only supported on .NET - /// and .NET Core runtimes. - /// - public bool SetDbStatementForStoredProcedure { get; set; } = true; - - /// - /// Gets or sets a value indicating whether or not the should add the text of commands as - /// the tag. - /// Default value: . - /// - /// - /// - /// WARNING: SetDbStatementForText will capture the raw - /// CommandText. Make sure your CommandText property never - /// contains any sensitive data. - /// - /// SetDbStatementForText is supported on all runtimes. - /// - /// On .NET and .NET Core SetDbStatementForText only applies to - /// SqlCommands with . - /// On .NET Framework SetDbStatementForText applies to all - /// SqlCommands regardless of . - /// - /// When using System.Data.SqlClient use - /// SetDbStatementForText to capture StoredProcedure command - /// names. - /// When using Microsoft.Data.SqlClient use - /// SetDbStatementForText to capture Text, StoredProcedure, and all - /// other command text. - /// - /// - /// - /// - public bool SetDbStatementForText { get; set; } - - /// - /// Gets or sets a value indicating whether or not the should parse the DataSource on a - /// SqlConnection into server name, instance name, and/or port - /// connection-level attribute tags. Default value: . - /// - /// - /// - /// EnableConnectionLevelAttributes is supported on all runtimes. - /// - /// - /// The default behavior is to set the SqlConnection DataSource as the tag. - /// If enabled, SqlConnection DataSource will be parsed and the server name will be sent as the - /// or tag, - /// the instance name will be sent as the tag, - /// and the port will be sent as the tag if it is not 1433 (the default port). - /// - /// - public bool EnableConnectionLevelAttributes { get; set; } - - /// - /// Gets or sets an action to enrich an with the - /// raw SqlCommand object. - /// - /// - /// Enrich is only executed on .NET and .NET Core - /// runtimes. - /// The parameters passed to the enrich action are: - /// - /// The being enriched. - /// The name of the event. Currently only "OnCustom" is - /// used but more events may be added in the future. - /// The raw SqlCommand object from which additional - /// information can be extracted to enrich the . - /// - /// - public Action Enrich { get; set; } - - /// - /// Gets or sets a filter function that determines whether or not to - /// collect telemetry about a command. - /// - /// - /// Filter is only executed on .NET and .NET Core - /// runtimes. - /// Notes: - /// - /// The first parameter passed to the filter function is the raw - /// SqlCommand object for the command being executed. - /// The return value for the filter function is interpreted as: - /// - /// If filter returns , the command is - /// collected. - /// If filter returns or throws an - /// exception the command is NOT collected. - /// - /// - /// - public Func Filter { get; set; } - - /// - /// Gets or sets a value indicating whether the exception will be - /// recorded as or not. Default value: . - /// - /// - /// RecordException is only supported on .NET and .NET Core - /// runtimes. - /// For specification details see: . - /// - public bool RecordException { get; set; } - - internal static SqlConnectionDetails ParseDataSource(string dataSource) - { - Match match = DataSourceRegex.Match(dataSource); - - string serverHostName = match.Groups[2].Value; - string serverIpAddress = null; - - string instanceName; - - var uriHostNameType = Uri.CheckHostName(serverHostName); - if (uriHostNameType == UriHostNameType.IPv4 || uriHostNameType == UriHostNameType.IPv6) - { - serverIpAddress = serverHostName; - serverHostName = null; - } - - string maybeProtocol = match.Groups[1].Value; - bool isNamedPipe = maybeProtocol.Length > 0 && - maybeProtocol.StartsWith("np", StringComparison.OrdinalIgnoreCase); - - if (isNamedPipe) - { - string pipeName = match.Groups[3].Value; - if (pipeName.Length > 0) - { - var namedInstancePipeMatch = NamedPipeRegex.Match(pipeName); - if (namedInstancePipeMatch.Success) - { - instanceName = namedInstancePipeMatch.Groups[1].Value; - return new SqlConnectionDetails - { - ServerHostName = serverHostName, - ServerIpAddress = serverIpAddress, - InstanceName = instanceName, - Port = null, - }; - } - } - - return new SqlConnectionDetails - { - ServerHostName = serverHostName, - ServerIpAddress = serverIpAddress, - InstanceName = null, - Port = null, - }; - } - - string port; - if (match.Groups[4].Length > 0) - { - instanceName = match.Groups[3].Value; - port = match.Groups[4].Value; - if (port == "1433") - { - port = null; - } - } - else if (int.TryParse(match.Groups[3].Value, out int parsedPort)) - { - port = parsedPort == 1433 ? null : match.Groups[3].Value; - instanceName = null; - } - else - { - instanceName = match.Groups[3].Value; - - if (string.IsNullOrEmpty(instanceName)) - { - instanceName = null; - } - - port = null; - } - - return new SqlConnectionDetails - { - ServerHostName = serverHostName, - ServerIpAddress = serverIpAddress, - InstanceName = instanceName, - Port = port, - }; - } - - internal void AddConnectionLevelDetailsToActivity(string dataSource, Activity sqlActivity) - { - if (!this.EnableConnectionLevelAttributes) - { - sqlActivity.SetTag(SemanticConventions.AttributePeerService, dataSource); - } - else - { - if (!ConnectionDetailCache.TryGetValue(dataSource, out SqlConnectionDetails connectionDetails)) - { - connectionDetails = ParseDataSource(dataSource); - ConnectionDetailCache.TryAdd(dataSource, connectionDetails); - } - - if (!string.IsNullOrEmpty(connectionDetails.InstanceName)) - { - sqlActivity.SetTag(SemanticConventions.AttributeDbMsSqlInstanceName, connectionDetails.InstanceName); - } - - if (!string.IsNullOrEmpty(connectionDetails.ServerHostName)) - { - sqlActivity.SetTag(SemanticConventions.AttributeServerAddress, connectionDetails.ServerHostName); - } - else - { - sqlActivity.SetTag(SemanticConventions.AttributeServerSocketAddress, connectionDetails.ServerIpAddress); - } - - if (!string.IsNullOrEmpty(connectionDetails.Port)) - { - // TODO: Should we continue to emit this if the default port (1433) is being used? - sqlActivity.SetTag(SemanticConventions.AttributeServerPort, connectionDetails.Port); - } - } - } - - internal sealed class SqlConnectionDetails - { - public string ServerHostName { get; set; } - - public string ServerIpAddress { get; set; } - - public string InstanceName { get; set; } - - public string Port { get; set; } - } -} diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/TracerProviderBuilderExtensions.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/TracerProviderBuilderExtensions.cs deleted file mode 100644 index c316d650806..00000000000 --- a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/TracerProviderBuilderExtensions.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 -#nullable disable - -#if NET6_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; -#endif -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using OpenTelemetry.Instrumentation.SqlClient; -using OpenTelemetry.Instrumentation.SqlClient.Implementation; -using OpenTelemetry.Internal; - -namespace OpenTelemetry.Trace; - -/// -/// Extension methods to simplify registering of dependency instrumentation. -/// -internal static class TracerProviderBuilderExtensions -{ - /// - /// Enables SqlClient instrumentation. - /// - /// being configured. - /// The instance of to chain the calls. -#if NET6_0_OR_GREATER - [RequiresUnreferencedCode(SqlClientInstrumentation.SqlClientTrimmingUnsupportedMessage)] -#endif - public static TracerProviderBuilder AddSqlClientInstrumentation(this TracerProviderBuilder builder) - => AddSqlClientInstrumentation(builder, name: null, configureSqlClientTraceInstrumentationOptions: null); - - /// - /// Enables SqlClient instrumentation. - /// - /// being configured. - /// Callback action for configuring . - /// The instance of to chain the calls. -#if NET6_0_OR_GREATER - [RequiresUnreferencedCode(SqlClientInstrumentation.SqlClientTrimmingUnsupportedMessage)] -#endif - public static TracerProviderBuilder AddSqlClientInstrumentation( - this TracerProviderBuilder builder, - Action configureSqlClientTraceInstrumentationOptions) - => AddSqlClientInstrumentation(builder, name: null, configureSqlClientTraceInstrumentationOptions); - - /// - /// Enables SqlClient instrumentation. - /// - /// being configured. - /// Name which is used when retrieving options. - /// Callback action for configuring . - /// The instance of to chain the calls. -#if NET6_0_OR_GREATER - [RequiresUnreferencedCode(SqlClientInstrumentation.SqlClientTrimmingUnsupportedMessage)] -#endif - - public static TracerProviderBuilder AddSqlClientInstrumentation( - this TracerProviderBuilder builder, - string name, - Action configureSqlClientTraceInstrumentationOptions) - { - Guard.ThrowIfNull(builder); - - name ??= Options.DefaultName; - - if (configureSqlClientTraceInstrumentationOptions != null) - { - builder.ConfigureServices(services => services.Configure(name, configureSqlClientTraceInstrumentationOptions)); - } - - builder.AddInstrumentation(sp => - { - var sqlOptions = sp.GetRequiredService>().Get(name); - - return new SqlClientInstrumentation(sqlOptions); - }); - - builder.AddSource(SqlActivitySourceHelper.ActivitySourceName); - - return builder; - } -} diff --git a/src/Vendoring/README.md b/src/Vendoring/README.md index 66cfe68e41d..09cc8343bda 100644 --- a/src/Vendoring/README.md +++ b/src/Vendoring/README.md @@ -1,28 +1,5 @@ # Vendoring code sync instructions -## OpenTelemetry.Instrumentation.SqlClient - -```console -git clone https://github.com/open-telemetry/opentelemetry-dotnet.git -git fetch --tags -git checkout tags/Instrumentation.SqlClient-1.7.0-beta.1 -``` - -### Instructions - -- Copy files from `src/OpenTelemetry.Instrumentation.SqlClient` to `src/Vendoring/OpenTelemetry.Instrumentation.SqlClient`: - - `**\*.cs` minus `AssemblyInfo.cs` -- Update `SqlActivitySourceHelper` with: - ```csharp - public const string ActivitySourceName = "OpenTelemetry.Instrumentation.SqlClient"; - public static readonly Version Version = new Version(1, 7, 0, 1173); - ``` -- Copy files from `src/Shared` to `src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Shared`: - - `DiagnosticSourceInstrumentation\*.cs` - - `ExceptionExtensions.cs` - - `Guard.cs` - - `SemanticConventions.cs` - ## OpenTelemetry.Instrumentation.ConfluentKafka ```console From 58cad5c247f5e743aabf5c439d13e41ab0aea510 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Mon, 23 Feb 2026 11:52:00 -0600 Subject: [PATCH 145/256] Add ACA Managed Redis deployment E2E test (#14584) * Add ACA Managed Redis deployment E2E test Add end-to-end deployment test that validates Azure Managed Redis with Entra ID authentication works when deployed to Azure Container Apps. The test: - Creates a React starter app with Redis enabled - Replaces AddRedis with AddAzureManagedRedis in the AppHost - Adds WithAzureAuthentication to the Server project's Redis config - Registers Microsoft.Cache provider for zone support - Deploys to ACA and verifies endpoints - Validates /api/weatherforecast returns valid JSON Detects both PIPELINE SUCCEEDED and PIPELINE FAILED for fast failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update test to latest patterns --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AcaManagedRedisDeploymentTests.cs | 387 ++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AcaManagedRedisDeploymentTests.cs diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaManagedRedisDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaManagedRedisDeploymentTests.cs new file mode 100644 index 00000000000..1caee7f4023 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaManagedRedisDeploymentTests.cs @@ -0,0 +1,387 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for deploying Aspire React starter template with Azure Managed Redis to Azure Container Apps. +/// Validates that Azure Managed Redis with Entra ID authentication (WithAzureAuthentication) works end-to-end. +/// +public sealed class AcaManagedRedisDeploymentTests(ITestOutputHelper output) +{ + // Azure Managed Redis typically provisions in ~5 minutes. + // Set timeout to 30 minutes to allow for provisioning plus app deployment. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task DeployStarterWithManagedRedisToAzureContainerApps() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await DeployStarterWithManagedRedisToAzureContainerAppsCore(cancellationToken); + } + + private async Task DeployStarterWithManagedRedisToAzureContainerAppsCore(CancellationToken cancellationToken) + { + // Validate prerequisites + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var startTime = DateTime.UtcNow; + var deploymentUrls = new Dictionary(); + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("acaredis"); + var projectName = "AcaRedis"; + + output.WriteLine($"Test: {nameof(DeployStarterWithManagedRedisToAzureContainerApps)}"); + output.WriteLine($"Project Name: {projectName}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); + var pendingRun = terminal.RunAsync(cancellationToken); + + // Pattern searchers for aspire new interactive prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find($"Enter the project name ({workspace.WorkspaceRoot.Name}): "); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find("Enter the output path:"); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + // Pattern searchers for aspire add prompts + var waitingForAddVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + var waitingForIntegrationSelectionPrompt = new CellPatternSearcher() + .Find("Select an integration to add:"); + + // Pattern searchers for deployment outcome + var waitingForPipelineSucceeded = new CellPatternSearcher() + .Find("PIPELINE SUCCEEDED"); + + var waitingForPipelineFailed = new CellPatternSearcher() + .Find("PIPELINE FAILED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 1b: Register Microsoft.Cache provider (required for Azure Managed Redis zone support) + output.WriteLine("Step 1b: Registering Microsoft.Cache resource provider..."); + sequenceBuilder + .Type("az provider register --namespace Microsoft.Cache --wait") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 2: Set up CLI environment (in CI) + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI from local build..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create starter project (React) with Redis enabled + output.WriteLine("Step 3: Creating React starter project with Redis..."); + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Key(Hex1b.Input.Hex1bKey.DownArrow) // Move to second template (Starter App ASP.NET Core/React) + .Enter() + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Type(projectName) + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Accept default output path + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "No" for localhost URLs (default) + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() // Select "Yes" for Redis Cache (first/default option) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 4: Navigate to project directory + output.WriteLine("Step 4: Navigating to project directory..."); + sequenceBuilder + .Type($"cd {projectName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 5: Add Aspire.Hosting.Azure.AppContainers package + output.WriteLine("Step 5: Adding Azure Container Apps hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 6: Add Aspire.Hosting.Azure.Redis package + output.WriteLine("Step 6: Adding Azure Redis hosting package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.Redis") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForAddVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); // select first version (PR build) + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 7: Add Aspire.Microsoft.Azure.StackExchangeRedis to Server project for WithAzureAuthentication + // Use --prerelease because this package may only be available as a prerelease version + output.WriteLine("Step 7: Adding Azure StackExchange Redis client package to Server project..."); + sequenceBuilder + .Type($"dotnet add {projectName}.Server/{projectName}.Server.csproj package Aspire.Microsoft.Azure.StackExchangeRedis --prerelease") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(120)); + + // Step 8: Modify AppHost.cs - Replace AddRedis with AddAzureManagedRedis and add ACA environment + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var appHostDir = Path.Combine(projectDir, $"{projectName}.AppHost"); + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + + output.WriteLine($"Modifying AppHost.cs at: {appHostFilePath}"); + + var content = File.ReadAllText(appHostFilePath); + + // Replace AddRedis("cache") with AddAzureManagedRedis("cache") + content = content.Replace( + "builder.AddRedis(\"cache\")", + "builder.AddAzureManagedRedis(\"cache\")"); + + // Insert the Azure Container App Environment before builder.Build().Run(); + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +// Add Azure Container App Environment for deployment +builder.AddAzureContainerAppEnvironment("infra"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified AppHost.cs: replaced AddRedis with AddAzureManagedRedis, added ACA environment"); + output.WriteLine($"New content:\n{content}"); + }); + + // Step 9: Modify Server Program.cs - Add WithAzureAuthentication for Azure Managed Redis + sequenceBuilder.ExecuteCallback(() => + { + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, projectName); + var serverDir = Path.Combine(projectDir, $"{projectName}.Server"); + var programFilePath = Path.Combine(serverDir, "Program.cs"); + + output.WriteLine($"Modifying Server Program.cs at: {programFilePath}"); + + var content = File.ReadAllText(programFilePath); + + // The React template uses AddRedisClientBuilder("cache").WithOutputCache() + // Add .WithAzureAuthentication() to the chain + content = content.Replace( + ".WithOutputCache();", + """ +.WithOutputCache() + .WithAzureAuthentication(); +"""); + + File.WriteAllText(programFilePath, content); + + output.WriteLine($"Modified Server Program.cs: added WithAzureAuthentication to Redis client builder"); + output.WriteLine($"New content:\n{content}"); + }); + + // Step 10: Navigate to AppHost project directory + output.WriteLine("Step 10: Navigating to AppHost directory..."); + sequenceBuilder + .Type($"cd {projectName}.AppHost") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 11: Set environment variables for deployment + // Use eastus for Azure Managed Redis availability zone support + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && export AZURE__LOCATION=eastus && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 12: Deploy to Azure Container Apps + // Azure Managed Redis provisioning typically takes ~5 minutes + output.WriteLine("Step 12: Starting Azure Container Apps deployment..."); + sequenceBuilder + .Type("aspire deploy --clear-cache") + .Enter() + // Wait for pipeline to complete - detect both success and failure to fail fast + .WaitUntil(s => + waitingForPipelineSucceeded.Search(s).Count > 0 || + waitingForPipelineFailed.Search(s).Count > 0, + TimeSpan.FromMinutes(30)) + .ExecuteCallback(() => + { + // This callback runs after the pipeline completes - we'll verify success in the prompt check + output.WriteLine("Pipeline completed, checking result..."); + }) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // 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..."); + sequenceBuilder + .Type($"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 && " + + "urls=$(az containerapp list -g \"$RG_NAME\" --query \"[].properties.configuration.ingress.fqdn\" -o tsv 2>/dev/null | grep -v '\\.internal\\.') && " + + "if [ -z \"$urls\" ]; then echo \"❌ No external container app endpoints found\"; exit 1; fi && " + + "failed=0 && " + + "for url in $urls; do " + + "echo \"Checking https://$url...\"; " + + "success=0; " + + "for i in $(seq 1 18); do " + + "STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" \"https://$url\" --max-time 10 2>/dev/null); " + + "if [ \"$STATUS\" = \"200\" ] || [ \"$STATUS\" = \"302\" ]; then echo \" ✅ $STATUS (attempt $i)\"; success=1; break; fi; " + + "echo \" Attempt $i: $STATUS, retrying in 10s...\"; sleep 10; " + + "done; " + + "if [ \"$success\" -eq 0 ]; then echo \" ❌ Failed after 18 attempts\"; failed=1; fi; " + + "done && " + + "if [ \"$failed\" -ne 0 ]; then echo \"❌ One or more endpoint checks failed\"; exit 1; fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 14: Verify /api/weatherforecast returns valid JSON (exercises Redis output cache) + output.WriteLine("Step 14: Verifying /api/weatherforecast returns valid JSON..."); + sequenceBuilder + .Type($"RG_NAME=\"{resourceGroupName}\" && " + + // Get the server container app FQDN + "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 && " + + "echo \"Server FQDN: $SERVER_FQDN\" && " + + // Retry fetching /api/weatherforecast and validate JSON + "success=0 && " + + "for i in $(seq 1 18); do " + + "RESPONSE=$(curl -s \"https://$SERVER_FQDN/api/weatherforecast\" --max-time 10 2>/dev/null) && " + + "echo \"$RESPONSE\" | python3 -m json.tool > /dev/null 2>&1 && " + + "echo \" ✅ Valid JSON response (attempt $i)\" && echo \"$RESPONSE\" | head -c 200 && echo && success=1 && break; " + + "echo \" Attempt $i: not valid JSON yet, retrying in 10s...\"; sleep 10; " + + "done && " + + "if [ \"$success\" -eq 0 ]; then echo \"❌ /api/weatherforecast did not return valid JSON after 18 attempts\"; exit 1; fi") + .Enter() + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(5)); + + // Step 15: Exit terminal + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Deployment completed in {duration}"); + + // Report success + DeploymentReporter.ReportDeploymentSuccess( + nameof(DeployStarterWithManagedRedisToAzureContainerApps), + resourceGroupName, + deploymentUrls, + duration); + + output.WriteLine("✅ Test passed - Aspire starter with Azure Managed Redis deployed to ACA!"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployStarterWithManagedRedisToAzureContainerApps), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + // Clean up the resource group + output.WriteLine($"Triggering cleanup of resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName, output); + DeploymentReporter.ReportCleanupStatus(resourceGroupName, success: true, "Cleanup triggered (fire-and-forget)"); + } + } + + /// + /// Triggers cleanup of a specific resource group. + /// This is fire-and-forget - the hourly cleanup workflow handles any missed resources. + /// + private static void TriggerCleanupResourceGroup(string resourceGroupName, ITestOutputHelper output) + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + } + } +} From d131e938ab1a6bd0763909e0c101b50d34744606 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 07:16:16 +1100 Subject: [PATCH 146/256] Add [Experimental] attribute to WithCompactResourceNaming API (#14583) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --- .../AzureContainerAppExtensions.cs | 2 ++ tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs | 1 + 2 files changed, 3 insertions(+) diff --git a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs index 92710731082..e9352549ffd 100644 --- a/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs +++ b/src/Aspire.Hosting.Azure.AppContainers/AzureContainerAppExtensions.cs @@ -4,6 +4,7 @@ #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.Diagnostics; +using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Aspire.Hosting.Azure.AppContainers; @@ -437,6 +438,7 @@ public static IResourceBuilder WithAzdReso /// Use to change those names as well. /// /// + [Experimental("ASPIREACANAMING001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] public static IResourceBuilder WithCompactResourceNaming(this IResourceBuilder builder) { builder.Resource.UseCompactResourceNaming = true; diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs index e9a0bb6c563..805b116aead 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureContainerAppsTests.cs @@ -6,6 +6,7 @@ #pragma warning disable ASPIREAZURE002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable ASPIREACANAMING001 // 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; From c258da6ae76d05ed2a3a8042355236909f7e4c20 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 24 Feb 2026 08:52:33 +1100 Subject: [PATCH 147/256] Handle malformed MCP JSON config files gracefully (#14537) * Handle malformed MCP JSON config files gracefully When 'aspire mcp init' or 'aspire agent init' encounters an MCP config file (e.g., .vscode/mcp.json) containing empty or malformed JSON, the CLI previously crashed with an unhandled JsonReaderException. This change wraps JsonNode.Parse() calls in try-catch blocks in all four agent environment scanner Apply methods (VsCode, CopilotCli, ClaudeCode, OpenCode). On malformed JSON, a descriptive InvalidOperationException is thrown with the file path, which is caught by AgentInitCommand and displayed as a user-friendly error. The malformed file is NOT overwritten, preserving the user's content so they can fix it manually. Fixes #14394 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add explicit skip message and non-zero exit code for malformed config files When a malformed JSON config file is encountered, now displays: - The error message identifying the file - An explicit 'Skipping update of ...' message - Returns a non-zero exit code (1) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add E2E test for malformed MCP JSON config handling Adds AgentInitCommand_WithMalformedMcpJson_ShowsErrorAndExitsNonZero which: 1. Creates a .vscode folder with a malformed mcp.json 2. Runs 'aspire agent init' and selects VS Code configuration 3. Verifies the error message about malformed JSON appears 4. Verifies the 'Skipping' message appears 5. Verifies the command exits with non-zero exit code 6. Verifies the original malformed file was NOT overwritten Also adds WaitForErrorPrompt and CreateMalformedMcpConfig helpers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix E2E test: handle additional options prompt in agent init The agent init command shows two multi-select prompts: 1. Agent environments (VS Code, OpenCode, Claude Code) 2. Additional options (skill files, Playwright) The test was missing handling for the second prompt, causing a timeout. Spectre.Console MultiSelectionPrompt requires at least one selection, so we select the first skill file option. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Extract shared McpConfigFileHelper to reduce duplication Move duplicated JSON config file reading and server-check logic from 4 scanner files into a shared McpConfigFileHelper class with two methods: - HasServerConfigured: sync read + parse + check (for Has* methods) - ReadConfigAsync: async read + parse with error handling (for Apply*) Both accept an optional preprocessContent delegate for JSONC support (used by OpenCode scanner). This removes ~360 lines of duplicated boilerplate across VsCode, CopilotCli, ClaudeCode, and OpenCode scanners. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Display partial success warning when agent init encounters errors When some applicators fail due to malformed JSON config files, display a warning message 'Configuration completed with errors' instead of silently returning a non-zero exit code. The success message is only shown when all applicators succeed. Also update the E2E test to verify the new warning message is displayed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add malformed JSON tests for CopilotCli, ClaudeCode, and OpenCode scanners Address review feedback to add test coverage for malformed JSON handling across all agent environment scanners, not just VsCode. Each scanner now has tests for malformed JSON, empty files, and file preservation. 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> --- .../ClaudeCodeAgentEnvironmentScanner.cs | 82 +---------- .../CopilotCliAgentEnvironmentScanner.cs | 82 +---------- src/Aspire.Cli/Agents/McpConfigFileHelper.cs | 92 +++++++++++++ .../OpenCodeAgentEnvironmentScanner.cs | 95 ++----------- .../VsCode/VsCodeAgentEnvironmentScanner.cs | 83 +----------- src/Aspire.Cli/Commands/AgentInitCommand.cs | 28 +++- .../Resources/AgentCommandStrings.Designer.cs | 27 ++++ .../Resources/AgentCommandStrings.resx | 9 ++ .../Resources/xlf/AgentCommandStrings.cs.xlf | 15 ++ .../Resources/xlf/AgentCommandStrings.de.xlf | 15 ++ .../Resources/xlf/AgentCommandStrings.es.xlf | 15 ++ .../Resources/xlf/AgentCommandStrings.fr.xlf | 15 ++ .../Resources/xlf/AgentCommandStrings.it.xlf | 15 ++ .../Resources/xlf/AgentCommandStrings.ja.xlf | 15 ++ .../Resources/xlf/AgentCommandStrings.ko.xlf | 15 ++ .../Resources/xlf/AgentCommandStrings.pl.xlf | 15 ++ .../xlf/AgentCommandStrings.pt-BR.xlf | 15 ++ .../Resources/xlf/AgentCommandStrings.ru.xlf | 15 ++ .../Resources/xlf/AgentCommandStrings.tr.xlf | 15 ++ .../xlf/AgentCommandStrings.zh-Hans.xlf | 15 ++ .../xlf/AgentCommandStrings.zh-Hant.xlf | 15 ++ .../AgentCommandTests.cs | 114 ++++++++++++++++ .../Helpers/CliE2ETestHelpers.cs | 52 +++++++ .../ClaudeCodeAgentEnvironmentScannerTests.cs | 128 ++++++++++++++++++ .../CopilotCliAgentEnvironmentScannerTests.cs | 82 +++++++++++ .../OpenCodeAgentEnvironmentScannerTests.cs | 108 +++++++++++++++ .../VsCodeAgentEnvironmentScannerTests.cs | 82 +++++++++++ 27 files changed, 934 insertions(+), 325 deletions(-) create mode 100644 src/Aspire.Cli/Agents/McpConfigFileHelper.cs create mode 100644 tests/Aspire.Cli.Tests/Agents/ClaudeCodeAgentEnvironmentScannerTests.cs create mode 100644 tests/Aspire.Cli.Tests/Agents/OpenCodeAgentEnvironmentScannerTests.cs diff --git a/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeAgentEnvironmentScanner.cs b/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeAgentEnvironmentScanner.cs index b71fa516610..1b613bd6b25 100644 --- a/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeAgentEnvironmentScanner.cs +++ b/src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeAgentEnvironmentScanner.cs @@ -179,34 +179,7 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok private static bool HasAspireServerConfigured(DirectoryInfo repoRoot) { var configFilePath = Path.Combine(repoRoot.FullName, McpConfigFileName); - - if (!File.Exists(configFilePath)) - { - return false; - } - - try - { - var content = File.ReadAllText(configFilePath); - var config = JsonNode.Parse(content)?.AsObject(); - - if (config is null) - { - return false; - } - - if (config.TryGetPropertyValue("mcpServers", out var serversNode) && serversNode is JsonObject servers) - { - return servers.ContainsKey(AspireServerName); - } - - return false; - } - catch (JsonException) - { - // If the JSON is malformed, assume aspire is not configured - return false; - } + return McpConfigFileHelper.HasServerConfigured(configFilePath, "mcpServers", AspireServerName); } /// @@ -215,32 +188,7 @@ private static bool HasAspireServerConfigured(DirectoryInfo repoRoot) private static bool HasPlaywrightServerConfigured(DirectoryInfo repoRoot) { var configFilePath = Path.Combine(repoRoot.FullName, McpConfigFileName); - - if (!File.Exists(configFilePath)) - { - return false; - } - - try - { - var content = File.ReadAllText(configFilePath); - var config = JsonNode.Parse(content)?.AsObject(); - if (config is null) - { - return false; - } - - if (config.TryGetPropertyValue("mcpServers", out var serversNode) && serversNode is JsonObject servers) - { - return servers.ContainsKey("playwright"); - } - - return false; - } - catch (JsonException) - { - return false; - } + return McpConfigFileHelper.HasServerConfigured(configFilePath, "mcpServers", "playwright"); } /// @@ -261,18 +209,7 @@ private static async Task ApplyAspireMcpConfigurationAsync( CancellationToken cancellationToken) { var configFilePath = Path.Combine(repoRoot.FullName, McpConfigFileName); - JsonObject config; - - // Read existing config or create new - if (File.Exists(configFilePath)) - { - var existingContent = await File.ReadAllTextAsync(configFilePath, cancellationToken); - config = JsonNode.Parse(existingContent)?.AsObject() ?? new JsonObject(); - } - else - { - config = new JsonObject(); - } + var config = await McpConfigFileHelper.ReadConfigAsync(configFilePath, cancellationToken); // Ensure "mcpServers" object exists if (!config.ContainsKey("mcpServers") || config["mcpServers"] is not JsonObject) @@ -302,18 +239,7 @@ private static async Task ApplyPlaywrightMcpConfigurationAsync( CancellationToken cancellationToken) { var configFilePath = Path.Combine(repoRoot.FullName, McpConfigFileName); - JsonObject config; - - // Read existing config or create new - if (File.Exists(configFilePath)) - { - var existingContent = await File.ReadAllTextAsync(configFilePath, cancellationToken); - config = JsonNode.Parse(existingContent)?.AsObject() ?? new JsonObject(); - } - else - { - config = new JsonObject(); - } + var config = await McpConfigFileHelper.ReadConfigAsync(configFilePath, cancellationToken); // Ensure "mcpServers" object exists if (!config.ContainsKey("mcpServers") || config["mcpServers"] is not JsonObject) diff --git a/src/Aspire.Cli/Agents/CopilotCli/CopilotCliAgentEnvironmentScanner.cs b/src/Aspire.Cli/Agents/CopilotCli/CopilotCliAgentEnvironmentScanner.cs index 68d5e3e6410..cb40cf3da30 100644 --- a/src/Aspire.Cli/Agents/CopilotCli/CopilotCliAgentEnvironmentScanner.cs +++ b/src/Aspire.Cli/Agents/CopilotCli/CopilotCliAgentEnvironmentScanner.cs @@ -162,34 +162,7 @@ private static string GetMcpConfigFilePath(DirectoryInfo homeDirectory) private static bool HasAspireServerConfigured(DirectoryInfo homeDirectory) { var configFilePath = GetMcpConfigFilePath(homeDirectory); - - if (!File.Exists(configFilePath)) - { - return false; - } - - try - { - var content = File.ReadAllText(configFilePath); - var config = JsonNode.Parse(content)?.AsObject(); - - if (config is null) - { - return false; - } - - if (config.TryGetPropertyValue("mcpServers", out var serversNode) && serversNode is JsonObject servers) - { - return servers.ContainsKey(AspireServerName); - } - - return false; - } - catch (JsonException) - { - // If the JSON is malformed, assume aspire is not configured - return false; - } + return McpConfigFileHelper.HasServerConfigured(configFilePath, "mcpServers", AspireServerName); } /// @@ -223,18 +196,7 @@ private static async Task ApplyMcpConfigurationAsync( Directory.CreateDirectory(configDirectory); } - JsonObject config; - - // Read existing config or create new - if (File.Exists(configFilePath)) - { - var existingContent = await File.ReadAllTextAsync(configFilePath, cancellationToken); - config = JsonNode.Parse(existingContent)?.AsObject() ?? new JsonObject(); - } - else - { - config = new JsonObject(); - } + var config = await McpConfigFileHelper.ReadConfigAsync(configFilePath, cancellationToken); // Ensure "mcpServers" object exists if (!config.ContainsKey("mcpServers") || config["mcpServers"] is not JsonObject) @@ -278,18 +240,7 @@ private static async Task ApplyPlaywrightMcpConfigurationAsync( Directory.CreateDirectory(configDirectory); } - JsonObject config; - - // Read existing config or create new - if (File.Exists(configFilePath)) - { - var existingContent = await File.ReadAllTextAsync(configFilePath, cancellationToken); - config = JsonNode.Parse(existingContent)?.AsObject() ?? new JsonObject(); - } - else - { - config = new JsonObject(); - } + var config = await McpConfigFileHelper.ReadConfigAsync(configFilePath, cancellationToken); // Ensure "mcpServers" object exists if (!config.ContainsKey("mcpServers") || config["mcpServers"] is not JsonObject) @@ -319,31 +270,6 @@ private static async Task ApplyPlaywrightMcpConfigurationAsync( private static bool HasPlaywrightServerConfigured(DirectoryInfo homeDirectory) { var configFilePath = GetMcpConfigFilePath(homeDirectory); - - if (!File.Exists(configFilePath)) - { - return false; - } - - try - { - var content = File.ReadAllText(configFilePath); - var config = JsonNode.Parse(content)?.AsObject(); - if (config is null) - { - return false; - } - - if (config.TryGetPropertyValue("mcpServers", out var serversNode) && serversNode is JsonObject servers) - { - return servers.ContainsKey("playwright"); - } - - return false; - } - catch (JsonException) - { - return false; - } + return McpConfigFileHelper.HasServerConfigured(configFilePath, "mcpServers", "playwright"); } } diff --git a/src/Aspire.Cli/Agents/McpConfigFileHelper.cs b/src/Aspire.Cli/Agents/McpConfigFileHelper.cs new file mode 100644 index 00000000000..0c2448ad3dc --- /dev/null +++ b/src/Aspire.Cli/Agents/McpConfigFileHelper.cs @@ -0,0 +1,92 @@ +// 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.Json; +using System.Text.Json.Nodes; +using Aspire.Cli.Resources; + +namespace Aspire.Cli.Agents; + +/// +/// Provides shared methods for reading and parsing MCP configuration files across agent environment scanners. +/// +internal static class McpConfigFileHelper +{ + /// + /// Checks if a specific server is configured in an MCP config file under the given container key. + /// + /// The path to the MCP configuration file. + /// The JSON property name that holds the servers object (e.g., "servers", "mcpServers", "mcp"). + /// The name of the server to check for. + /// Optional function to preprocess file content before parsing (e.g., to strip JSONC comments). + /// true if the server is configured; false otherwise (including when the file is missing or malformed). + public static bool HasServerConfigured(string configFilePath, string serverContainerKey, string serverName, Func? preprocessContent = null) + { + if (!File.Exists(configFilePath)) + { + return false; + } + + try + { + var content = File.ReadAllText(configFilePath); + + if (preprocessContent is not null) + { + content = preprocessContent(content); + } + + var config = JsonNode.Parse(content)?.AsObject(); + + if (config is null) + { + return false; + } + + if (config.TryGetPropertyValue(serverContainerKey, out var serversNode) && serversNode is JsonObject servers) + { + return servers.ContainsKey(serverName); + } + + return false; + } + catch (JsonException) + { + return false; + } + } + + /// + /// Reads an existing MCP config file and parses it into a , or creates a new one if the file doesn't exist. + /// + /// The path to the MCP configuration file. + /// A cancellation token. + /// Optional function to preprocess file content before parsing (e.g., to strip JSONC comments). + /// The parsed from the file, or a new empty if the file doesn't exist. + /// Thrown when the file exists but contains malformed JSON, wrapping the underlying . + public static async Task ReadConfigAsync(string configFilePath, CancellationToken cancellationToken, Func? preprocessContent = null) + { + if (!File.Exists(configFilePath)) + { + return new JsonObject(); + } + + var content = await File.ReadAllTextAsync(configFilePath, cancellationToken); + + if (preprocessContent is not null) + { + content = preprocessContent(content); + } + + try + { + return JsonNode.Parse(content)?.AsObject() ?? new JsonObject(); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.MalformedConfigFileError, configFilePath), ex); + } + } +} diff --git a/src/Aspire.Cli/Agents/OpenCode/OpenCodeAgentEnvironmentScanner.cs b/src/Aspire.Cli/Agents/OpenCode/OpenCodeAgentEnvironmentScanner.cs index bfcee0fc316..1674a690e29 100644 --- a/src/Aspire.Cli/Agents/OpenCode/OpenCodeAgentEnvironmentScanner.cs +++ b/src/Aspire.Cli/Agents/OpenCode/OpenCodeAgentEnvironmentScanner.cs @@ -121,32 +121,7 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok /// True if the aspire server is already configured, false otherwise. private static bool HasAspireServerConfigured(string configFilePath) { - try - { - var content = File.ReadAllText(configFilePath); - - // Remove single-line comments for parsing (JSONC support) - content = RemoveJsonComments(content); - - var config = JsonNode.Parse(content)?.AsObject(); - - if (config is null) - { - return false; - } - - if (config.TryGetPropertyValue("mcp", out var mcpNode) && mcpNode is JsonObject mcp) - { - return mcp.ContainsKey(AspireServerName); - } - - return false; - } - catch (JsonException) - { - // If the JSON is malformed, assume aspire is not configured - return false; - } + return McpConfigFileHelper.HasServerConfigured(configFilePath, "mcp", AspireServerName, RemoveJsonComments); } /// @@ -203,24 +178,10 @@ private static async Task ApplyMcpConfigurationAsync( CancellationToken cancellationToken) { var configFilePath = Path.Combine(configDirectory.FullName, OpenCodeConfigFileName); - JsonObject config; + var config = await McpConfigFileHelper.ReadConfigAsync(configFilePath, cancellationToken, RemoveJsonComments); - // Read existing config or create new - if (File.Exists(configFilePath)) - { - var existingContent = await File.ReadAllTextAsync(configFilePath, cancellationToken); - - // Remove comments for parsing - var jsonContent = RemoveJsonComments(existingContent); - config = JsonNode.Parse(jsonContent)?.AsObject() ?? new JsonObject(); - } - else - { - config = new JsonObject - { - ["$schema"] = "https://opencode.ai/config.json" - }; - } + // Ensure schema is set for new files + config.TryAdd("$schema", "https://opencode.ai/config.json"); // Ensure "mcp" object exists if (!config.ContainsKey("mcp") || config["mcp"] is not JsonObject) @@ -251,24 +212,10 @@ private static async Task ApplyPlaywrightMcpConfigurationAsync( CancellationToken cancellationToken) { var configFilePath = Path.Combine(configDirectory.FullName, OpenCodeConfigFileName); - JsonObject config; + var config = await McpConfigFileHelper.ReadConfigAsync(configFilePath, cancellationToken, RemoveJsonComments); - // Read existing config or create new - if (File.Exists(configFilePath)) - { - var existingContent = await File.ReadAllTextAsync(configFilePath, cancellationToken); - - // Remove comments for parsing - var jsonContent = RemoveJsonComments(existingContent); - config = JsonNode.Parse(jsonContent)?.AsObject() ?? new JsonObject(); - } - else - { - config = new JsonObject - { - ["$schema"] = "https://opencode.ai/config.json" - }; - } + // Ensure schema is set for new files + config.TryAdd("$schema", "https://opencode.ai/config.json"); // Ensure "mcp" object exists if (!config.ContainsKey("mcp") || config["mcp"] is not JsonObject) @@ -296,32 +243,6 @@ private static async Task ApplyPlaywrightMcpConfigurationAsync( /// private static bool HasPlaywrightServerConfigured(string configFilePath) { - if (!File.Exists(configFilePath)) - { - return false; - } - - try - { - var content = File.ReadAllText(configFilePath); - var jsonContent = RemoveJsonComments(content); - var config = JsonNode.Parse(jsonContent)?.AsObject(); - - if (config is null) - { - return false; - } - - if (config.TryGetPropertyValue("mcp", out var mcpNode) && mcpNode is JsonObject mcp) - { - return mcp.ContainsKey("playwright"); - } - - return false; - } - catch (JsonException) - { - return false; - } + return McpConfigFileHelper.HasServerConfigured(configFilePath, "mcp", "playwright", RemoveJsonComments); } } diff --git a/src/Aspire.Cli/Agents/VsCode/VsCodeAgentEnvironmentScanner.cs b/src/Aspire.Cli/Agents/VsCode/VsCodeAgentEnvironmentScanner.cs index fb2caedc77c..22bbd1885e4 100644 --- a/src/Aspire.Cli/Agents/VsCode/VsCodeAgentEnvironmentScanner.cs +++ b/src/Aspire.Cli/Agents/VsCode/VsCodeAgentEnvironmentScanner.cs @@ -205,34 +205,7 @@ private bool HasVsCodeEnvironmentVariables() private static bool HasAspireServerConfigured(DirectoryInfo vsCodeFolder) { var mcpConfigPath = Path.Combine(vsCodeFolder.FullName, McpConfigFileName); - - if (!File.Exists(mcpConfigPath)) - { - return false; - } - - try - { - var content = File.ReadAllText(mcpConfigPath); - var config = JsonNode.Parse(content)?.AsObject(); - - if (config is null) - { - return false; - } - - if (config.TryGetPropertyValue("servers", out var serversNode) && serversNode is JsonObject servers) - { - return servers.ContainsKey(AspireServerName); - } - - return false; - } - catch (JsonException) - { - // If the JSON is malformed, assume aspire is not configured - return false; - } + return McpConfigFileHelper.HasServerConfigured(mcpConfigPath, "servers", AspireServerName); } /// @@ -241,33 +214,7 @@ private static bool HasAspireServerConfigured(DirectoryInfo vsCodeFolder) private static bool HasPlaywrightServerConfigured(DirectoryInfo vsCodeFolder) { var mcpConfigPath = Path.Combine(vsCodeFolder.FullName, McpConfigFileName); - - if (!File.Exists(mcpConfigPath)) - { - return false; - } - - try - { - var content = File.ReadAllText(mcpConfigPath); - var config = JsonNode.Parse(content)?.AsObject(); - if (config is null) - { - return false; - } - - if (config.TryGetPropertyValue("servers", out var serversNode) && serversNode is JsonObject servers) - { - return servers.ContainsKey("playwright"); - } - - return false; - } - catch (JsonException) - { - // If the JSON is malformed, assume playwright is not configured - return false; - } + return McpConfigFileHelper.HasServerConfigured(mcpConfigPath, "servers", "playwright"); } /// @@ -294,18 +241,7 @@ private static async Task ApplyAspireMcpConfigurationAsync( } var mcpConfigPath = Path.Combine(vsCodeFolder.FullName, McpConfigFileName); - JsonObject config; - - // Read existing config or create new - if (File.Exists(mcpConfigPath)) - { - var existingContent = await File.ReadAllTextAsync(mcpConfigPath, cancellationToken); - config = JsonNode.Parse(existingContent)?.AsObject() ?? new JsonObject(); - } - else - { - config = new JsonObject(); - } + var config = await McpConfigFileHelper.ReadConfigAsync(mcpConfigPath, cancellationToken); // Ensure "servers" object exists if (!config.ContainsKey("servers") || config["servers"] is not JsonObject) @@ -342,18 +278,7 @@ private static async Task ApplyPlaywrightMcpConfigurationAsync( } var mcpConfigPath = Path.Combine(vsCodeFolder.FullName, McpConfigFileName); - JsonObject config; - - // Read existing config or create new - if (File.Exists(mcpConfigPath)) - { - var existingContent = await File.ReadAllTextAsync(mcpConfigPath, cancellationToken); - config = JsonNode.Parse(existingContent)?.AsObject() ?? new JsonObject(); - } - else - { - config = new JsonObject(); - } + var config = await McpConfigFileHelper.ReadConfigAsync(mcpConfigPath, cancellationToken); // Ensure "servers" object exists if (!config.ContainsKey("servers") || config["servers"] is not JsonObject) diff --git a/src/Aspire.Cli/Commands/AgentInitCommand.cs b/src/Aspire.Cli/Commands/AgentInitCommand.cs index 76074b53d2d..e4100675215 100644 --- a/src/Aspire.Cli/Commands/AgentInitCommand.cs +++ b/src/Aspire.Cli/Commands/AgentInitCommand.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.Globalization; +using System.Text.Json; using Aspire.Cli.Agents; using Aspire.Cli.Configuration; using Aspire.Cli.Git; @@ -139,13 +140,34 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } // Apply all selected applicators + var hasErrors = false; foreach (var applicator in selectedApplicators) { - await applicator.ApplyAsync(cancellationToken); + try + { + await applicator.ApplyAsync(cancellationToken); + } + catch (InvalidOperationException ex) + { + _interactionService.DisplayError(ex.Message); + if (ex.InnerException is JsonException) + { + _interactionService.DisplaySubtleMessage( + string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.SkippedMalformedConfigFile, applicator.Description)); + } + hasErrors = true; + } } - _interactionService.DisplaySuccess(McpCommandStrings.InitCommand_ConfigurationComplete); + if (hasErrors) + { + _interactionService.DisplayMessage("warning", AgentCommandStrings.ConfigurationCompletedWithErrors); + } + else + { + _interactionService.DisplaySuccess(McpCommandStrings.InitCommand_ConfigurationComplete); + } - return ExitCodeConstants.Success; + return hasErrors ? ExitCodeConstants.InvalidCommand : ExitCodeConstants.Success; } } diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs index 5df0551253a..b98194c480b 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs @@ -122,5 +122,32 @@ internal static string ConfigUpdatesSelectPrompt { return ResourceManager.GetString("ConfigUpdatesSelectPrompt", resourceCulture); } } + + /// + /// Looks up a localized string similar to The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command.. + /// + internal static string MalformedConfigFileError { + get { + return ResourceManager.GetString("MalformedConfigFileError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Skipping update of '{0}'.. + /// + internal static string SkippedMalformedConfigFile { + get { + return ResourceManager.GetString("SkippedMalformedConfigFile", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Configuration completed with errors. Please fix the reported issues and re-run the command.. + /// + internal static string ConfigurationCompletedWithErrors { + get { + return ResourceManager.GetString("ConfigurationCompletedWithErrors", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.resx b/src/Aspire.Cli/Resources/AgentCommandStrings.resx index 1676b381ea8..d20104fd57b 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.resx @@ -81,4 +81,13 @@ The following agent configurations use the deprecated 'mcp start' command. Update them? + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + + + Skipping update of '{0}'. + + + Configuration completed with errors. Please fix the reported issues and re-run the command. + diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf index d0d933897a3..3e6e9255b44 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf @@ -32,11 +32,26 @@ Inicializujte konfiguraci prostředí agentů pro zjištěné agenty. + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + + Start the MCP (Model Context Protocol) server. Spusťte server MCP (Model Context Protocol). + + Skipping update of '{0}'. + Skipping update of '{0}'. + + + + Configuration completed with errors. Please fix the reported issues and re-run the command. + Configuration completed with errors. Please fix the reported issues and re-run the command. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf index 1c43bbbed0a..27f1bb51c46 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf @@ -32,11 +32,26 @@ Initialisieren Sie die Agent-Umgebungskonfiguration für erkannte Agenten. + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + + Start the MCP (Model Context Protocol) server. Starten Sie den MCP-Server (Model Context Protocol). + + Skipping update of '{0}'. + Skipping update of '{0}'. + + + + Configuration completed with errors. Please fix the reported issues and re-run the command. + Configuration completed with errors. Please fix the reported issues and re-run the command. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf index 18efd92b16b..5e8ca917049 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf @@ -32,11 +32,26 @@ Inicialice la configuración del entorno del agente para los agentes detectados. + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + + Start the MCP (Model Context Protocol) server. Inicie el servidor MCP (protocolo de contexto de modelo). + + Skipping update of '{0}'. + Skipping update of '{0}'. + + + + Configuration completed with errors. Please fix the reported issues and re-run the command. + Configuration completed with errors. Please fix the reported issues and re-run the command. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf index 2bdc3ba028d..144ff39e840 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf @@ -32,11 +32,26 @@ Initialiser la configuration de l’environnement des agents détectés. + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + + Start the MCP (Model Context Protocol) server. Démarrez le serveur MCP (Model Context Protocol). + + Skipping update of '{0}'. + Skipping update of '{0}'. + + + + Configuration completed with errors. Please fix the reported issues and re-run the command. + Configuration completed with errors. Please fix the reported issues and re-run the command. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf index 3c670bea806..3efb7a98536 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf @@ -32,11 +32,26 @@ Consente di inizializzare la configurazione dell'ambiente agente per gli agenti rilevati. + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + + Start the MCP (Model Context Protocol) server. Avviare il server MCP (Model Context Protocol). + + Skipping update of '{0}'. + Skipping update of '{0}'. + + + + Configuration completed with errors. Please fix the reported issues and re-run the command. + Configuration completed with errors. Please fix the reported issues and re-run the command. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf index 25d9e8d20f8..41c529b1cac 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf @@ -32,11 +32,26 @@ 検出されたエージェントのエージェント環境構成を初期化します。 + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + + Start the MCP (Model Context Protocol) server. MCP (モデル コンテキスト プロトコル) サーバーを起動します。 + + Skipping update of '{0}'. + Skipping update of '{0}'. + + + + Configuration completed with errors. Please fix the reported issues and re-run the command. + Configuration completed with errors. Please fix the reported issues and re-run the command. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf index c312b98f5ee..3cee8b9d29f 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf @@ -32,11 +32,26 @@ 감지된 에이전트에 대한 에이전트 환경 구성을 초기화합니다. + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + + Start the MCP (Model Context Protocol) server. MCP(모델 컨텍스트 프로토콜) 서버를 시작합니다. + + Skipping update of '{0}'. + Skipping update of '{0}'. + + + + Configuration completed with errors. Please fix the reported issues and re-run the command. + Configuration completed with errors. Please fix the reported issues and re-run the command. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf index b42532db057..8697a4b1cf6 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf @@ -32,11 +32,26 @@ Zainicjuj konfigurację środowiska agenta dla wykrytych agentów. + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + + Start the MCP (Model Context Protocol) server. Uruchom serwer MCP (Model Context Protocol). + + Skipping update of '{0}'. + Skipping update of '{0}'. + + + + Configuration completed with errors. Please fix the reported issues and re-run the command. + Configuration completed with errors. Please fix the reported issues and re-run the command. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf index 4294efb9752..48a592b8376 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf @@ -32,11 +32,26 @@ Inicialize a configuração de ambiente do agente para agentes detectados. + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + + Start the MCP (Model Context Protocol) server. Inicie o servidor MCP (Protocolo de Contexto de Modelo). + + Skipping update of '{0}'. + Skipping update of '{0}'. + + + + Configuration completed with errors. Please fix the reported issues and re-run the command. + Configuration completed with errors. Please fix the reported issues and re-run the command. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf index db959a6d4d5..07193b8ad3b 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf @@ -32,11 +32,26 @@ Инициализировать конфигурацию среды агента для обнаруженных агентов. + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + + Start the MCP (Model Context Protocol) server. Запуск сервера MCP (протокол контекста модели). + + Skipping update of '{0}'. + Skipping update of '{0}'. + + + + Configuration completed with errors. Please fix the reported issues and re-run the command. + Configuration completed with errors. Please fix the reported issues and re-run the command. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf index b4f7dbdc41b..b20ee85ae2f 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf @@ -32,11 +32,26 @@ Tespit edilen aracılar için aracı ortam yapılandırmasını başlatın. + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + + Start the MCP (Model Context Protocol) server. MCP (Model Bağlam Protokolü) sunucusunu başlat. + + Skipping update of '{0}'. + Skipping update of '{0}'. + + + + Configuration completed with errors. Please fix the reported issues and re-run the command. + Configuration completed with errors. Please fix the reported issues and re-run the command. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf index 49c47e7220f..fbb65fdfae3 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf @@ -32,11 +32,26 @@ 初始化检测到的智能体的智能体环境配置。 + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + + Start the MCP (Model Context Protocol) server. 启动 MCP (模型上下文协议)服务器。 + + Skipping update of '{0}'. + Skipping update of '{0}'. + + + + Configuration completed with errors. Please fix the reported issues and re-run the command. + Configuration completed with errors. Please fix the reported issues and re-run the command. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf index 45422b98fde..ae2b4105963 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf @@ -32,11 +32,26 @@ 為偵測到的代理程式初始化代理程式環境設定。 + + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + The configuration file '{0}' contains malformed JSON. Please fix the file manually and re-run the command. + + Start the MCP (Model Context Protocol) server. 啟動 MCP (模型內容通訊協定) 伺服器。 + + Skipping update of '{0}'. + Skipping update of '{0}'. + + + + Configuration completed with errors. Please fix the reported issues and re-run the command. + Configuration completed with errors. Please fix the reported issues and re-run the command. + + \ No newline at end of file diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs index f3081afc8ac..446b4c1a4b9 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs @@ -315,5 +315,119 @@ public async Task DoctorCommand_DetectsDeprecatedAgentConfig() await pendingRun; } + + /// + /// Tests that aspire agent init gracefully handles malformed JSON in MCP config files. + /// When a .vscode/mcp.json file contains invalid JSON, the command should: + /// - Display an error message identifying the malformed file + /// - Display a "Skipping" message + /// - NOT overwrite the malformed file + /// - Exit with a non-zero exit code + /// + [Fact] + public async Task AgentInitCommand_WithMalformedMcpJson_ShowsErrorAndExitsNonZero() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath( + nameof(AgentInitCommand_WithMalformedMcpJson_ShowsErrorAndExitsNonZero)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // Set up paths + var vscodePath = Path.Combine(workspace.WorkspaceRoot.FullName, ".vscode"); + var mcpConfigPath = Path.Combine(vscodePath, "mcp.json"); + var malformedContent = "{ invalid json content"; + + // Patterns for agent init prompts + var workspacePathPrompt = new CellPatternSearcher().Find("workspace:"); + + // Pattern for the malformed JSON error message + var malformedError = new CellPatternSearcher().Find("malformed JSON"); + + // Pattern for the skip message + var skippingMessage = new CellPatternSearcher().Find("Skipping"); + + // Pattern for the partial success warning message + var completedWithErrors = new CellPatternSearcher().Find("completed with errors"); + + // Pattern for the agent environment selection prompt + var agentSelectPrompt = new CellPatternSearcher().Find("agent environments"); + + // Pattern for the additional options prompt that appears after agent environment selection + var additionalOptionsPrompt = new CellPatternSearcher().Find("additional options"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Step 1: Create .vscode folder with malformed mcp.json + sequenceBuilder + .CreateVsCodeFolder(vscodePath) + .CreateMalformedMcpConfig(mcpConfigPath, malformedContent); + + // Verify the malformed config was created + sequenceBuilder + .VerifyFileContains(mcpConfigPath, "invalid json"); + + // Step 2: Run aspire agent init + sequenceBuilder + .Type("aspire agent init") + .Enter() + .WaitUntil(s => workspacePathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Wait(500) + .Enter() // Accept default workspace path + .WaitUntil(s => agentSelectPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Type(" ") // Select first option (VS Code) + .Enter() + // Handle the additional options prompt - must select at least one item + // (Spectre.Console MultiSelectionPrompt requires at least one selection) + // Select the first skill file option which is harmless (doesn't touch mcp.json) + .WaitUntil(s => additionalOptionsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Type(" ") // Select first additional option (skill file) + .Enter() + // After all prompts, wait for the error about malformed JSON and non-zero exit + .WaitUntil(s => + { + var hasError = malformedError.Search(s).Count > 0; + var hasSkip = skippingMessage.Search(s).Count > 0; + var hasCompletedWithErrors = completedWithErrors.Search(s).Count > 0; + return hasError && hasSkip && hasCompletedWithErrors; + }, TimeSpan.FromSeconds(30)) + .WaitForErrorPrompt(counter); + + // Step 3: Verify the malformed file was NOT overwritten + sequenceBuilder + .VerifyFileContains(mcpConfigPath, "invalid json"); + + sequenceBuilder + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index a58de694b39..fa33ed999bc 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -243,6 +243,35 @@ internal static Hex1bTerminalInputSequenceBuilder WaitForAnyPrompt( .IncrementSequence(counter); } + /// + /// Waits for the shell prompt to show a non-zero exit code pattern: [N ERR:code] $ + /// This is used to verify that a command exited with a failure code. + /// + /// The sequence builder. + /// The sequence counter for prompt detection. + /// The expected non-zero exit code. + /// Optional timeout (defaults to 500 seconds). + /// The builder for chaining. + internal static Hex1bTerminalInputSequenceBuilder WaitForErrorPrompt( + this Hex1bTerminalInputSequenceBuilder builder, + SequenceCounter counter, + int exitCode = 1, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); + + return builder.WaitUntil(snapshot => + { + var errorPromptSearcher = new CellPatternSearcher() + .FindPattern(counter.Value.ToString()) + .RightText($" ERR:{exitCode}] $ "); + + var result = errorPromptSearcher.Search(snapshot); + return result.Count > 0; + }, effectiveTimeout) + .IncrementSequence(counter); + } + internal static Hex1bTerminalInputSequenceBuilder IncrementSequence( this Hex1bTerminalInputSequenceBuilder builder, SequenceCounter counter) @@ -421,6 +450,29 @@ internal static Hex1bTerminalInputSequenceBuilder CreateDeprecatedMcpConfig( return builder.ExecuteCallback(() => File.WriteAllText(configPath, deprecatedConfig)); } + /// + /// Creates a malformed MCP config file for testing error handling. + /// + /// The sequence builder. + /// The path to create the malformed config file. + /// The malformed JSON content to write. + /// The builder for chaining. + internal static Hex1bTerminalInputSequenceBuilder CreateMalformedMcpConfig( + this Hex1bTerminalInputSequenceBuilder builder, + string configPath, + string content = "{ invalid json content") + { + return builder.ExecuteCallback(() => + { + var dir = Path.GetDirectoryName(configPath); + if (dir is not null && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + File.WriteAllText(configPath, content); + }); + } + /// /// Creates a .vscode folder for testing VS Code agent detection. /// diff --git a/tests/Aspire.Cli.Tests/Agents/ClaudeCodeAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/ClaudeCodeAgentEnvironmentScannerTests.cs new file mode 100644 index 00000000000..a0a0185e653 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Agents/ClaudeCodeAgentEnvironmentScannerTests.cs @@ -0,0 +1,128 @@ +// 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; +using Aspire.Cli.Agents; +using Aspire.Cli.Agents.ClaudeCode; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.Logging.Abstractions; +using Semver; + +namespace Aspire.Cli.Tests.Agents; + +public class ClaudeCodeAgentEnvironmentScannerTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task ApplyAsync_WithMalformedMcpJson_ThrowsInvalidOperationException() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + workspace.CreateDirectory(".claude"); + + // Create a malformed .mcp.json at the workspace root + var mcpJsonPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".mcp.json"); + await File.WriteAllTextAsync(mcpJsonPath, "{ invalid json content"); + + var claudeCodeCliRunner = new FakeClaudeCodeCliRunner(new SemVersion(1, 0, 0)); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var scanner = new ClaudeCodeAgentEnvironmentScanner(claudeCodeCliRunner, executionContext, NullLogger.Instance); + var context = CreateScanContext(workspace.WorkspaceRoot); + + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); + + // The scan should succeed (HasServerConfigured catches JsonException) + Assert.NotEmpty(context.Applicators); + var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); + + // Applying should throw with a descriptive message + var ex = await Assert.ThrowsAsync( + () => aspireApplicator.ApplyAsync(CancellationToken.None)).DefaultTimeout(); + Assert.Contains(mcpJsonPath, ex.Message); + Assert.Contains("malformed JSON", ex.Message); + } + + [Fact] + public async Task ApplyAsync_WithEmptyMcpJson_ThrowsInvalidOperationException() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + workspace.CreateDirectory(".claude"); + + // Create an empty .mcp.json + var mcpJsonPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".mcp.json"); + await File.WriteAllTextAsync(mcpJsonPath, ""); + + var claudeCodeCliRunner = new FakeClaudeCodeCliRunner(new SemVersion(1, 0, 0)); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var scanner = new ClaudeCodeAgentEnvironmentScanner(claudeCodeCliRunner, executionContext, NullLogger.Instance); + var context = CreateScanContext(workspace.WorkspaceRoot); + + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); + + Assert.NotEmpty(context.Applicators); + var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); + + var ex = await Assert.ThrowsAsync( + () => aspireApplicator.ApplyAsync(CancellationToken.None)).DefaultTimeout(); + Assert.Contains(mcpJsonPath, ex.Message); + } + + [Fact] + public async Task ApplyAsync_WithMalformedMcpJson_DoesNotOverwriteFile() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + workspace.CreateDirectory(".claude"); + + // Create a malformed .mcp.json with content the user may want to preserve + var mcpJsonPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".mcp.json"); + var originalContent = "{ \"mcpServers\": { \"my-server\": { \"command\": \"test\" } }"; + await File.WriteAllTextAsync(mcpJsonPath, originalContent); + + var claudeCodeCliRunner = new FakeClaudeCodeCliRunner(new SemVersion(1, 0, 0)); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var scanner = new ClaudeCodeAgentEnvironmentScanner(claudeCodeCliRunner, executionContext, NullLogger.Instance); + var context = CreateScanContext(workspace.WorkspaceRoot); + + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); + + Assert.NotEmpty(context.Applicators); + var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); + + await Assert.ThrowsAsync( + () => aspireApplicator.ApplyAsync(CancellationToken.None)).DefaultTimeout(); + + // The original file content should be preserved + var currentContent = await File.ReadAllTextAsync(mcpJsonPath); + Assert.Equal(originalContent, currentContent); + } + + private static AgentEnvironmentScanContext CreateScanContext( + DirectoryInfo workingDirectory) + { + return new AgentEnvironmentScanContext + { + WorkingDirectory = workingDirectory, + RepositoryRoot = workingDirectory + }; + } + + private static CliExecutionContext CreateExecutionContext(DirectoryInfo workingDirectory) + { + return new CliExecutionContext( + workingDirectory: workingDirectory, + hivesDirectory: workingDirectory, + cacheDirectory: workingDirectory, + sdksDirectory: workingDirectory, + logsDirectory: workingDirectory, + logFilePath: "test.log", + debugMode: false, + environmentVariables: new Dictionary(), + homeDirectory: workingDirectory); + } + + private sealed class FakeClaudeCodeCliRunner(SemVersion? version) : IClaudeCodeCliRunner + { + public Task GetVersionAsync(CancellationToken cancellationToken) + { + return Task.FromResult(version); + } + } +} diff --git a/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs index ee693cebaa6..3a791f21202 100644 --- a/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/CopilotCliAgentEnvironmentScannerTests.cs @@ -227,6 +227,88 @@ private static CliExecutionContext CreateExecutionContextWithVSCode(DirectoryInf homeDirectory: workingDirectory); } + [Fact] + public async Task ApplyAsync_WithMalformedMcpJson_ThrowsInvalidOperationException() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var copilotFolder = workspace.CreateDirectory(".copilot"); + + // Create a malformed mcp-config.json + var mcpConfigPath = Path.Combine(copilotFolder.FullName, "mcp-config.json"); + await File.WriteAllTextAsync(mcpConfigPath, "{ invalid json content"); + + var copilotCliRunner = new FakeCopilotCliRunner(new SemVersion(1, 0, 0)); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); + var context = CreateScanContext(workspace.WorkspaceRoot); + + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); + + // The scan should succeed (HasServerConfigured catches JsonException) + Assert.NotEmpty(context.Applicators); + var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); + + // Applying should throw with a descriptive message + var ex = await Assert.ThrowsAsync( + () => aspireApplicator.ApplyAsync(CancellationToken.None)).DefaultTimeout(); + Assert.Contains(mcpConfigPath, ex.Message); + Assert.Contains("malformed JSON", ex.Message); + } + + [Fact] + public async Task ApplyAsync_WithEmptyMcpJson_ThrowsInvalidOperationException() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var copilotFolder = workspace.CreateDirectory(".copilot"); + + // Create an empty mcp-config.json + var mcpConfigPath = Path.Combine(copilotFolder.FullName, "mcp-config.json"); + await File.WriteAllTextAsync(mcpConfigPath, ""); + + var copilotCliRunner = new FakeCopilotCliRunner(new SemVersion(1, 0, 0)); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); + var context = CreateScanContext(workspace.WorkspaceRoot); + + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); + + Assert.NotEmpty(context.Applicators); + var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); + + var ex = await Assert.ThrowsAsync( + () => aspireApplicator.ApplyAsync(CancellationToken.None)).DefaultTimeout(); + Assert.Contains(mcpConfigPath, ex.Message); + } + + [Fact] + public async Task ApplyAsync_WithMalformedMcpJson_DoesNotOverwriteFile() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var copilotFolder = workspace.CreateDirectory(".copilot"); + + // Create a malformed mcp-config.json with content the user may want to preserve + var mcpConfigPath = Path.Combine(copilotFolder.FullName, "mcp-config.json"); + var originalContent = "{ \"mcpServers\": { \"my-server\": { \"command\": \"test\" } }"; + await File.WriteAllTextAsync(mcpConfigPath, originalContent); + + var copilotCliRunner = new FakeCopilotCliRunner(new SemVersion(1, 0, 0)); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var scanner = new CopilotCliAgentEnvironmentScanner(copilotCliRunner, executionContext, NullLogger.Instance); + var context = CreateScanContext(workspace.WorkspaceRoot); + + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); + + Assert.NotEmpty(context.Applicators); + var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); + + await Assert.ThrowsAsync( + () => aspireApplicator.ApplyAsync(CancellationToken.None)).DefaultTimeout(); + + // The original file content should be preserved + var currentContent = await File.ReadAllTextAsync(mcpConfigPath); + Assert.Equal(originalContent, currentContent); + } + /// /// A fake implementation of for testing. /// diff --git a/tests/Aspire.Cli.Tests/Agents/OpenCodeAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/OpenCodeAgentEnvironmentScannerTests.cs new file mode 100644 index 00000000000..0e8b4540592 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Agents/OpenCodeAgentEnvironmentScannerTests.cs @@ -0,0 +1,108 @@ +// 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; +using Aspire.Cli.Agents; +using Aspire.Cli.Agents.OpenCode; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.Logging.Abstractions; +using Semver; + +namespace Aspire.Cli.Tests.Agents; + +public class OpenCodeAgentEnvironmentScannerTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task ApplyAsync_WithMalformedOpenCodeJsonc_ThrowsInvalidOperationException() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // Create a malformed opencode.jsonc at the workspace root + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, "opencode.jsonc"); + await File.WriteAllTextAsync(configPath, "{ invalid json content"); + + var openCodeCliRunner = new FakeOpenCodeCliRunner(new SemVersion(1, 0, 0)); + var scanner = new OpenCodeAgentEnvironmentScanner(openCodeCliRunner, NullLogger.Instance); + var context = CreateScanContext(workspace.WorkspaceRoot); + + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); + + // The scan should succeed (HasServerConfigured catches JsonException) + Assert.NotEmpty(context.Applicators); + var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); + + // Applying should throw with a descriptive message + var ex = await Assert.ThrowsAsync( + () => aspireApplicator.ApplyAsync(CancellationToken.None)).DefaultTimeout(); + Assert.Contains(configPath, ex.Message); + Assert.Contains("malformed JSON", ex.Message); + } + + [Fact] + public async Task ApplyAsync_WithEmptyOpenCodeJsonc_ThrowsInvalidOperationException() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // Create an empty opencode.jsonc + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, "opencode.jsonc"); + await File.WriteAllTextAsync(configPath, ""); + + var openCodeCliRunner = new FakeOpenCodeCliRunner(new SemVersion(1, 0, 0)); + var scanner = new OpenCodeAgentEnvironmentScanner(openCodeCliRunner, NullLogger.Instance); + var context = CreateScanContext(workspace.WorkspaceRoot); + + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); + + Assert.NotEmpty(context.Applicators); + var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); + + var ex = await Assert.ThrowsAsync( + () => aspireApplicator.ApplyAsync(CancellationToken.None)).DefaultTimeout(); + Assert.Contains(configPath, ex.Message); + } + + [Fact] + public async Task ApplyAsync_WithMalformedOpenCodeJsonc_DoesNotOverwriteFile() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + // Create a malformed opencode.jsonc with content the user may want to preserve + var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, "opencode.jsonc"); + var originalContent = "{ \"mcp\": { \"my-server\": { \"command\": [\"test\"] } }"; + await File.WriteAllTextAsync(configPath, originalContent); + + var openCodeCliRunner = new FakeOpenCodeCliRunner(new SemVersion(1, 0, 0)); + var scanner = new OpenCodeAgentEnvironmentScanner(openCodeCliRunner, NullLogger.Instance); + var context = CreateScanContext(workspace.WorkspaceRoot); + + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); + + Assert.NotEmpty(context.Applicators); + var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); + + await Assert.ThrowsAsync( + () => aspireApplicator.ApplyAsync(CancellationToken.None)).DefaultTimeout(); + + // The original file content should be preserved + var currentContent = await File.ReadAllTextAsync(configPath); + Assert.Equal(originalContent, currentContent); + } + + private static AgentEnvironmentScanContext CreateScanContext( + DirectoryInfo workingDirectory) + { + return new AgentEnvironmentScanContext + { + WorkingDirectory = workingDirectory, + RepositoryRoot = workingDirectory + }; + } + + private sealed class FakeOpenCodeCliRunner(SemVersion? version) : IOpenCodeCliRunner + { + public Task GetVersionAsync(CancellationToken cancellationToken) + { + return Task.FromResult(version); + } + } +} diff --git a/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs b/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs index bcb247ae7f8..989170312cd 100644 --- a/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/VsCodeAgentEnvironmentScannerTests.cs @@ -288,6 +288,88 @@ public async Task ApplyAsync_WithConfigurePlaywrightTrue_AddsPlaywrightServer() Assert.Equal("npx", playwrightServer["command"]?.GetValue()); } + [Fact] + public async Task ApplyAsync_WithMalformedMcpJson_ThrowsInvalidOperationException() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var vsCodeFolder = workspace.CreateDirectory(".vscode"); + + // Create a malformed mcp.json + var mcpJsonPath = Path.Combine(vsCodeFolder.FullName, "mcp.json"); + await File.WriteAllTextAsync(mcpJsonPath, "{ invalid json content"); + + var vsCodeCliRunner = new FakeVsCodeCliRunner(null); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var context = CreateScanContext(workspace.WorkspaceRoot); + + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); + + // The scan should succeed (Has*ServerConfigured catches JsonException) + Assert.NotEmpty(context.Applicators); + var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); + + // Applying should throw with a descriptive message + var ex = await Assert.ThrowsAsync( + () => aspireApplicator.ApplyAsync(CancellationToken.None)).DefaultTimeout(); + Assert.Contains(mcpJsonPath, ex.Message); + Assert.Contains("malformed JSON", ex.Message); + } + + [Fact] + public async Task ApplyAsync_WithEmptyMcpJson_ThrowsInvalidOperationException() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var vsCodeFolder = workspace.CreateDirectory(".vscode"); + + // Create an empty mcp.json (this is the exact scenario from the issue) + var mcpJsonPath = Path.Combine(vsCodeFolder.FullName, "mcp.json"); + await File.WriteAllTextAsync(mcpJsonPath, ""); + + var vsCodeCliRunner = new FakeVsCodeCliRunner(null); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var context = CreateScanContext(workspace.WorkspaceRoot); + + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); + + Assert.NotEmpty(context.Applicators); + var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); + + var ex = await Assert.ThrowsAsync( + () => aspireApplicator.ApplyAsync(CancellationToken.None)).DefaultTimeout(); + Assert.Contains(mcpJsonPath, ex.Message); + } + + [Fact] + public async Task ApplyAsync_WithMalformedMcpJson_DoesNotOverwriteFile() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var vsCodeFolder = workspace.CreateDirectory(".vscode"); + + // Create a malformed mcp.json with content the user may want to preserve + var mcpJsonPath = Path.Combine(vsCodeFolder.FullName, "mcp.json"); + var originalContent = "{ \"servers\": { \"my-server\": { \"command\": \"test\" } }"; + await File.WriteAllTextAsync(mcpJsonPath, originalContent); + + var vsCodeCliRunner = new FakeVsCodeCliRunner(null); + var executionContext = CreateExecutionContext(workspace.WorkspaceRoot); + var scanner = new VsCodeAgentEnvironmentScanner(vsCodeCliRunner, executionContext, NullLogger.Instance); + var context = CreateScanContext(workspace.WorkspaceRoot); + + await scanner.ScanAsync(context, CancellationToken.None).DefaultTimeout(); + + Assert.NotEmpty(context.Applicators); + var aspireApplicator = context.Applicators.First(a => a.Description.Contains("Aspire MCP")); + + await Assert.ThrowsAsync( + () => aspireApplicator.ApplyAsync(CancellationToken.None)).DefaultTimeout(); + + // The original file content should be preserved + var currentContent = await File.ReadAllTextAsync(mcpJsonPath); + Assert.Equal(originalContent, currentContent); + } + /// /// A fake implementation of for testing. /// From 63ff050e346c0dcc45ab78a2c86d2aa760fc6bd7 Mon Sep 17 00:00:00 2001 From: Benjamin Bartels Date: Mon, 23 Feb 2026 23:30:16 +0000 Subject: [PATCH 148/256] Fix port mismatch for bait-and-switch resources in Kubernetes publisher (#14590) * Add a script for startup performance measurement (#14345) * Add startup perf collection script * Analyze trace more efficiently * Increase pause between iterations * Fix TraceAnalyzer * Add startup-perf skill * Add backmerge release workflow to automate merging changes from release/13.2 to main (#14453) * Add backmerge release workflow to automate merging changes from release/13.2 to main * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply more fixes and use dotnet's action --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Bump Aspire branding from 13.2 to 13.3 (#14456) * Initial plan * Bump Aspire branding from 13.2 to 13.3 Co-authored-by: joperezr <13854455+joperezr@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: joperezr <13854455+joperezr@users.noreply.github.com> * Update Azure.Core to latest version - lift all runtime dependencies to latest (#14361) * Update to Azure.Core 1.51.1 Use latest versions for all dotnet/runtime nuget packages. This simplifies our dependency management. Remove ForceLatestDotnetVersions property from multiple project files * Update AzureDeployerTests to use WaitForShutdown instead of StopAsync There is a timing issue when using Start/Stop since the background pipeline might still be running and it cancels the pipeline before it can complete. * Fix AuxiliaryBackchannelTests by adding a Task that completes when the AuxiliaryBackchannelService is listening and ready for connections. * Remove double registration of AuxiliaryBackchannelService as an IHostedService. * Fix ResourceLoggerForwarderServiceTests to ensure the ResourceLoggerForwarderService has started before signalling the stopping token. * Update Arcade to latest version from the .NET 10 Eng channel (#13556) Co-authored-by: Jose Perez Rodriguez * Refactor backmerge PR creation to update existing PRs and streamline body formatting (#14476) * [main] Fix transitive Azure role assignments through WaitFor dependencies (#14478) * Initial plan * Fix transitive Azure role assignments through WaitFor dependencies Remove CollectAnnotationDependencies calls from CollectDependenciesFromValue to prevent WaitFor/parent/connection-string-redirect annotations from referenced resources being included as direct dependencies of the caller. Add tests verifying: - DirectOnly mode excludes WaitFor deps from referenced resources - WaitFor doesn't create transitive role assignments in Azure publish Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> * Remove auto-merge step from backmerge workflow (#14481) * Remove auto-merge step from backmerge workflow * Update PR body to request merge commit instead of auto-merge * Add agentic workflow daily-repo-status (#14498) * [Automated] Backmerge release/13.2 to main (#14536) * Fix Windows pipeline image to use windows.vs2022.amd64.open (#14492) * Fix Windows pipeline image to use windows.vs2022.amd64.open * Use windows.vs2026preview.scout.amd64 for public pipeline Windows pool Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Azure portal link for Resource Group in deploy pipeline summary (#14434) * Add Azure portal link for Resource Group in pipeline summary When printing the Resource Group in the pipeline summary of `aspire deploy`, include a clickable link to the Azure portal resource group page. The link uses the format: https://portal.azure.com/#@{tenantId}/resource/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/overview Changes: - AzureEnvironmentResource.AddToPipelineSummary: construct markdown link for resource group - ConsoleActivityLogger.FormatPipelineSummaryKvp: convert markdown to Spectre markup for clickable links - Add ConsoleActivityLoggerTests for the new markdown rendering behavior Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> * Clean up the code * Fix tests * More test fixups * Refactor code * Update src/Aspire.Cli/Utils/MarkdownToSpectreConverter.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add test for color-enabled non-interactive rendering path Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> * fix test --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> Co-authored-by: Eric Erhardt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Jose Perez Rodriguez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> Co-authored-by: Eric Erhardt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] * [Automated] Update AI Foundry Models (#14541) Co-authored-by: sebastienros * Detect CLI at default install paths when not on PATH (#14545) Check default installation directories (~/.aspire/bin, ~/.dotnet/tools) when the Aspire CLI is not found on the system PATH. If found at a default location, the VS Code setting is auto-updated. If later found on PATH, the setting is cleared. Resolution order: configured custom path > system PATH > default install paths. Fixes #14235 * [automated] Unquarantine stable tests with 25+ days zero failures (#14531) * Initial plan * [automated] Unquarantine stable tests - Unquarantined: DeployCommandIncludesDeployFlagInArguments - Unquarantined: GetAppHostsCommand_WithMultipleProjects_ReturnsSuccessWithAllCandidates - Unquarantined: GetAppHostsCommand_WithSingleProject_ReturnsSuccessWithValidJson - Unquarantined: PushImageToRegistry_WithRemoteRegistry_PushesImage - Unquarantined: ProcessParametersStep_ValidatesBehavior - Unquarantined: WithHttpCommand_EnablesCommandOnceResourceIsRunning These tests are being unquarantined as they have had 25+ days of quarantined run data with zero failures. Co-authored-by: radical <1472+radical@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: radical <1472+radical@users.noreply.github.com> * Partially fix quarantined test: Update stale snapshot for DeployAsync_WithMultipleComputeEnvironments_Works (#14551) * Initial plan * Update snapshot for DeployAsync_WithMultipleComputeEnvironments_Works test Co-authored-by: radical <1472+radical@users.noreply.github.com> * Remove quarantine attribute from DeployAsync_WithMultipleComputeEnvironments_Works test Co-authored-by: radical <1472+radical@users.noreply.github.com> * Restore quarantine attribute - step="deploy" case still fails Co-authored-by: radical <1472+radical@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: radical <1472+radical@users.noreply.github.com> * Update daily report to 13.2 milestone burndown (#14563) * Update daily report to 13.2 milestone burndown Refocus the daily-repo-status agentic workflow to serve as a 13.2 release burndown report: - Track 13.2 milestone issues closed/opened in the last 24 hours - Highlight new bugs added to the milestone - Summarize PRs merged to release/13.2 branch - List PRs targeting release/13.2 awaiting review - Surface relevant 13.2 discussions - Generate a Mermaid xychart burndown using cache-memory snapshots - Keep general triage queue as a brief secondary section - Schedule daily around 9am, auto-close older report issues Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback: clarify cache schema and queries - Exclude PRs from milestone counts (issues-only filter) - Specify exact JSON schema for cache-memory burndown snapshots - Add dedup, sort, and trim-to-7 logic for cache entries - Simplify 'new issues' query to opened-in-last-24h with milestone Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Aspire.Hosting.Kubernetes.csproj * Initialize _kubernetesComponents with ResourceNameComparer * Update KubernetesPublisherTests.cs * Update Aspire.Hosting.Kubernetes.csproj * Adds snapshots * Adds Chart.yaml to snapshot --------- Co-authored-by: Karol Zadora-Przylecki Co-authored-by: Jose Perez Rodriguez Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: joperezr <13854455+joperezr@users.noreply.github.com> Co-authored-by: Eric Erhardt Co-authored-by: dotnet-maestro[bot] <42748379+dotnet-maestro[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: eerhardt <8291187+eerhardt@users.noreply.github.com> Co-authored-by: David Negstad <50252651+danegsta@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Mitch Denny Co-authored-by: sebastienros Co-authored-by: Adam Ratzman Co-authored-by: radical <1472+radical@users.noreply.github.com> --- .gitattributes | 2 + .../policies/milestoneAssignment.prClosed.yml | 8 +- .github/skills/startup-perf/SKILL.md | 193 +++ .github/workflows/README.md | 23 + .github/workflows/backmerge-release.yml | 166 +++ .github/workflows/ci.yml | 1 + .github/workflows/daily-repo-status.lock.yml | 1101 +++++++++++++++++ .github/workflows/daily-repo-status.md | 131 ++ AGENTS.md | 1 + docs/getting-perf-traces.md | 10 +- eng/Version.Details.xml | 28 +- eng/Versions.props | 8 +- eng/build.sh | 2 +- eng/common/SetupNugetSources.ps1 | 17 +- eng/common/SetupNugetSources.sh | 17 +- eng/common/build.ps1 | 2 - eng/common/build.sh | 7 +- eng/common/core-templates/job/job.yml | 8 - .../job/publish-build-assets.yml | 18 +- .../core-templates/job/source-build.yml | 8 +- .../core-templates/post-build/post-build.yml | 463 +++---- .../core-templates/steps/generate-sbom.yml | 2 +- .../steps/install-microbuild-impl.yml | 34 - .../steps/install-microbuild.yml | 64 +- .../core-templates/steps/source-build.yml | 2 +- .../steps/source-index-stage1-publish.yml | 8 +- eng/common/darc-init.sh | 2 +- eng/common/dotnet-install.sh | 2 +- eng/common/dotnet.sh | 2 +- eng/common/internal-feed-operations.sh | 2 +- eng/common/native/install-dependencies.sh | 4 +- eng/common/post-build/redact-logs.ps1 | 3 +- .../templates/variables/pool-providers.yml | 2 +- eng/common/tools.ps1 | 17 +- eng/common/tools.sh | 4 + eng/restore-toolset.sh | 2 +- extension/loc/xlf/aspire-vscode.xlf | 3 + extension/package.nls.json | 1 + extension/src/commands/add.ts | 2 +- extension/src/commands/deploy.ts | 2 +- extension/src/commands/init.ts | 2 +- extension/src/commands/new.ts | 2 +- extension/src/commands/publish.ts | 2 +- extension/src/commands/update.ts | 2 +- .../AspireDebugConfigurationProvider.ts | 12 +- extension/src/debugger/AspireDebugSession.ts | 8 +- extension/src/extension.ts | 7 +- extension/src/loc/strings.ts | 1 + .../src/test/aspireTerminalProvider.test.ts | 76 +- extension/src/test/cliPath.test.ts | 211 ++++ extension/src/utils/AspireTerminalProvider.ts | 17 +- extension/src/utils/cliPath.ts | 194 +++ extension/src/utils/configInfoProvider.ts | 4 +- extension/src/utils/workspace.ts | 71 +- global.json | 6 +- .../KubernetesEnvironmentContext.cs | 2 +- .../Commands/DeployCommandTests.cs | 4 +- .../Commands/ExtensionInternalCommandTests.cs | 5 +- ...nments_Works_step=diagnostics.verified.txt | 100 +- .../DockerComposeTests.cs | 1 - .../KubernetesPublisherTests.cs | 42 + ...ForBaitAndSwitchResources#00.verified.yaml | 11 + ...ForBaitAndSwitchResources#01.verified.yaml | 10 + ...ForBaitAndSwitchResources#02.verified.yaml | 40 + ...ForBaitAndSwitchResources#03.verified.yaml | 20 + ...ForBaitAndSwitchResources#04.verified.yaml | 11 + ...ForBaitAndSwitchResources#05.verified.yaml | 40 + ...ForBaitAndSwitchResources#06.verified.yaml | 12 + .../DistributedApplicationPipelineTests.cs | 4 +- .../WithHttpCommandTests.cs | 2 - tools/perf/Measure-StartupPerformance.ps1 | 678 ++++++++++ tools/perf/TraceAnalyzer/Program.cs | 80 ++ tools/perf/TraceAnalyzer/TraceAnalyzer.csproj | 16 + 73 files changed, 3489 insertions(+), 576 deletions(-) create mode 100644 .github/skills/startup-perf/SKILL.md create mode 100644 .github/workflows/backmerge-release.yml create mode 100644 .github/workflows/daily-repo-status.lock.yml create mode 100644 .github/workflows/daily-repo-status.md delete mode 100644 eng/common/core-templates/steps/install-microbuild-impl.yml create mode 100644 extension/src/test/cliPath.test.ts create mode 100644 extension/src/utils/cliPath.ts create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#00.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#01.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#02.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#03.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#04.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#05.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#06.verified.yaml create mode 100644 tools/perf/Measure-StartupPerformance.ps1 create mode 100644 tools/perf/TraceAnalyzer/Program.cs create mode 100644 tools/perf/TraceAnalyzer/TraceAnalyzer.csproj diff --git a/.gitattributes b/.gitattributes index 594552221cc..4c262a83c4c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -60,3 +60,5 @@ # https://github.com/github/linguist/issues/1626#issuecomment-401442069 # this only affects the repo's language statistics *.h linguist-language=C + +.github/workflows/*.lock.yml linguist-generated=true merge=ours \ No newline at end of file diff --git a/.github/policies/milestoneAssignment.prClosed.yml b/.github/policies/milestoneAssignment.prClosed.yml index 1ec03595d00..ad9aaad2f57 100644 --- a/.github/policies/milestoneAssignment.prClosed.yml +++ b/.github/policies/milestoneAssignment.prClosed.yml @@ -16,16 +16,16 @@ configuration: branch: main then: - addMilestone: - milestone: 13.2 + milestone: 13.3 description: '[Milestone Assignments] Assign Milestone to PRs merged to the `main` branch' - if: - payloadType: Pull_Request - isAction: action: Closed - targetsBranch: - branch: release/13.1 + branch: release/13.2 then: - removeMilestone - addMilestone: - milestone: 13.1.1 - description: '[Milestone Assignments] Assign Milestone to PRs merged to release/13.1 branch' + milestone: 13.2 + description: '[Milestone Assignments] Assign Milestone to PRs merged to release/13.2 branch' diff --git a/.github/skills/startup-perf/SKILL.md b/.github/skills/startup-perf/SKILL.md new file mode 100644 index 00000000000..33ca4d3875f --- /dev/null +++ b/.github/skills/startup-perf/SKILL.md @@ -0,0 +1,193 @@ +--- +name: startup-perf +description: Measures Aspire application startup performance using dotnet-trace and the TraceAnalyzer tool. Use this when asked to measure impact of a code change on Aspire application startup performance. +--- + +# Aspire Startup Performance Measurement + +This skill provides patterns and practices for measuring .NET Aspire application startup performance using the `Measure-StartupPerformance.ps1` script and the companion `TraceAnalyzer` tool. + +## Overview + +The startup performance tooling collects `dotnet-trace` traces from an Aspire AppHost application and computes the startup duration from `AspireEventSource` events. Specifically, it measures the time between the `DcpModelCreationStart` (event ID 17) and `DcpModelCreationStop` (event ID 18) events emitted by the `Microsoft-Aspire-Hosting` EventSource provider. + +**Script Location**: `tools/perf/Measure-StartupPerformance.ps1` +**TraceAnalyzer Location**: `tools/perf/TraceAnalyzer/` +**Documentation**: `docs/getting-perf-traces.md` + +## Prerequisites + +- PowerShell 7+ +- `dotnet-trace` global tool (`dotnet tool install -g dotnet-trace`) +- .NET SDK (restored via `./restore.cmd` or `./restore.sh`) + +## Quick Start + +### Single Measurement + +```powershell +# From repository root — measures the default TestShop.AppHost +.\tools\perf\Measure-StartupPerformance.ps1 +``` + +### Multiple Iterations with Statistics + +```powershell +.\tools\perf\Measure-StartupPerformance.ps1 -Iterations 5 +``` + +### Custom Project + +```powershell +.\tools\perf\Measure-StartupPerformance.ps1 -ProjectPath "path\to\MyApp.AppHost.csproj" -Iterations 3 +``` + +### Preserve Traces for Manual Analysis + +```powershell +.\tools\perf\Measure-StartupPerformance.ps1 -Iterations 3 -PreserveTraces -TraceOutputDirectory "C:\traces" +``` + +### Verbose Output + +```powershell +.\tools\perf\Measure-StartupPerformance.ps1 -Verbose +``` + +## Parameters + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `ProjectPath` | TestShop.AppHost | Path to the AppHost `.csproj` to measure | +| `Iterations` | 1 | Number of measurement runs (1–100) | +| `PreserveTraces` | `$false` | Keep `.nettrace` files after analysis | +| `TraceOutputDirectory` | temp folder | Directory for preserved trace files | +| `SkipBuild` | `$false` | Skip `dotnet build` before running | +| `TraceDurationSeconds` | 60 | Maximum trace collection time (1–86400) | +| `PauseBetweenIterationsSeconds` | 45 | Pause between iterations (0–3600) | +| `Verbose` | `$false` | Show detailed output | + +## How It Works + +The script follows this sequence: + +1. **Prerequisites check** — Verifies `dotnet-trace` is installed and the project exists. +2. **Build** — Builds the AppHost project in Release configuration (unless `-SkipBuild`). +3. **Build TraceAnalyzer** — Builds the companion `tools/perf/TraceAnalyzer` project. +4. **For each iteration:** + a. Locates the compiled executable (Arcade-style or traditional output paths). + b. Reads `launchSettings.json` for environment variables. + c. Launches the AppHost as a separate process. + d. Attaches `dotnet-trace` to the running process with the `Microsoft-Aspire-Hosting` provider. + e. Waits for the trace to complete (duration timeout or process exit). + f. Runs the TraceAnalyzer to extract the startup duration from the `.nettrace` file. + g. Cleans up processes. +5. **Reports results** — Prints per-iteration times and statistics (min, max, average, std dev). + +## TraceAnalyzer Tool + +The `tools/perf/TraceAnalyzer` is a small .NET console app that parses `.nettrace` files using the `Microsoft.Diagnostics.Tracing.TraceEvent` library. + +### What It Does + +- Opens the `.nettrace` file with `EventPipeEventSource` +- Listens for events from the `Microsoft-Aspire-Hosting` provider +- Extracts timestamps for `DcpModelCreationStart` (ID 17) and `DcpModelCreationStop` (ID 18) +- Outputs the duration in milliseconds (or `"null"` if events are not found) + +### Standalone Usage + +```bash +dotnet run --project tools/perf/TraceAnalyzer -c Release -- +``` + +## Understanding Output + +### Successful Run + +``` +================================================== + Aspire Startup Performance Measurement +================================================== + +Project: TestShop.AppHost +Iterations: 3 +... + +Iteration 1 +---------------------------------------- +Starting TestShop.AppHost... +Attaching trace collection to PID 12345... +Collecting performance trace... +Trace collection completed. +Analyzing trace: ... +Startup time: 1234.56 ms + +... + +================================================== + Results Summary +================================================== + +Iteration StartupTimeMs +--------- ------------- + 1 1234.56 + 2 1189.23 + 3 1201.45 + +Statistics: + Successful iterations: 3 / 3 + Minimum: 1189.23 ms + Maximum: 1234.56 ms + Average: 1208.41 ms + Std Dev: 18.92 ms +``` + +### Common Issues + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `dotnet-trace is not installed` | Missing global tool | Run `dotnet tool install -g dotnet-trace` | +| `Could not find compiled executable` | Project not built | Remove `-SkipBuild` or build manually | +| `Could not find DcpModelCreation events` | Trace too short or events not emitted | Increase `-TraceDurationSeconds` | +| `Application exited immediately` | App crash on startup | Check app logs, ensure dependencies are available | +| `dotnet-trace exited with code != 0` | Trace collection error | Check verbose output; trace file may still be valid | + +## Comparing Before/After Performance + +To measure the impact of a code change: + +```powershell +# 1. Measure baseline (on main branch) +git checkout main +.\tools\perf\Measure-StartupPerformance.ps1 -Iterations 5 -PreserveTraces -TraceOutputDirectory "C:\traces\baseline" + +# 2. Measure with changes +git checkout my-feature-branch +.\tools\perf\Measure-StartupPerformance.ps1 -Iterations 5 -PreserveTraces -TraceOutputDirectory "C:\traces\feature" + +# 3. Compare the reported averages and std devs +``` + +Use enough iterations (5+) and a consistent pause between iterations for reliable comparisons. + +## Collecting Traces for Manual Analysis + +If you need to inspect trace files manually (e.g., in PerfView or Visual Studio): + +```powershell +.\tools\perf\Measure-StartupPerformance.ps1 -PreserveTraces -TraceOutputDirectory "C:\my-traces" +``` + +See `docs/getting-perf-traces.md` for guidance on analyzing traces with PerfView or `dotnet trace report`. + +## EventSource Provider Details + +The `Microsoft-Aspire-Hosting` EventSource emits events for key Aspire lifecycle milestones. The startup performance script focuses on: + +| Event ID | Event Name | Description | +|----------|------------|-------------| +| 17 | `DcpModelCreationStart` | Marks the beginning of DCP model creation | +| 18 | `DcpModelCreationStop` | Marks the completion of DCP model creation | + +The measured startup time is the wall-clock difference between these two events, representing the time to create all application services and supporting dependencies. diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 06975dcd71c..e802e904706 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -99,3 +99,26 @@ When you comment on a PR (not an issue), the workflow will automatically push ch ### Concurrency The workflow uses concurrency groups based on the issue/PR number to prevent race conditions when multiple commands are issued on the same issue. + +## Backmerge Release Workflow + +The `backmerge-release.yml` workflow automatically creates PRs to merge changes from `release/13.2` back into `main`. + +### Schedule + +Runs daily at 00:00 UTC (4pm PT during standard time, 5pm PT during daylight saving time). Can also be triggered manually via `workflow_dispatch`. + +### Behavior + +1. **Change Detection**: Checks if `release/13.2` has commits not in `main` +2. **PR Creation**: If changes exist, creates a PR to merge `release/13.2` → `main` +3. **Auto-merge**: Enables GitHub's auto-merge feature, so the PR merges automatically once approved +4. **Conflict Handling**: If merge conflicts occur, creates an issue instead of a PR + +### Assignees + +PRs and conflict issues are automatically assigned to @joperezr and @radical. + +### Manual Trigger + +To trigger manually, go to Actions → "Backmerge Release to Main" → "Run workflow". diff --git a/.github/workflows/backmerge-release.yml b/.github/workflows/backmerge-release.yml new file mode 100644 index 00000000000..0e530c49dfc --- /dev/null +++ b/.github/workflows/backmerge-release.yml @@ -0,0 +1,166 @@ +name: Backmerge Release to Main + +on: + schedule: + - cron: '0 0 * * *' # Runs daily at 00:00 UTC (16:00 PST / 17:00 PDT) + workflow_dispatch: # Allow manual trigger + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + backmerge: + runs-on: ubuntu-latest + timeout-minutes: 15 + if: ${{ github.repository_owner == 'dotnet' }} + + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 # Full history needed for merge + + - name: Check for changes to backmerge + id: check + run: | + git fetch origin main release/13.2 + BEHIND_COUNT=$(git rev-list --count origin/main..origin/release/13.2) + echo "behind_count=$BEHIND_COUNT" >> $GITHUB_OUTPUT + if [ "$BEHIND_COUNT" -gt 0 ]; then + echo "changes=true" >> $GITHUB_OUTPUT + echo "Found $BEHIND_COUNT commits in release/13.2 not in main" + else + echo "changes=false" >> $GITHUB_OUTPUT + echo "No changes to backmerge - release/13.2 is up-to-date with main" + fi + + - name: Attempt merge and create branch + if: steps.check.outputs.changes == 'true' + id: merge + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git checkout origin/main + git checkout -b backmerge/release-13.2-to-main + + # Attempt the merge + if git merge origin/release/13.2 --no-edit; then + echo "merge_success=true" >> $GITHUB_OUTPUT + git push origin backmerge/release-13.2-to-main --force + echo "Merge successful, branch pushed" + else + echo "merge_success=false" >> $GITHUB_OUTPUT + git merge --abort + echo "Merge conflicts detected" + fi + + - name: Create or update Pull Request + if: steps.check.outputs.changes == 'true' && steps.merge.outputs.merge_success == 'true' + id: create-pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Check if a PR already exists for this branch + EXISTING_PR=$(gh pr list --head backmerge/release-13.2-to-main --base main --json number --jq '.[0].number // empty') + + if [ -n "$EXISTING_PR" ]; then + echo "PR #$EXISTING_PR already exists, updating it" + echo "pull_request_number=$EXISTING_PR" >> $GITHUB_OUTPUT + else + PR_BODY="## Automated Backmerge + + This PR merges changes from \`release/13.2\` back into \`main\`. + + **Commits to merge:** ${{ steps.check.outputs.behind_count }} + + This PR was created automatically to keep \`main\` up-to-date with release branch changes. + Once approved, please merge using a **merge commit** (not squash or rebase). + + --- + *This PR was generated by the [backmerge-release](${{ github.server_url }}/${{ github.repository }}/actions/workflows/backmerge-release.yml) workflow.*" + + # Remove leading whitespace from heredoc-style body + PR_BODY=$(echo "$PR_BODY" | sed 's/^ //') + + PR_URL=$(gh pr create \ + --head backmerge/release-13.2-to-main \ + --base main \ + --title "[Automated] Backmerge release/13.2 to main" \ + --body "$PR_BODY" \ + --assignee joperezr,radical \ + --label area-engineering-systems) + + PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') + if [ -z "$PR_NUMBER" ]; then + echo "::error::Failed to extract PR number from: $PR_URL" + exit 1 + fi + echo "pull_request_number=$PR_NUMBER" >> $GITHUB_OUTPUT + echo "Created PR #$PR_NUMBER" + fi + + - name: Create issue for merge conflicts + if: steps.check.outputs.changes == 'true' && steps.merge.outputs.merge_success == 'false' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const workflowRunUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + // Check if there's already an open issue for this + const existingIssues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'backmerge-conflict', + creator: 'github-actions[bot]' + }); + + if (existingIssues.data.length > 0) { + console.log(`Existing backmerge conflict issue found: #${existingIssues.data[0].number}`); + // Add a comment to the existing issue + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existingIssues.data[0].number, + body: `⚠️ Merge conflicts still exist.\n\n**Workflow run:** ${workflowRunUrl}\n\nPlease resolve the conflicts manually.` + }); + return; + } + + // Create a new issue + const issueBody = [ + '## Backmerge Conflict', + '', + 'The automated backmerge from `release/13.2` to `main` failed due to merge conflicts.', + '', + '### What to do', + '', + '1. Checkout main and attempt the merge locally:', + ' ```bash', + ' git checkout main', + ' git pull origin main', + ' git merge origin/release/13.2', + ' ```', + '2. Resolve the conflicts', + '3. Push the merge commit or create a PR manually', + '', + '### Details', + '', + `**Workflow run:** ${workflowRunUrl}`, + '**Commits to merge:** ${{ steps.check.outputs.behind_count }}', + '', + '---', + `*This issue was created automatically by the [backmerge-release](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/workflows/backmerge-release.yml) workflow.*` + ].join('\n'); + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: '[Backmerge] Merge conflicts between release/13.2 and main', + body: issueBody, + assignees: ['joperezr', 'radical'], + labels: ['area-engineering-systems', 'backmerge-conflict'] + }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0007e8030f..0901bf2f888 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: eng/pipelines/.* eng/test-configuration.json \.github/workflows/apply-test-attributes.yml + \.github/workflows/backmerge-release.yml \.github/workflows/backport.yml \.github/workflows/dogfood-comment.yml \.github/workflows/generate-api-diffs.yml diff --git a/.github/workflows/daily-repo-status.lock.yml b/.github/workflows/daily-repo-status.lock.yml new file mode 100644 index 00000000000..f226e7a052b --- /dev/null +++ b/.github/workflows/daily-repo-status.lock.yml @@ -0,0 +1,1101 @@ +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.45.5). DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Daily burndown report for the Aspire 13.2 milestone. Tracks progress +# on issues closed, new bugs found, notable changes merged into the +# release/13.2 branch, pending PR reviews, and discussions. Generates +# a 7-day burndown chart using cached daily snapshots. +# +# frontmatter-hash: 427ab537ab52b999a8cbb139515b504ba7359549cab995530c129ea037f08ef0 + +name: "13.2 Release Burndown Report" +"on": + schedule: + - cron: "42 9 * * *" + # Friendly format: daily around 9am (scattered) + workflow_dispatch: + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}" + +run-name: "13.2 Release Burndown Report" + +jobs: + activation: + runs-on: ubuntu-slim + permissions: + contents: read + outputs: + comment_id: "" + comment_repo: "" + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 + with: + destination: /opt/gh-aw/actions + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + .github + .agents + fetch-depth: 1 + persist-credentials: false + - name: Check workflow file timestamps + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_WORKFLOW_FILE: "daily-repo-status.lock.yml" + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + run: | + bash /opt/gh-aw/actions/create_prompt_first.sh + cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/cache_memory_prompt.md" >> "$GH_AW_PROMPT" + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + GitHub API Access Instructions + + The gh CLI is NOT authenticated. Do NOT use gh commands for GitHub operations. + + + To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls. + + Temporary IDs: Some safe output tools support a temporary ID field (usually named temporary_id) so you can reference newly-created items elsewhere in the SAME agent output (for example, using #aw_abc1 in a later body). + + **IMPORTANT - temporary_id format rules:** + - If you DON'T need to reference the item later, OMIT the temporary_id field entirely (it will be auto-generated if needed) + - If you DO need cross-references/chaining, you MUST match this EXACT validation regex: /^aw_[A-Za-z0-9]{3,8}$/i + - Format: aw_ prefix followed by 3 to 8 alphanumeric characters (A-Z, a-z, 0-9, case-insensitive) + - Valid alphanumeric characters: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 + - INVALID examples: aw_ab (too short), aw_123456789 (too long), aw_test-id (contains hyphen), aw_id_123 (contains underscore) + - VALID examples: aw_abc, aw_abc1, aw_Test123, aw_A1B2C3D4, aw_12345678 + - To generate valid IDs: use 3-8 random alphanumeric characters or omit the field to let the system auto-generate + + Do NOT invent other aw_* formats — downstream steps will reject them with validation errors matching against /^aw_[A-Za-z0-9]{3,8}$/i. + + Discover available tools from the safeoutputs MCP server. + + **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped. + + **Note**: If you made no other safe output tool calls during this workflow execution, call the "noop" tool to provide a status message indicating completion or that no actions were needed. + + + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + {{#runtime-import .github/workflows/daily-repo-status.md}} + GH_AW_PROMPT_EOF + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ALLOWED_EXTENSIONS: '' + GH_AW_CACHE_DESCRIPTION: '' + GH_AW_CACHE_DIR: '/tmp/gh-aw/cache-memory/' + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: ${{ needs.pre_activation.outputs.matched_command }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_ALLOWED_EXTENSIONS: process.env.GH_AW_ALLOWED_EXTENSIONS, + GH_AW_CACHE_DESCRIPTION: process.env.GH_AW_CACHE_DESCRIPTION, + GH_AW_CACHE_DIR: process.env.GH_AW_CACHE_DIR, + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Upload prompt artifact + if: success() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: prompt + path: /tmp/gh-aw/aw-prompts/prompt.txt + retention-days: 1 + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + contents: read + discussions: read + issues: read + pull-requests: read + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_WORKFLOW_ID_SANITIZED: dailyrepostatus + outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + model: ${{ steps.generate_aw_info.outputs.model }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 + with: + destination: /opt/gh-aw/actions + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh + # Cache memory file share configuration from frontmatter processed below + - name: Create cache-memory directory + run: bash /opt/gh-aw/actions/create_cache_memory_dir.sh + - name: Restore cache-memory file share data + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} + path: /tmp/gh-aw/cache-memory + restore-keys: | + memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}- + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Generate agentic run info + id: generate_aw_info + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "copilot", + engine_name: "GitHub Copilot CLI", + model: process.env.GH_AW_MODEL_AGENT_COPILOT || "", + version: "", + agent_version: "0.0.410", + cli_version: "v0.45.5", + workflow_name: "13.2 Release Burndown Report", + experimental: false, + supports_tools_allowlist: true, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + staged: false, + allowed_domains: ["defaults"], + firewall_enabled: true, + awf_version: "v0.19.1", + awmg_version: "v0.1.4", + steps: { + firewall: "squid" + }, + created_at: new Date().toISOString() + }; + + // Write to /tmp/gh-aw directory to avoid inclusion in PR + const tmpPath = '/tmp/gh-aw/aw_info.json'; + fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); + console.log('Generated aw_info.json at:', tmpPath); + console.log(JSON.stringify(awInfo, null, 2)); + + // Set model as output for reuse in other steps/jobs + core.setOutput('model', awInfo.model); + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Install GitHub Copilot CLI + run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.410 + - name: Install awf binary + run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.19.1 + - name: Download container images + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.19.1 ghcr.io/github/gh-aw-firewall/squid:0.19.1 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 node:lts-alpine + - name: Write Safe Outputs Config + run: | + mkdir -p /opt/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' + {"create_issue":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}} + GH_AW_SAFE_OUTPUTS_CONFIG_EOF + cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' + [ + { + "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[13.2-burndown] \". Labels [report burndown] will be automatically added.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.", + "type": "string" + }, + "labels": { + "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.", + "items": { + "type": "string" + }, + "type": "array" + }, + "parent": { + "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123', 'aw_Test123') from a previously created issue in the same workflow run.", + "type": [ + "number", + "string" + ] + }, + "temporary_id": { + "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 3 to 8 alphanumeric characters (e.g., 'aw_abc1', 'aw_Test123'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", + "pattern": "^aw_[A-Za-z0-9]{3,8}$", + "type": "string" + }, + "title": { + "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.", + "type": "string" + } + }, + "required": [ + "title", + "body" + ], + "type": "object" + }, + "name": "create_issue" + }, + { + "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "reason": { + "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).", + "type": "string" + }, + "tool": { + "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.", + "type": "string" + } + }, + "required": [ + "reason" + ], + "type": "object" + }, + "name": "missing_tool" + }, + { + "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "name": "noop" + }, + { + "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "context": { + "description": "Additional context about the missing data or where it should come from (max 256 characters).", + "type": "string" + }, + "data_type": { + "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.", + "type": "string" + }, + "reason": { + "description": "Explanation of why this data is needed to complete the task (max 256 characters).", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "missing_data" + } + ] + GH_AW_SAFE_OUTPUTS_TOOLS_EOF + cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + GH_AW_SAFE_OUTPUTS_VALIDATION_EOF + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash /opt/gh-aw/actions/start_safe_outputs_server.sh + + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p /tmp/gh-aw/mcp-config + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.4' + + mkdir -p /home/runner/.copilot + cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v0.30.3", + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "repos,issues,pull_requests,discussions,search" + } + }, + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_EOF + - name: Generate workflow overview + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); + await generateWorkflowOverview(core); + - name: Download prompt artifact + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: prompt + path: /tmp/gh-aw/aw-prompts + - name: Clean git credentials + run: bash /opt/gh-aw/actions/clean_git_credentials.sh + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + # --allow-tool github + # --allow-tool safeoutputs + # --allow-tool shell(cat) + # --allow-tool shell(date) + # --allow-tool shell(echo) + # --allow-tool shell(grep) + # --allow-tool shell(head) + # --allow-tool shell(ls) + # --allow-tool shell(pwd) + # --allow-tool shell(sort) + # --allow-tool shell(tail) + # --allow-tool shell(uniq) + # --allow-tool shell(wc) + # --allow-tool shell(yq) + # --allow-tool write + timeout-minutes: 20 + run: | + set -o pipefail + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.19.1 --skip-pull \ + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --add-dir /tmp/gh-aw/cache-memory/ --allow-all-paths --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + GH_AW_MODEL_AGENT_COPILOT: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} + GITHUB_WORKSPACE: ${{ github.workspace }} + XDG_CONFIG_HOME: /home/runner + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: | + # Copy Copilot session state files to logs folder for artifact collection + # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them + SESSION_STATE_DIR="$HOME/.copilot/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + + if [ -d "$SESSION_STATE_DIR" ]; then + echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" + mkdir -p "$LOGS_DIR" + cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true + echo "Session state files copied successfully" + else + echo "No session-state directory found at $SESSION_STATE_DIR" + fi + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload Safe Outputs + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: safe-output + path: ${{ env.GH_AW_SAFE_OUTPUTS }} + if-no-files-found: warn + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Upload sanitized agent output + if: always() && env.GH_AW_AGENT_OUTPUT + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: agent-output + path: ${{ env.GH_AW_AGENT_OUTPUT }} + if-no-files-found: warn + - name: Upload engine output files + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: agent_outputs + path: | + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + if-no-files-found: ignore + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Upload cache-memory data as artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + if: always() + with: + name: cache-memory + path: /tmp/gh-aw/cache-memory + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: agent-artifacts + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/agent/ + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - detection + - safe_outputs + - update_cache_memory + if: (always()) && (needs.agent.result != 'skipped') + runs-on: ubuntu-slim + permissions: + contents: read + issues: write + outputs: + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process No-Op Messages + id: noop + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: 1 + GH_AW_WORKFLOW_NAME: "13.2 Release Burndown Report" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/noop.cjs'); + await main(); + - name: Record Missing Tool + id: missing_tool + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "13.2 Release Burndown Report" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Handle Agent Failure + id: handle_agent_failure + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "13.2 Release Burndown Report" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "daily-repo-status" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + - name: Handle No-Op Message + id: handle_noop_message + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "13.2 Release Burndown Report" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); + await main(); + + detection: + needs: agent + if: needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true' + runs-on: ubuntu-latest + permissions: {} + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" + timeout-minutes: 10 + outputs: + success: ${{ steps.parse_results.outputs.success }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 + with: + destination: /opt/gh-aw/actions + - name: Download agent artifacts + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent-artifacts + path: /tmp/gh-aw/threat-detection/ + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent-output + path: /tmp/gh-aw/threat-detection/ + - name: Echo agent output types + env: + AGENT_OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} + run: | + echo "Agent output-types: $AGENT_OUTPUT_TYPES" + - name: Setup threat detection + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + WORKFLOW_NAME: "13.2 Release Burndown Report" + WORKFLOW_DESCRIPTION: "Daily burndown report for the Aspire 13.2 milestone. Tracks progress\non issues closed, new bugs found, notable changes merged into the\nrelease/13.2 branch, pending PR reviews, and discussions. Generates\na 7-day burndown chart using cached daily snapshots." + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Install GitHub Copilot CLI + run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.410 + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + # --allow-tool shell(cat) + # --allow-tool shell(grep) + # --allow-tool shell(head) + # --allow-tool shell(jq) + # --allow-tool shell(ls) + # --allow-tool shell(tail) + # --allow-tool shell(wc) + timeout-minutes: 20 + run: | + set -o pipefail + COPILOT_CLI_INSTRUCTION="$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" + mkdir -p /tmp/ + mkdir -p /tmp/gh-aw/ + mkdir -p /tmp/gh-aw/agent/ + mkdir -p /tmp/gh-aw/sandbox/agent/logs/ + copilot --add-dir /tmp/ --add-dir /tmp/gh-aw/ --add-dir /tmp/gh-aw/agent/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-tool 'shell(cat)' --allow-tool 'shell(grep)' --allow-tool 'shell(head)' --allow-tool 'shell(jq)' --allow-tool 'shell(ls)' --allow-tool 'shell(tail)' --allow-tool 'shell(wc)' --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$COPILOT_CLI_INSTRUCTION"${GH_AW_MODEL_DETECTION_COPILOT:+ --model "$GH_AW_MODEL_DETECTION_COPILOT"} 2>&1 | tee /tmp/gh-aw/threat-detection/detection.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_MODEL_DETECTION_COPILOT: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} + GITHUB_WORKSPACE: ${{ github.workspace }} + XDG_CONFIG_HOME: /home/runner + - name: Parse threat detection results + id: parse_results + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + - name: Upload threat detection log + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: threat-detection.log + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + + safe_outputs: + needs: + - agent + - detection + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.detection.outputs.success == 'true') + runs-on: ubuntu-slim + permissions: + contents: read + issues: write + timeout-minutes: 15 + env: + GH_AW_ENGINE_ID: "copilot" + GH_AW_WORKFLOW_ID: "daily-repo-status" + GH_AW_WORKFLOW_NAME: "13.2 Release Burndown Report" + outputs: + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"close_older_issues\":true,\"labels\":[\"report\",\"burndown\"],\"max\":1,\"title_prefix\":\"[13.2-burndown] \"},\"missing_data\":{},\"missing_tool\":{}}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + + update_cache_memory: + needs: + - agent + - detection + if: always() && needs.detection.outputs.success == 'true' + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 + with: + destination: /opt/gh-aw/actions + - name: Download cache-memory artifact (default) + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + continue-on-error: true + with: + name: cache-memory + path: /tmp/gh-aw/cache-memory + - name: Save cache-memory to cache (default) + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} + path: /tmp/gh-aw/cache-memory + diff --git a/.github/workflows/daily-repo-status.md b/.github/workflows/daily-repo-status.md new file mode 100644 index 00000000000..6291aed99d5 --- /dev/null +++ b/.github/workflows/daily-repo-status.md @@ -0,0 +1,131 @@ +--- +description: | + Daily burndown report for the Aspire 13.2 milestone. Tracks progress + on issues closed, new bugs found, notable changes merged into the + release/13.2 branch, pending PR reviews, and discussions. Generates + a 7-day burndown chart using cached daily snapshots. + +on: + schedule: daily around 9am + workflow_dispatch: + +permissions: + contents: read + issues: read + pull-requests: read + discussions: read + +network: defaults + +tools: + github: + toolsets: [repos, issues, pull_requests, discussions, search] + lockdown: false + cache-memory: + bash: ["echo", "date", "cat", "wc"] + +safe-outputs: + create-issue: + title-prefix: "[13.2-burndown] " + labels: [report, burndown] + close-older-issues: true +--- + +# 13.2 Release Burndown Report + +Create a daily burndown report for the **Aspire 13.2 milestone** as a GitHub issue. +The primary goal of this report is to help the team track progress towards the 13.2 release. + +## Data gathering + +Collect the following data using the GitHub tools. All time-based queries should look at the **last 24 hours** unless stated otherwise. + +### 1. Milestone snapshot + +- Find the milestone named **13.2** in this repository. +- Count the **total open issues** and **total closed issues** in the milestone, **excluding pull requests**. Use an issues-only filter (for example, a search query like `is:issue milestone:"13.2" state:open` / `state:closed`) so the counts are consistent across tools. +- Store today's snapshot (date, open count, closed count) using the **cache-memory** tool with the key `burndown-13.2-snapshot`. + - The value for this key **must** be a JSON array of objects with the exact shape: + `[{ "date": "YYYY-MM-DD", "open": , "closed": }, ...]` + - When writing today's data: + 1. Read the existing cache value (if any) and parse it as JSON. If the cache is empty or invalid, start from an empty array. + 2. If an entry for today's date already exists, **replace** it instead of adding a duplicate. + 3. If no entry exists, append a new object. + 4. Sort by date ascending and trim to the **most recent 7 entries**. + 5. Serialize back to JSON and overwrite the cache value. + +### 2. Issues closed in the last 24 hours (13.2 milestone) + +- Search for issues in this repository that were **closed in the last 24 hours** and belong to the **13.2 milestone**. +- For each issue, note the issue number, title, and who closed it. + +### 3. New issues added to 13.2 milestone in the last 24 hours + +- Search for issues in this repository that were **opened in the last 24 hours** and are assigned to the **13.2 milestone**. +- Highlight any that are labeled as `bug` — these are newly discovered bugs for the release. + +### 4. Notable changes merged into release/13.2 + +- Look at pull requests **merged in the last 24 hours** whose **base branch is `release/13.2`**. +- Summarize the most impactful or interesting changes (group by area if possible). + +### 5. PRs pending review targeting release/13.2 + +- Find **open pull requests** with base branch `release/13.2` that are **awaiting reviews** (have no approving reviews yet, or have review requests pending). +- List them with PR number, title, author, and how long they've been open. + +### 6. Discussions related to 13.2 + +- Search discussions in this repository that mention "13.2" or the milestone, especially any **recent activity in the last 24 hours**. +- Briefly summarize any relevant discussion threads. + +### 7. General triage needs (secondary) + +- Briefly note any **new issues opened in the last 24 hours that have no milestone assigned** and may need triage. +- Keep this section short — the focus is on 13.2. + +## Burndown chart + +Using the historical data stored via **cache-memory** (key: `burndown-13.2-snapshot`), generate a **Mermaid xychart** showing the number of **open issues** in the 13.2 milestone over the last 7 days (or however many data points are available). + +Use this format so it renders natively in the GitHub issue: + +~~~ +```mermaid +xychart-beta + title "13.2 Milestone Burndown (Open Issues)" + x-axis [Feb 13, Feb 14, Feb 15, ...] + y-axis "Open Issues" 0 --> MAX + line [N1, N2, N3, ...] +``` +~~~ + +If fewer than 2 data points are available, note that the chart will become richer over the coming days as more snapshots are collected, and still show whatever data is available. + +## Report structure + +Create a GitHub issue with the following sections in this order: + +1. **📊 Burndown Chart** — The Mermaid chart (or a note that data is still being collected) +2. **📈 Milestone Progress** — Total open vs closed, percentage complete, net change today +3. **✅ Issues Closed Today** — Table or list of issues closed in the 13.2 milestone +4. **🐛 New Bugs Found** — Any new bug issues added to the 13.2 milestone +5. **🚀 Notable Changes Merged** — Summary of impactful PRs merged to release/13.2 +6. **👀 PRs Awaiting Review** — Open PRs targeting release/13.2 that need reviewer attention +7. **💬 Discussions** — Relevant 13.2 discussion activity +8. **📋 Triage Queue** — Brief list of un-milestoned issues that need attention (keep short) + +## Style + +- Be concise and data-driven — this is a status report, not a blog post +- Use tables for lists of issues and PRs where appropriate +- Use emojis for section headers to make scanning easy +- If there was no activity in a section, say so briefly (e.g., "No new bugs found today 🎉") +- End with a one-line motivational note for the team + +## Process + +1. Gather all the data described above +2. Read historical burndown data from cache-memory and store today's snapshot +3. Generate the burndown chart +4. Create a new GitHub issue with all sections populated diff --git a/AGENTS.md b/AGENTS.md index cb4d5711eb1..cb6596c3d31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -355,6 +355,7 @@ The following specialized skills are available in `.github/skills/`: - **test-management**: Quarantines or disables flaky/problematic tests using the QuarantineTools utility - **connection-properties**: Expert for creating and improving Connection Properties in Aspire resources - **dependency-update**: Guides dependency version updates by checking nuget.org, triggering the dotnet-migrate-package Azure DevOps pipeline, and monitoring runs +- **startup-perf**: Measures Aspire application startup performance using dotnet-trace and the TraceAnalyzer tool ## Pattern-Based Instructions diff --git a/docs/getting-perf-traces.md b/docs/getting-perf-traces.md index a669c591ee0..94a5a14a0d5 100644 --- a/docs/getting-perf-traces.md +++ b/docs/getting-perf-traces.md @@ -28,8 +28,16 @@ Once you are ready, hit "Start Collection" button and run your scenario. When done with the scenario, hit "Stop Collection". Wait for PerfView to finish merging and analyzing data (the "working" status bar stops flashing). -### Verify that the trace contains Aspire data +### Verify that PerfView trace contains Aspire data This is an optional step, but if you are wondering if your trace has been captured properly, you can check the following: 1. Open the trace (usually named PerfViewData.etl, if you haven't changed the name) and double click Events view. Verify you have a bunch of events from the Microsoft-Aspire-Hosting provider. + +## Profiling scripts + +The `tools/perf` folder in the repository contains scripts that help quickly assess the impact of code changes on key performance scenarios. Currently available scripts are: + +| Script | Description | +| --- | --------- | +| `Measure-StartupPerformance.ps1` | Measures startup time for a specific Aspire project. More specifically, the script measures the time to get all application services and supporting dependencies CREATED; the application is not necessarily responsive after measured time. | diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 1cdf20f5f5e..6e938847991 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -179,33 +179,33 @@ - + https://github.com/dotnet/arcade - 27e190e2a8053738859c082e2f70df62e01ff524 + 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d - + https://github.com/dotnet/arcade - 27e190e2a8053738859c082e2f70df62e01ff524 + 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d - + https://github.com/dotnet/arcade - 27e190e2a8053738859c082e2f70df62e01ff524 + 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d - + https://github.com/dotnet/arcade - 27e190e2a8053738859c082e2f70df62e01ff524 + 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d - + https://github.com/dotnet/arcade - 27e190e2a8053738859c082e2f70df62e01ff524 + 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d - + https://github.com/dotnet/arcade - 27e190e2a8053738859c082e2f70df62e01ff524 + 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d - + https://github.com/dotnet/arcade - 27e190e2a8053738859c082e2f70df62e01ff524 + 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d diff --git a/eng/Versions.props b/eng/Versions.props index 33153ae8d66..0dc60eb7c0f 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -2,7 +2,7 @@ 13 - 2 + 3 0 $(MajorVersion).$(MinorVersion).$(PatchVersion) preview.1 @@ -38,9 +38,9 @@ 0.22.6 0.22.6 - 11.0.0-beta.25610.3 - 11.0.0-beta.25610.3 - 11.0.0-beta.25610.3 + 10.0.0-beta.26110.1 + 10.0.0-beta.26110.1 + 10.0.0-beta.26110.1 10.0.2 10.2.0 diff --git a/eng/build.sh b/eng/build.sh index c80b2c68aba..58596335da2 100755 --- a/eng/build.sh +++ b/eng/build.sh @@ -150,7 +150,7 @@ while [[ $# > 0 ]]; do ;; -mauirestore) - extraargs="$extraargs -restoreMaui" + export restore_maui=true shift 1 ;; diff --git a/eng/common/SetupNugetSources.ps1 b/eng/common/SetupNugetSources.ps1 index fc8d618014e..65ed3a8adef 100644 --- a/eng/common/SetupNugetSources.ps1 +++ b/eng/common/SetupNugetSources.ps1 @@ -1,6 +1,7 @@ # This script adds internal feeds required to build commits that depend on internal package sources. For instance, -# dotnet6-internal would be added automatically if dotnet6 was found in the nuget.config file. In addition also enables -# disabled internal Maestro (darc-int*) feeds. +# dotnet6-internal would be added automatically if dotnet6 was found in the nuget.config file. Similarly, +# dotnet-eng-internal and dotnet-tools-internal are added if dotnet-eng and dotnet-tools are present. +# In addition, this script also enables disabled internal Maestro (darc-int*) feeds. # # Optionally, this script also adds a credential entry for each of the internal feeds if supplied. # @@ -173,4 +174,16 @@ foreach ($dotnetVersion in $dotnetVersions) { } } +# Check for dotnet-eng and add dotnet-eng-internal if present +$dotnetEngSource = $sources.SelectSingleNode("add[@key='dotnet-eng']") +if ($dotnetEngSource -ne $null) { + AddOrEnablePackageSource -Sources $sources -DisabledPackageSources $disabledSources -SourceName "dotnet-eng-internal" -SourceEndPoint "https://pkgs.dev.azure.com/dnceng/internal/_packaging/dotnet-eng-internal/nuget/$feedSuffix" -Creds $creds -Username $userName -pwd $Password +} + +# Check for dotnet-tools and add dotnet-tools-internal if present +$dotnetToolsSource = $sources.SelectSingleNode("add[@key='dotnet-tools']") +if ($dotnetToolsSource -ne $null) { + AddOrEnablePackageSource -Sources $sources -DisabledPackageSources $disabledSources -SourceName "dotnet-tools-internal" -SourceEndPoint "https://pkgs.dev.azure.com/dnceng/internal/_packaging/dotnet-tools-internal/nuget/$feedSuffix" -Creds $creds -Username $userName -pwd $Password +} + $doc.Save($filename) diff --git a/eng/common/SetupNugetSources.sh b/eng/common/SetupNugetSources.sh index b97cc536379..b2163abbe71 100755 --- a/eng/common/SetupNugetSources.sh +++ b/eng/common/SetupNugetSources.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # This script adds internal feeds required to build commits that depend on internal package sources. For instance, -# dotnet6-internal would be added automatically if dotnet6 was found in the nuget.config file. In addition also enables -# disabled internal Maestro (darc-int*) feeds. +# dotnet6-internal would be added automatically if dotnet6 was found in the nuget.config file. Similarly, +# dotnet-eng-internal and dotnet-tools-internal are added if dotnet-eng and dotnet-tools are present. +# In addition, this script also enables disabled internal Maestro (darc-int*) feeds. # # Optionally, this script also adds a credential entry for each of the internal feeds if supplied. # @@ -173,6 +174,18 @@ for DotNetVersion in ${DotNetVersions[@]} ; do fi done +# Check for dotnet-eng and add dotnet-eng-internal if present +grep -i " /dev/null +if [ "$?" == "0" ]; then + AddOrEnablePackageSource "dotnet-eng-internal" "https://pkgs.dev.azure.com/dnceng/internal/_packaging/dotnet-eng-internal/nuget/$FeedSuffix" +fi + +# Check for dotnet-tools and add dotnet-tools-internal if present +grep -i " /dev/null +if [ "$?" == "0" ]; then + AddOrEnablePackageSource "dotnet-tools-internal" "https://pkgs.dev.azure.com/dnceng/internal/_packaging/dotnet-tools-internal/nuget/$FeedSuffix" +fi + # I want things split line by line PrevIFS=$IFS IFS=$'\n' diff --git a/eng/common/build.ps1 b/eng/common/build.ps1 index c10aba98ac6..8cfee107e7a 100644 --- a/eng/common/build.ps1 +++ b/eng/common/build.ps1 @@ -30,7 +30,6 @@ Param( [string] $runtimeSourceFeedKey = '', [switch] $excludePrereleaseVS, [switch] $nativeToolsOnMachine, - [switch] $restoreMaui, [switch] $help, [Parameter(ValueFromRemainingArguments=$true)][String[]]$properties ) @@ -77,7 +76,6 @@ function Print-Usage() { Write-Host " -nodeReuse Sets nodereuse msbuild parameter ('true' or 'false')" Write-Host " -buildCheck Sets /check msbuild parameter" Write-Host " -fromVMR Set when building from within the VMR" - Write-Host " -restoreMaui Restore the MAUI workload after restore (only on Windows/macOS)" Write-Host "" Write-Host "Command line arguments not listed above are passed thru to msbuild." diff --git a/eng/common/build.sh b/eng/common/build.sh index 09d1f8e6d9c..9767bb411a4 100755 --- a/eng/common/build.sh +++ b/eng/common/build.sh @@ -44,7 +44,6 @@ usage() echo " --warnAsError Sets warnaserror msbuild parameter ('true' or 'false')" echo " --buildCheck Sets /check msbuild parameter" echo " --fromVMR Set when building from within the VMR" - echo " --restoreMaui Restore the MAUI workload after restore (only on macOS)" echo "" echo "Command line arguments not listed above are passed thru to msbuild." echo "Arguments can also be passed in with a single hyphen." @@ -77,7 +76,6 @@ sign=false public=false ci=false clean=false -restore_maui=false warn_as_error=true node_reuse=true @@ -94,7 +92,7 @@ runtime_source_feed='' runtime_source_feed_key='' properties=() -while [[ $# -gt 0 ]]; do +while [[ $# > 0 ]]; do opt="$(echo "${1/#--/-}" | tr "[:upper:]" "[:lower:]")" case "$opt" in -help|-h) @@ -185,9 +183,6 @@ while [[ $# -gt 0 ]]; do -buildcheck) build_check=true ;; - -restoremaui|-restore-maui) - restore_maui=true - ;; -runtimesourcefeed) runtime_source_feed=$2 shift diff --git a/eng/common/core-templates/job/job.yml b/eng/common/core-templates/job/job.yml index 748c4f07a64..5ce51840619 100644 --- a/eng/common/core-templates/job/job.yml +++ b/eng/common/core-templates/job/job.yml @@ -19,8 +19,6 @@ parameters: # publishing defaults artifacts: '' enableMicrobuild: false - enablePreviewMicrobuild: false - microbuildPluginVersion: 'latest' enableMicrobuildForMacAndLinux: false microbuildUseESRP: true enablePublishBuildArtifacts: false @@ -73,8 +71,6 @@ jobs: templateContext: ${{ parameters.templateContext }} variables: - - name: AllowPtrToDetectTestRunRetryFiles - value: true - ${{ if ne(parameters.enableTelemetry, 'false') }}: - name: DOTNET_CLI_TELEMETRY_PROFILE value: '$(Build.Repository.Uri)' @@ -132,8 +128,6 @@ jobs: - template: /eng/common/core-templates/steps/install-microbuild.yml parameters: enableMicrobuild: ${{ parameters.enableMicrobuild }} - enablePreviewMicrobuild: ${{ parameters.enablePreviewMicrobuild }} - microbuildPluginVersion: ${{ parameters.microbuildPluginVersion }} enableMicrobuildForMacAndLinux: ${{ parameters.enableMicrobuildForMacAndLinux }} microbuildUseESRP: ${{ parameters.microbuildUseESRP }} continueOnError: ${{ parameters.continueOnError }} @@ -159,8 +153,6 @@ jobs: - template: /eng/common/core-templates/steps/cleanup-microbuild.yml parameters: enableMicrobuild: ${{ parameters.enableMicrobuild }} - enablePreviewMicrobuild: ${{ parameters.enablePreviewMicrobuild }} - microbuildPluginVersion: ${{ parameters.microbuildPluginVersion }} enableMicrobuildForMacAndLinux: ${{ parameters.enableMicrobuildForMacAndLinux }} continueOnError: ${{ parameters.continueOnError }} diff --git a/eng/common/core-templates/job/publish-build-assets.yml b/eng/common/core-templates/job/publish-build-assets.yml index 8b5c635fe80..b955fac6e13 100644 --- a/eng/common/core-templates/job/publish-build-assets.yml +++ b/eng/common/core-templates/job/publish-build-assets.yml @@ -80,7 +80,7 @@ jobs: # If it's not devdiv, it's dnceng ${{ if ne(variables['System.TeamProject'], 'DevDiv') }}: name: NetCore1ESPool-Publishing-Internal - image: windows.vs2019.amd64 + image: windows.vs2022.amd64 os: windows steps: - ${{ if eq(parameters.is1ESPipeline, '') }}: @@ -91,8 +91,8 @@ jobs: fetchDepth: 3 clean: true - - ${{ if eq(parameters.isAssetlessBuild, 'false') }}: - - ${{ if eq(parameters.publishingVersion, 3) }}: + - ${{ if eq(parameters.isAssetlessBuild, 'false') }}: + - ${{ if eq(parameters.publishingVersion, 3) }}: - task: DownloadPipelineArtifact@2 displayName: Download Asset Manifests inputs: @@ -117,7 +117,7 @@ jobs: flattenFolders: true condition: ${{ parameters.condition }} continueOnError: ${{ parameters.continueOnError }} - + - task: NuGetAuthenticate@1 # Populate internal runtime variables. @@ -125,7 +125,7 @@ jobs: ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: parameters: legacyCredential: $(dn-bot-dnceng-artifact-feeds-rw) - + - template: /eng/common/templates/steps/enable-internal-runtimes.yml - task: AzureCLI@2 @@ -145,7 +145,7 @@ jobs: condition: ${{ parameters.condition }} continueOnError: ${{ parameters.continueOnError }} - + - task: powershell@2 displayName: Create ReleaseConfigs Artifact inputs: @@ -173,7 +173,7 @@ jobs: artifactName: AssetManifests displayName: 'Publish Merged Manifest' retryCountOnTaskFailure: 10 # for any logs being locked - sbomEnabled: false # we don't need SBOM for logs + sbomEnabled: false # we don't need SBOM for logs - template: /eng/common/core-templates/steps/publish-build-artifacts.yml parameters: @@ -190,7 +190,7 @@ jobs: BARBuildId: ${{ parameters.BARBuildId }} PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} is1ESPipeline: ${{ parameters.is1ESPipeline }} - + # Darc is targeting 8.0, so make sure it's installed - task: UseDotNet@2 inputs: @@ -218,4 +218,4 @@ jobs: - template: /eng/common/core-templates/steps/publish-logs.yml parameters: is1ESPipeline: ${{ parameters.is1ESPipeline }} - JobLabel: 'Publish_Artifacts_Logs' + JobLabel: 'Publish_Artifacts_Logs' diff --git a/eng/common/core-templates/job/source-build.yml b/eng/common/core-templates/job/source-build.yml index 9d820f97421..1997c2ae00d 100644 --- a/eng/common/core-templates/job/source-build.yml +++ b/eng/common/core-templates/job/source-build.yml @@ -60,19 +60,19 @@ jobs: pool: ${{ if eq(variables['System.TeamProject'], 'public') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore-Svc-Public' ), False, 'NetCore-Public')] - demands: ImageOverride -equals build.ubuntu.2204.amd64 + demands: ImageOverride -equals build.azurelinux.3.amd64.open ${{ if eq(variables['System.TeamProject'], 'internal') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore1ESPool-Svc-Internal'), False, 'NetCore1ESPool-Internal')] - image: 1es-azurelinux-3 + image: build.azurelinux.3.amd64 os: linux ${{ else }}: pool: ${{ if eq(variables['System.TeamProject'], 'public') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore-Svc-Public' ), False, 'NetCore-Public')] - demands: ImageOverride -equals Build.Ubuntu.2204.Amd64.Open + demands: ImageOverride -equals build.azurelinux.3.amd64.open ${{ if eq(variables['System.TeamProject'], 'internal') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore1ESPool-Svc-Internal'), False, 'NetCore1ESPool-Internal')] - demands: ImageOverride -equals Build.Ubuntu.2204.Amd64 + demands: ImageOverride -equals build.azurelinux.3.amd64 ${{ if ne(parameters.platform.pool, '') }}: pool: ${{ parameters.platform.pool }} diff --git a/eng/common/core-templates/post-build/post-build.yml b/eng/common/core-templates/post-build/post-build.yml index 06864cd1feb..b942a79ef02 100644 --- a/eng/common/core-templates/post-build/post-build.yml +++ b/eng/common/core-templates/post-build/post-build.yml @@ -1,106 +1,106 @@ parameters: -# Which publishing infra should be used. THIS SHOULD MATCH THE VERSION ON THE BUILD MANIFEST. -# Publishing V1 is no longer supported -# Publishing V2 is no longer supported -# Publishing V3 is the default -- name: publishingInfraVersion - displayName: Which version of publishing should be used to promote the build definition? - type: number - default: 3 - values: - - 3 - -- name: BARBuildId - displayName: BAR Build Id - type: number - default: 0 - -- name: PromoteToChannelIds - displayName: Channel to promote BARBuildId to - type: string - default: '' - -- name: enableSourceLinkValidation - displayName: Enable SourceLink validation - type: boolean - default: false - -- name: enableSigningValidation - displayName: Enable signing validation - type: boolean - default: true - -- name: enableSymbolValidation - displayName: Enable symbol validation - type: boolean - default: false - -- name: enableNugetValidation - displayName: Enable NuGet validation - type: boolean - default: true - -- name: publishInstallersAndChecksums - displayName: Publish installers and checksums - type: boolean - default: true - -- name: requireDefaultChannels - displayName: Fail the build if there are no default channel(s) registrations for the current build - type: boolean - default: false - -- name: SDLValidationParameters - type: object - default: - enable: false - publishGdn: false - continueOnError: false - params: '' - artifactNames: '' - downloadArtifacts: true - -- name: isAssetlessBuild - type: boolean - displayName: Is Assetless Build - default: false - -# These parameters let the user customize the call to sdk-task.ps1 for publishing -# symbols & general artifacts as well as for signing validation -- name: symbolPublishingAdditionalParameters - displayName: Symbol publishing additional parameters - type: string - default: '' - -- name: artifactsPublishingAdditionalParameters - displayName: Artifact publishing additional parameters - type: string - default: '' - -- name: signingValidationAdditionalParameters - displayName: Signing validation additional parameters - type: string - default: '' - -# Which stages should finish execution before post-build stages start -- name: validateDependsOn - type: object - default: - - build - -- name: publishDependsOn - type: object - default: - - Validate - -# Optional: Call asset publishing rather than running in a separate stage -- name: publishAssetsImmediately - type: boolean - default: false - -- name: is1ESPipeline - type: boolean - default: false + # Which publishing infra should be used. THIS SHOULD MATCH THE VERSION ON THE BUILD MANIFEST. + # Publishing V1 is no longer supported + # Publishing V2 is no longer supported + # Publishing V3 is the default + - name: publishingInfraVersion + displayName: Which version of publishing should be used to promote the build definition? + type: number + default: 3 + values: + - 3 + + - name: BARBuildId + displayName: BAR Build Id + type: number + default: 0 + + - name: PromoteToChannelIds + displayName: Channel to promote BARBuildId to + type: string + default: '' + + - name: enableSourceLinkValidation + displayName: Enable SourceLink validation + type: boolean + default: false + + - name: enableSigningValidation + displayName: Enable signing validation + type: boolean + default: true + + - name: enableSymbolValidation + displayName: Enable symbol validation + type: boolean + default: false + + - name: enableNugetValidation + displayName: Enable NuGet validation + type: boolean + default: true + + - name: publishInstallersAndChecksums + displayName: Publish installers and checksums + type: boolean + default: true + + - name: requireDefaultChannels + displayName: Fail the build if there are no default channel(s) registrations for the current build + type: boolean + default: false + + - name: SDLValidationParameters + type: object + default: + enable: false + publishGdn: false + continueOnError: false + params: '' + artifactNames: '' + downloadArtifacts: true + + - name: isAssetlessBuild + type: boolean + displayName: Is Assetless Build + default: false + + # These parameters let the user customize the call to sdk-task.ps1 for publishing + # symbols & general artifacts as well as for signing validation + - name: symbolPublishingAdditionalParameters + displayName: Symbol publishing additional parameters + type: string + default: '' + + - name: artifactsPublishingAdditionalParameters + displayName: Artifact publishing additional parameters + type: string + default: '' + + - name: signingValidationAdditionalParameters + displayName: Signing validation additional parameters + type: string + default: '' + + # Which stages should finish execution before post-build stages start + - name: validateDependsOn + type: object + default: + - build + + - name: publishDependsOn + type: object + default: + - Validate + + # Optional: Call asset publishing rather than running in a separate stage + - name: publishAssetsImmediately + type: boolean + default: false + + - name: is1ESPipeline + type: boolean + default: false stages: - ${{ if or(eq( parameters.enableNugetValidation, 'true'), eq(parameters.enableSigningValidation, 'true'), eq(parameters.enableSourceLinkValidation, 'true'), eq(parameters.SDLValidationParameters.enable, 'true')) }}: @@ -108,10 +108,10 @@ stages: dependsOn: ${{ parameters.validateDependsOn }} displayName: Validate Build Assets variables: - - template: /eng/common/core-templates/post-build/common-variables.yml - - template: /eng/common/core-templates/variables/pool-providers.yml - parameters: - is1ESPipeline: ${{ parameters.is1ESPipeline }} + - template: /eng/common/core-templates/post-build/common-variables.yml + - template: /eng/common/core-templates/variables/pool-providers.yml + parameters: + is1ESPipeline: ${{ parameters.is1ESPipeline }} jobs: - job: displayName: NuGet Validation @@ -134,28 +134,28 @@ stages: demands: ImageOverride -equals windows.vs2026preview.scout.amd64 steps: - - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml - parameters: - BARBuildId: ${{ parameters.BARBuildId }} - PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} - is1ESPipeline: ${{ parameters.is1ESPipeline }} - - - task: DownloadBuildArtifacts@0 - displayName: Download Package Artifacts - inputs: - buildType: specific - buildVersionToDownload: specific - project: $(AzDOProjectName) - pipeline: $(AzDOPipelineId) - buildId: $(AzDOBuildId) - artifactName: PackageArtifacts - checkDownloadedFiles: true - - - task: PowerShell@2 - displayName: Validate - inputs: - filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/nuget-validation.ps1 - arguments: -PackagesPath $(Build.ArtifactStagingDirectory)/PackageArtifacts/ + - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml + parameters: + BARBuildId: ${{ parameters.BARBuildId }} + PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} + is1ESPipeline: ${{ parameters.is1ESPipeline }} + + - task: DownloadBuildArtifacts@0 + displayName: Download Package Artifacts + inputs: + buildType: specific + buildVersionToDownload: specific + project: $(AzDOProjectName) + pipeline: $(AzDOPipelineId) + buildId: $(AzDOBuildId) + artifactName: PackageArtifacts + checkDownloadedFiles: true + + - task: PowerShell@2 + displayName: Validate + inputs: + filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/nuget-validation.ps1 + arguments: -PackagesPath $(Build.ArtifactStagingDirectory)/PackageArtifacts/ - job: displayName: Signing Validation @@ -169,7 +169,7 @@ stages: os: windows # If it's not devdiv, it's dnceng ${{ else }}: - ${{ if eq(parameters.is1ESPipeline, true) }}: + ${{ if eq(parameters.is1ESPipeline, true) }}: name: $(DncEngInternalBuildPool) image: 1es-windows-2022 os: windows @@ -177,46 +177,46 @@ stages: name: $(DncEngInternalBuildPool) demands: ImageOverride -equals windows.vs2026preview.scout.amd64 steps: - - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml - parameters: - BARBuildId: ${{ parameters.BARBuildId }} - PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} - is1ESPipeline: ${{ parameters.is1ESPipeline }} - - - task: DownloadBuildArtifacts@0 - displayName: Download Package Artifacts - inputs: - buildType: specific - buildVersionToDownload: specific - project: $(AzDOProjectName) - pipeline: $(AzDOPipelineId) - buildId: $(AzDOBuildId) - artifactName: PackageArtifacts - checkDownloadedFiles: true - - # This is necessary whenever we want to publish/restore to an AzDO private feed - # Since sdk-task.ps1 tries to restore packages we need to do this authentication here - # otherwise it'll complain about accessing a private feed. - - task: NuGetAuthenticate@1 - displayName: 'Authenticate to AzDO Feeds' - - # Signing validation will optionally work with the buildmanifest file which is downloaded from - # Azure DevOps above. - - task: PowerShell@2 - displayName: Validate - inputs: - filePath: eng\common\sdk-task.ps1 - arguments: -task SigningValidation -restore -msbuildEngine vs - /p:PackageBasePath='$(Build.ArtifactStagingDirectory)/PackageArtifacts' - /p:SignCheckExclusionsFile='$(System.DefaultWorkingDirectory)/eng/SignCheckExclusionsFile.txt' - ${{ parameters.signingValidationAdditionalParameters }} - - - template: /eng/common/core-templates/steps/publish-logs.yml - parameters: - is1ESPipeline: ${{ parameters.is1ESPipeline }} - StageLabel: 'Validation' - JobLabel: 'Signing' - BinlogToolVersion: $(BinlogToolVersion) + - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml + parameters: + BARBuildId: ${{ parameters.BARBuildId }} + PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} + is1ESPipeline: ${{ parameters.is1ESPipeline }} + + - task: DownloadBuildArtifacts@0 + displayName: Download Package Artifacts + inputs: + buildType: specific + buildVersionToDownload: specific + project: $(AzDOProjectName) + pipeline: $(AzDOPipelineId) + buildId: $(AzDOBuildId) + artifactName: PackageArtifacts + checkDownloadedFiles: true + + # This is necessary whenever we want to publish/restore to an AzDO private feed + # Since sdk-task.ps1 tries to restore packages we need to do this authentication here + # otherwise it'll complain about accessing a private feed. + - task: NuGetAuthenticate@1 + displayName: 'Authenticate to AzDO Feeds' + + # Signing validation will optionally work with the buildmanifest file which is downloaded from + # Azure DevOps above. + - task: PowerShell@2 + displayName: Validate + inputs: + filePath: eng\common\sdk-task.ps1 + arguments: -task SigningValidation -restore -msbuildEngine vs + /p:PackageBasePath='$(Build.ArtifactStagingDirectory)/PackageArtifacts' + /p:SignCheckExclusionsFile='$(System.DefaultWorkingDirectory)/eng/SignCheckExclusionsFile.txt' + ${{ parameters.signingValidationAdditionalParameters }} + + - template: /eng/common/core-templates/steps/publish-logs.yml + parameters: + is1ESPipeline: ${{ parameters.is1ESPipeline }} + StageLabel: 'Validation' + JobLabel: 'Signing' + BinlogToolVersion: $(BinlogToolVersion) - job: displayName: SourceLink Validation @@ -230,7 +230,7 @@ stages: os: windows # If it's not devdiv, it's dnceng ${{ else }}: - ${{ if eq(parameters.is1ESPipeline, true) }}: + ${{ if eq(parameters.is1ESPipeline, true) }}: name: $(DncEngInternalBuildPool) image: 1es-windows-2022 os: windows @@ -238,33 +238,33 @@ stages: name: $(DncEngInternalBuildPool) demands: ImageOverride -equals windows.vs2026preview.scout.amd64 steps: - - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml - parameters: - BARBuildId: ${{ parameters.BARBuildId }} - PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} - is1ESPipeline: ${{ parameters.is1ESPipeline }} - - - task: DownloadBuildArtifacts@0 - displayName: Download Blob Artifacts - inputs: - buildType: specific - buildVersionToDownload: specific - project: $(AzDOProjectName) - pipeline: $(AzDOPipelineId) - buildId: $(AzDOBuildId) - artifactName: BlobArtifacts - checkDownloadedFiles: true - - - task: PowerShell@2 - displayName: Validate - inputs: - filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/sourcelink-validation.ps1 - arguments: -InputPath $(Build.ArtifactStagingDirectory)/BlobArtifacts/ - -ExtractPath $(Agent.BuildDirectory)/Extract/ - -GHRepoName $(Build.Repository.Name) - -GHCommit $(Build.SourceVersion) - -SourcelinkCliVersion $(SourceLinkCLIVersion) - continueOnError: true + - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml + parameters: + BARBuildId: ${{ parameters.BARBuildId }} + PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} + is1ESPipeline: ${{ parameters.is1ESPipeline }} + + - task: DownloadBuildArtifacts@0 + displayName: Download Blob Artifacts + inputs: + buildType: specific + buildVersionToDownload: specific + project: $(AzDOProjectName) + pipeline: $(AzDOPipelineId) + buildId: $(AzDOBuildId) + artifactName: BlobArtifacts + checkDownloadedFiles: true + + - task: PowerShell@2 + displayName: Validate + inputs: + filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/sourcelink-validation.ps1 + arguments: -InputPath $(Build.ArtifactStagingDirectory)/BlobArtifacts/ + -ExtractPath $(Agent.BuildDirectory)/Extract/ + -GHRepoName $(Build.Repository.Name) + -GHCommit $(Build.SourceVersion) + -SourcelinkCliVersion $(SourceLinkCLIVersion) + continueOnError: true - ${{ if ne(parameters.publishAssetsImmediately, 'true') }}: - stage: publish_using_darc @@ -274,10 +274,10 @@ stages: dependsOn: ${{ parameters.validateDependsOn }} displayName: Publish using Darc variables: - - template: /eng/common/core-templates/post-build/common-variables.yml - - template: /eng/common/core-templates/variables/pool-providers.yml - parameters: - is1ESPipeline: ${{ parameters.is1ESPipeline }} + - template: /eng/common/core-templates/post-build/common-variables.yml + - template: /eng/common/core-templates/variables/pool-providers.yml + parameters: + is1ESPipeline: ${{ parameters.is1ESPipeline }} jobs: - job: displayName: Publish Using Darc @@ -291,41 +291,42 @@ stages: os: windows # If it's not devdiv, it's dnceng ${{ else }}: - ${{ if eq(parameters.is1ESPipeline, true) }}: + ${{ if eq(parameters.is1ESPipeline, true) }}: name: NetCore1ESPool-Publishing-Internal - image: windows.vs2019.amd64 + image: windows.vs2022.amd64 os: windows ${{ else }}: name: NetCore1ESPool-Publishing-Internal - demands: ImageOverride -equals windows.vs2019.amd64 + demands: ImageOverride -equals windows.vs2022.amd64 steps: - - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml - parameters: - BARBuildId: ${{ parameters.BARBuildId }} - PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} - is1ESPipeline: ${{ parameters.is1ESPipeline }} - - - task: NuGetAuthenticate@1 - - # Populate internal runtime variables. - - template: /eng/common/templates/steps/enable-internal-sources.yml - parameters: - legacyCredential: $(dn-bot-dnceng-artifact-feeds-rw) - - - template: /eng/common/templates/steps/enable-internal-runtimes.yml - - - task: UseDotNet@2 - inputs: - version: 8.0.x - - - task: AzureCLI@2 - displayName: Publish Using Darc - inputs: - azureSubscription: "Darc: Maestro Production" - scriptType: ps - scriptLocation: scriptPath - scriptPath: $(System.DefaultWorkingDirectory)/eng/common/post-build/publish-using-darc.ps1 - arguments: > + - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml + parameters: + BARBuildId: ${{ parameters.BARBuildId }} + PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} + is1ESPipeline: ${{ parameters.is1ESPipeline }} + + - task: NuGetAuthenticate@1 + + # Populate internal runtime variables. + - template: /eng/common/templates/steps/enable-internal-sources.yml + parameters: + legacyCredential: $(dn-bot-dnceng-artifact-feeds-rw) + + - template: /eng/common/templates/steps/enable-internal-runtimes.yml + + # Darc is targeting 8.0, so make sure it's installed + - task: UseDotNet@2 + inputs: + version: 8.0.x + + - task: AzureCLI@2 + displayName: Publish Using Darc + inputs: + azureSubscription: "Darc: Maestro Production" + scriptType: ps + scriptLocation: scriptPath + scriptPath: $(System.DefaultWorkingDirectory)/eng/common/post-build/publish-using-darc.ps1 + arguments: > -BuildId $(BARBuildId) -PublishingInfraVersion ${{ parameters.publishingInfraVersion }} -AzdoToken '$(System.AccessToken)' diff --git a/eng/common/core-templates/steps/generate-sbom.yml b/eng/common/core-templates/steps/generate-sbom.yml index 003f7eae0fa..c05f6502797 100644 --- a/eng/common/core-templates/steps/generate-sbom.yml +++ b/eng/common/core-templates/steps/generate-sbom.yml @@ -5,7 +5,7 @@ # IgnoreDirectories - Directories to ignore for SBOM generation. This will be passed through to the CG component detector. parameters: - PackageVersion: 11.0.0 + PackageVersion: 10.0.0 BuildDropPath: '$(System.DefaultWorkingDirectory)/artifacts' PackageName: '.NET' ManifestDirPath: $(Build.ArtifactStagingDirectory)/sbom diff --git a/eng/common/core-templates/steps/install-microbuild-impl.yml b/eng/common/core-templates/steps/install-microbuild-impl.yml deleted file mode 100644 index b9e0143ee92..00000000000 --- a/eng/common/core-templates/steps/install-microbuild-impl.yml +++ /dev/null @@ -1,34 +0,0 @@ -parameters: - - name: microbuildTaskInputs - type: object - default: {} - - - name: microbuildEnv - type: object - default: {} - - - name: enablePreviewMicrobuild - type: boolean - default: false - - - name: condition - type: string - - - name: continueOnError - type: boolean - -steps: -- ${{ if eq(parameters.enablePreviewMicrobuild, 'true') }}: - - task: MicroBuildSigningPluginPreview@4 - displayName: Install Preview MicroBuild plugin - inputs: ${{ parameters.microbuildTaskInputs }} - env: ${{ parameters.microbuildEnv }} - continueOnError: ${{ parameters.continueOnError }} - condition: ${{ parameters.condition }} -- ${{ else }}: - - task: MicroBuildSigningPlugin@4 - displayName: Install MicroBuild plugin - inputs: ${{ parameters.microbuildTaskInputs }} - env: ${{ parameters.microbuildEnv }} - continueOnError: ${{ parameters.continueOnError }} - condition: ${{ parameters.condition }} diff --git a/eng/common/core-templates/steps/install-microbuild.yml b/eng/common/core-templates/steps/install-microbuild.yml index 4f4b56ed2a6..553fce66b94 100644 --- a/eng/common/core-templates/steps/install-microbuild.yml +++ b/eng/common/core-templates/steps/install-microbuild.yml @@ -4,8 +4,6 @@ parameters: # Enable install tasks for MicroBuild on Mac and Linux # Will be ignored if 'enableMicrobuild' is false or 'Agent.Os' is 'Windows_NT' enableMicrobuildForMacAndLinux: false - # Enable preview version of MB signing plugin - enablePreviewMicrobuild: false # Determines whether the ESRP service connection information should be passed to the signing plugin. # This overlaps with _SignType to some degree. We only need the service connection for real signing. # It's important that the service connection not be passed to the MicroBuildSigningPlugin task in this place. @@ -15,8 +13,6 @@ parameters: microbuildUseESRP: true # Microbuild installation directory microBuildOutputFolder: $(Agent.TempDirectory)/MicroBuild - # Microbuild version - microbuildPluginVersion: 'latest' continueOnError: false @@ -73,46 +69,42 @@ steps: # YAML expansion, and Windows vs. Linux/Mac uses different service connections. However, # we can avoid including the MB install step if not enabled at all. This avoids a bunch of # extra pipeline authorizations, since most pipelines do not sign on non-Windows. - - template: /eng/common/core-templates/steps/install-microbuild-impl.yml@self - parameters: - enablePreviewMicrobuild: ${{ parameters.enablePreviewMicrobuild }} - microbuildTaskInputs: + - task: MicroBuildSigningPlugin@4 + displayName: Install MicroBuild plugin (Windows) + inputs: + signType: $(_SignType) + zipSources: false + feedSource: https://dnceng.pkgs.visualstudio.com/_packaging/MicroBuildToolset/nuget/v3/index.json + ${{ if eq(parameters.microbuildUseESRP, true) }}: + ConnectedServiceName: 'MicroBuild Signing Task (DevDiv)' + ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: + ConnectedPMEServiceName: 6cc74545-d7b9-4050-9dfa-ebefcc8961ea + ${{ else }}: + ConnectedPMEServiceName: 248d384a-b39b-46e3-8ad5-c2c210d5e7ca + env: + TeamName: $(_TeamName) + MicroBuildOutputFolderOverride: ${{ parameters.microBuildOutputFolder }} + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + continueOnError: ${{ parameters.continueOnError }} + condition: and(succeeded(), eq(variables['Agent.Os'], 'Windows_NT'), in(variables['_SignType'], 'real', 'test')) + + - ${{ if eq(parameters.enableMicrobuildForMacAndLinux, true) }}: + - task: MicroBuildSigningPlugin@4 + displayName: Install MicroBuild plugin (non-Windows) + inputs: signType: $(_SignType) zipSources: false feedSource: https://dnceng.pkgs.visualstudio.com/_packaging/MicroBuildToolset/nuget/v3/index.json - version: ${{ parameters.microbuildPluginVersion }} + workingDirectory: ${{ parameters.microBuildOutputFolder }} ${{ if eq(parameters.microbuildUseESRP, true) }}: ConnectedServiceName: 'MicroBuild Signing Task (DevDiv)' ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: - ConnectedPMEServiceName: 6cc74545-d7b9-4050-9dfa-ebefcc8961ea + ConnectedPMEServiceName: beb8cb23-b303-4c95-ab26-9e44bc958d39 ${{ else }}: - ConnectedPMEServiceName: 248d384a-b39b-46e3-8ad5-c2c210d5e7ca - microbuildEnv: + ConnectedPMEServiceName: c24de2a5-cc7a-493d-95e4-8e5ff5cad2bc + env: TeamName: $(_TeamName) MicroBuildOutputFolderOverride: ${{ parameters.microBuildOutputFolder }} SYSTEM_ACCESSTOKEN: $(System.AccessToken) continueOnError: ${{ parameters.continueOnError }} - condition: and(succeeded(), eq(variables['Agent.Os'], 'Windows_NT'), in(variables['_SignType'], 'real', 'test')) - - - ${{ if eq(parameters.enableMicrobuildForMacAndLinux, true) }}: - - template: /eng/common/core-templates/steps/install-microbuild-impl.yml@self - parameters: - enablePreviewMicrobuild: ${{ parameters.enablePreviewMicrobuild }} - microbuildTaskInputs: - signType: $(_SignType) - zipSources: false - feedSource: https://dnceng.pkgs.visualstudio.com/_packaging/MicroBuildToolset/nuget/v3/index.json - version: ${{ parameters.microbuildPluginVersion }} - workingDirectory: ${{ parameters.microBuildOutputFolder }} - ${{ if eq(parameters.microbuildUseESRP, true) }}: - ConnectedServiceName: 'MicroBuild Signing Task (DevDiv)' - ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: - ConnectedPMEServiceName: beb8cb23-b303-4c95-ab26-9e44bc958d39 - ${{ else }}: - ConnectedPMEServiceName: c24de2a5-cc7a-493d-95e4-8e5ff5cad2bc - microbuildEnv: - TeamName: $(_TeamName) - MicroBuildOutputFolderOverride: ${{ parameters.microBuildOutputFolder }} - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - continueOnError: ${{ parameters.continueOnError }} - condition: and(succeeded(), ne(variables['Agent.Os'], 'Windows_NT'), eq(variables['_SignType'], 'real')) + condition: and(succeeded(), ne(variables['Agent.Os'], 'Windows_NT'), eq(variables['_SignType'], 'real')) diff --git a/eng/common/core-templates/steps/source-build.yml b/eng/common/core-templates/steps/source-build.yml index acf16ed3496..b9c86c18ae4 100644 --- a/eng/common/core-templates/steps/source-build.yml +++ b/eng/common/core-templates/steps/source-build.yml @@ -24,7 +24,7 @@ steps: # in the default public locations. internalRuntimeDownloadArgs= if [ '$(dotnetbuilds-internal-container-read-token-base64)' != '$''(dotnetbuilds-internal-container-read-token-base64)' ]; then - internalRuntimeDownloadArgs='/p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) --runtimesourcefeed https://ci.dot.net/internal --runtimesourcefeedkey $(dotnetbuilds-internal-container-read-token-base64)' + internalRuntimeDownloadArgs='/p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) --runtimesourcefeed https://ci.dot.net/internal --runtimesourcefeedkey '$(dotnetbuilds-internal-container-read-token-base64)'' fi buildConfig=Release diff --git a/eng/common/core-templates/steps/source-index-stage1-publish.yml b/eng/common/core-templates/steps/source-index-stage1-publish.yml index ac019e2d033..e9a694afa58 100644 --- a/eng/common/core-templates/steps/source-index-stage1-publish.yml +++ b/eng/common/core-templates/steps/source-index-stage1-publish.yml @@ -1,6 +1,6 @@ parameters: - sourceIndexUploadPackageVersion: 2.0.0-20250906.1 - sourceIndexProcessBinlogPackageVersion: 1.0.1-20250906.1 + sourceIndexUploadPackageVersion: 2.0.0-20250818.1 + sourceIndexProcessBinlogPackageVersion: 1.0.1-20250818.1 sourceIndexPackageSource: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json binlogPath: artifacts/log/Debug/Build.binlog @@ -14,8 +14,8 @@ steps: workingDirectory: $(Agent.TempDirectory) - script: | - $(Agent.TempDirectory)/dotnet/dotnet tool install BinLogToSln --version ${{parameters.sourceIndexProcessBinlogPackageVersion}} --source ${{parameters.SourceIndexPackageSource}} --tool-path $(Agent.TempDirectory)/.source-index/tools - $(Agent.TempDirectory)/dotnet/dotnet tool install UploadIndexStage1 --version ${{parameters.sourceIndexUploadPackageVersion}} --source ${{parameters.SourceIndexPackageSource}} --tool-path $(Agent.TempDirectory)/.source-index/tools + $(Agent.TempDirectory)/dotnet/dotnet tool install BinLogToSln --version ${{parameters.sourceIndexProcessBinlogPackageVersion}} --add-source ${{parameters.SourceIndexPackageSource}} --tool-path $(Agent.TempDirectory)/.source-index/tools + $(Agent.TempDirectory)/dotnet/dotnet tool install UploadIndexStage1 --version ${{parameters.sourceIndexUploadPackageVersion}} --add-source ${{parameters.SourceIndexPackageSource}} --tool-path $(Agent.TempDirectory)/.source-index/tools displayName: "Source Index: Download netsourceindex Tools" # Set working directory to temp directory so 'dotnet' doesn't try to use global.json and use the repo's sdk. workingDirectory: $(Agent.TempDirectory) diff --git a/eng/common/darc-init.sh b/eng/common/darc-init.sh index 9f5ad6b763b..e889f439b8d 100755 --- a/eng/common/darc-init.sh +++ b/eng/common/darc-init.sh @@ -5,7 +5,7 @@ darcVersion='' versionEndpoint='https://maestro.dot.net/api/assets/darc-version?api-version=2020-02-20' verbosity='minimal' -while [[ $# -gt 0 ]]; do +while [[ $# > 0 ]]; do opt="$(echo "$1" | tr "[:upper:]" "[:lower:]")" case "$opt" in --darcversion) diff --git a/eng/common/dotnet-install.sh b/eng/common/dotnet-install.sh index 61f302bb677..7b9d97e3bd4 100755 --- a/eng/common/dotnet-install.sh +++ b/eng/common/dotnet-install.sh @@ -18,7 +18,7 @@ architecture='' runtime='dotnet' runtimeSourceFeed='' runtimeSourceFeedKey='' -while [[ $# -gt 0 ]]; do +while [[ $# > 0 ]]; do opt="$(echo "$1" | tr "[:upper:]" "[:lower:]")" case "$opt" in -version|-v) diff --git a/eng/common/dotnet.sh b/eng/common/dotnet.sh index f6d24871c1d..2ef68235675 100644 --- a/eng/common/dotnet.sh +++ b/eng/common/dotnet.sh @@ -19,7 +19,7 @@ source $scriptroot/tools.sh InitializeDotNetCli true # install # Invoke acquired SDK with args if they are provided -if [[ $# -gt 0 ]]; then +if [[ $# > 0 ]]; then __dotnetDir=${_InitializeDotNetCli} dotnetPath=${__dotnetDir}/dotnet ${dotnetPath} "$@" diff --git a/eng/common/internal-feed-operations.sh b/eng/common/internal-feed-operations.sh index 6299e7effd4..9378223ba09 100755 --- a/eng/common/internal-feed-operations.sh +++ b/eng/common/internal-feed-operations.sh @@ -100,7 +100,7 @@ operation='' authToken='' repoName='' -while [[ $# -gt 0 ]]; do +while [[ $# > 0 ]]; do opt="$(echo "$1" | tr "[:upper:]" "[:lower:]")" case "$opt" in --operation) diff --git a/eng/common/native/install-dependencies.sh b/eng/common/native/install-dependencies.sh index 64b87d0bcc3..477a44f335b 100644 --- a/eng/common/native/install-dependencies.sh +++ b/eng/common/native/install-dependencies.sh @@ -27,11 +27,9 @@ case "$os" in libssl-dev libkrb5-dev pigz cpio localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 - elif [ "$ID" = "fedora" ] || [ "$ID" = "rhel" ] || [ "$ID" = "azurelinux" ] || [ "$ID" = "centos"]; then + elif [ "$ID" = "fedora" ] || [ "$ID" = "rhel" ] || [ "$ID" = "azurelinux" ]; then pkg_mgr="$(command -v tdnf 2>/dev/null || command -v dnf)" $pkg_mgr install -y cmake llvm lld lldb clang python curl libicu-devel openssl-devel krb5-devel lttng-ust-devel pigz cpio - elif [ "$ID" = "amzn" ]; then - dnf install -y cmake llvm lld lldb clang python libicu-devel openssl-devel krb5-devel lttng-ust-devel pigz cpio elif [ "$ID" = "alpine" ]; then apk add build-base cmake bash curl clang llvm-dev lld lldb krb5-dev lttng-ust-dev icu-dev openssl-dev pigz cpio else diff --git a/eng/common/post-build/redact-logs.ps1 b/eng/common/post-build/redact-logs.ps1 index fc0218a013d..472d5bb562c 100644 --- a/eng/common/post-build/redact-logs.ps1 +++ b/eng/common/post-build/redact-logs.ps1 @@ -9,8 +9,7 @@ param( [Parameter(Mandatory=$false)][string] $TokensFilePath, [Parameter(ValueFromRemainingArguments=$true)][String[]]$TokensToRedact, [Parameter(Mandatory=$false)][string] $runtimeSourceFeed, - [Parameter(Mandatory=$false)][string] $runtimeSourceFeedKey -) + [Parameter(Mandatory=$false)][string] $runtimeSourceFeedKey) try { $ErrorActionPreference = 'Stop' diff --git a/eng/common/templates/variables/pool-providers.yml b/eng/common/templates/variables/pool-providers.yml index e0b19c14a07..18693ea120d 100644 --- a/eng/common/templates/variables/pool-providers.yml +++ b/eng/common/templates/variables/pool-providers.yml @@ -23,7 +23,7 @@ # # pool: # name: $(DncEngInternalBuildPool) -# demands: ImageOverride -equals windows.vs2019.amd64 +# demands: ImageOverride -equals windows.vs2022.amd64 variables: - ${{ if eq(variables['System.TeamProject'], 'internal') }}: - template: /eng/common/templates-official/variables/pool-providers.yml diff --git a/eng/common/tools.ps1 b/eng/common/tools.ps1 index e8e9f7615f1..049fe6db994 100644 --- a/eng/common/tools.ps1 +++ b/eng/common/tools.ps1 @@ -157,6 +157,9 @@ function InitializeDotNetCli([bool]$install, [bool]$createSdkLocationFile) { return $global:_DotNetInstallDir } + # Don't resolve runtime, shared framework, or SDK from other locations to ensure build determinism + $env:DOTNET_MULTILEVEL_LOOKUP=0 + # Disable first run since we do not need all ASP.NET packages restored. $env:DOTNET_NOLOGO=1 @@ -222,6 +225,7 @@ function InitializeDotNetCli([bool]$install, [bool]$createSdkLocationFile) { # Make Sure that our bootstrapped dotnet cli is available in future steps of the Azure Pipelines build Write-PipelinePrependPath -Path $dotnetRoot + Write-PipelineSetVariable -Name 'DOTNET_MULTILEVEL_LOOKUP' -Value '0' Write-PipelineSetVariable -Name 'DOTNET_NOLOGO' -Value '1' return $global:_DotNetInstallDir = $dotnetRoot @@ -556,19 +560,26 @@ function LocateVisualStudio([object]$vsRequirements = $null){ }) } - if (!$vsRequirements) { $vsRequirements = $GlobalJson.tools.vs } + if (!$vsRequirements) { + if (Get-Member -InputObject $GlobalJson.tools -Name 'vs' -ErrorAction SilentlyContinue) { + $vsRequirements = $GlobalJson.tools.vs + } else { + $vsRequirements = $null + } + } + $args = @('-latest', '-format', 'json', '-requires', 'Microsoft.Component.MSBuild', '-products', '*') if (!$excludePrereleaseVS) { $args += '-prerelease' } - if (Get-Member -InputObject $vsRequirements -Name 'version') { + if ($vsRequirements -and (Get-Member -InputObject $vsRequirements -Name 'version' -ErrorAction SilentlyContinue)) { $args += '-version' $args += $vsRequirements.version } - if (Get-Member -InputObject $vsRequirements -Name 'components') { + if ($vsRequirements -and (Get-Member -InputObject $vsRequirements -Name 'components' -ErrorAction SilentlyContinue)) { foreach ($component in $vsRequirements.components) { $args += '-requires' $args += $component diff --git a/eng/common/tools.sh b/eng/common/tools.sh index 6c121300ac7..c1841c9dfd0 100755 --- a/eng/common/tools.sh +++ b/eng/common/tools.sh @@ -115,6 +115,9 @@ function InitializeDotNetCli { local install=$1 + # Don't resolve runtime, shared framework, or SDK from other locations to ensure build determinism + export DOTNET_MULTILEVEL_LOOKUP=0 + # Disable first run since we want to control all package sources export DOTNET_NOLOGO=1 @@ -163,6 +166,7 @@ function InitializeDotNetCli { # build steps from using anything other than what we've downloaded. Write-PipelinePrependPath -path "$dotnet_root" + Write-PipelineSetVariable -name "DOTNET_MULTILEVEL_LOOKUP" -value "0" Write-PipelineSetVariable -name "DOTNET_NOLOGO" -value "1" # return value diff --git a/eng/restore-toolset.sh b/eng/restore-toolset.sh index 8a7bb526c06..cdcf18f1d19 100644 --- a/eng/restore-toolset.sh +++ b/eng/restore-toolset.sh @@ -3,7 +3,7 @@ # Install MAUI workload if -restoreMaui was passed # Only on macOS (MAUI doesn't support Linux, Windows uses .cmd) -if [[ "$restore_maui" == true ]]; then +if [[ "${restore_maui:-false}" == true ]]; then # Check if we're on macOS if [[ "$(uname -s)" == "Darwin" ]]; then echo "" diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 73088ed32fb..bbe98c2cc15 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -10,6 +10,9 @@ Aspire CLI Version: {0}. + + Aspire CLI found at {0}. The extension will use this path. + Aspire CLI is not available on PATH. Please install it and restart VS Code. diff --git a/extension/package.nls.json b/extension/package.nls.json index 75e0719f912..03c1794715e 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -93,6 +93,7 @@ "aspire-vscode.strings.lookingForDevkitBuildTask": "C# Dev Kit is installed, looking for C# Dev Kit build task...", "aspire-vscode.strings.csharpDevKitNotInstalled": "C# Dev Kit is not installed, building using dotnet CLI...", "aspire-vscode.strings.cliNotAvailable": "Aspire CLI is not available on PATH. Please install it and restart VS Code.", + "aspire-vscode.strings.cliFoundAtDefaultPath": "Aspire CLI found at {0}. The extension will use this path.", "aspire-vscode.strings.openCliInstallInstructions": "See CLI installation instructions", "aspire-vscode.strings.dismissLabel": "Dismiss" } diff --git a/extension/src/commands/add.ts b/extension/src/commands/add.ts index 5d8bd3307a7..e1e158d7b4b 100644 --- a/extension/src/commands/add.ts +++ b/extension/src/commands/add.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function addCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('add'); + await terminalProvider.sendAspireCommandToAspireTerminal('add'); } diff --git a/extension/src/commands/deploy.ts b/extension/src/commands/deploy.ts index a40590e1891..057d419f6ca 100644 --- a/extension/src/commands/deploy.ts +++ b/extension/src/commands/deploy.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function deployCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('deploy'); + await terminalProvider.sendAspireCommandToAspireTerminal('deploy'); } diff --git a/extension/src/commands/init.ts b/extension/src/commands/init.ts index 642bfa23aa3..3d6c60e25d9 100644 --- a/extension/src/commands/init.ts +++ b/extension/src/commands/init.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from "../utils/AspireTerminalProvider"; export async function initCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('init'); + await terminalProvider.sendAspireCommandToAspireTerminal('init'); }; \ No newline at end of file diff --git a/extension/src/commands/new.ts b/extension/src/commands/new.ts index d8a26eab433..ab2936e0af3 100644 --- a/extension/src/commands/new.ts +++ b/extension/src/commands/new.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from "../utils/AspireTerminalProvider"; export async function newCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('new'); + await terminalProvider.sendAspireCommandToAspireTerminal('new'); }; diff --git a/extension/src/commands/publish.ts b/extension/src/commands/publish.ts index 181d590337a..276ea03a7a8 100644 --- a/extension/src/commands/publish.ts +++ b/extension/src/commands/publish.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function publishCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('publish'); + await terminalProvider.sendAspireCommandToAspireTerminal('publish'); } diff --git a/extension/src/commands/update.ts b/extension/src/commands/update.ts index 31ab5b9f89e..23e8070920e 100644 --- a/extension/src/commands/update.ts +++ b/extension/src/commands/update.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function updateCommand(terminalProvider: AspireTerminalProvider) { - terminalProvider.sendAspireCommandToAspireTerminal('update'); + await terminalProvider.sendAspireCommandToAspireTerminal('update'); } diff --git a/extension/src/debugger/AspireDebugConfigurationProvider.ts b/extension/src/debugger/AspireDebugConfigurationProvider.ts index ba4c8d98c14..643db6ed958 100644 --- a/extension/src/debugger/AspireDebugConfigurationProvider.ts +++ b/extension/src/debugger/AspireDebugConfigurationProvider.ts @@ -1,15 +1,8 @@ import * as vscode from 'vscode'; import { defaultConfigurationName } from '../loc/strings'; -import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { checkCliAvailableOrRedirect } from '../utils/workspace'; export class AspireDebugConfigurationProvider implements vscode.DebugConfigurationProvider { - private _terminalProvider: AspireTerminalProvider; - - constructor(terminalProvider: AspireTerminalProvider) { - this._terminalProvider = terminalProvider; - } - async provideDebugConfigurations(folder: vscode.WorkspaceFolder | undefined, token?: vscode.CancellationToken): Promise { if (folder === undefined) { return []; @@ -28,9 +21,8 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise { // Check if CLI is available before starting debug session - const cliPath = this._terminalProvider.getAspireCliExecutablePath(); - const isCliAvailable = await checkCliAvailableOrRedirect(cliPath); - if (!isCliAvailable) { + const result = await checkCliAvailableOrRedirect(); + if (!result.available) { return undefined; // Cancel the debug session } diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index bc35aceeb6c..293beade0d7 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -93,14 +93,14 @@ export class AspireDebugSession implements vscode.DebugAdapter { if (isDirectory(appHostPath)) { this.sendMessageWithEmoji("📁", launchingWithDirectory(appHostPath)); - this.spawnRunCommand(args, appHostPath, noDebug); + void this.spawnRunCommand(args, appHostPath, noDebug); } else { this.sendMessageWithEmoji("📂", launchingWithAppHost(appHostPath)); const workspaceFolder = path.dirname(appHostPath); args.push('--project', appHostPath); - this.spawnRunCommand(args, workspaceFolder, noDebug); + void this.spawnRunCommand(args, workspaceFolder, noDebug); } } else if (message.command === 'disconnect' || message.command === 'terminate') { @@ -133,7 +133,7 @@ export class AspireDebugSession implements vscode.DebugAdapter { } } - spawnRunCommand(args: string[], workingDirectory: string | undefined, noDebug: boolean) { + async spawnRunCommand(args: string[], workingDirectory: string | undefined, noDebug: boolean) { const disposable = this._rpcServer.onNewConnection((client: ICliRpcClient) => { if (client.debugSessionId === this.debugSessionId) { this._rpcClient = client; @@ -143,7 +143,7 @@ export class AspireDebugSession implements vscode.DebugAdapter { spawnCliProcess( this._terminalProvider, - this._terminalProvider.getAspireCliExecutablePath(), + await this._terminalProvider.getAspireCliExecutablePath(), args, { stdoutCallback: (data) => { diff --git a/extension/src/extension.ts b/extension/src/extension.ts index f2e2c44f8eb..de001575696 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -67,7 +67,7 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(cliAddCommandRegistration, cliNewCommandRegistration, cliInitCommandRegistration, cliDeployCommandRegistration, cliPublishCommandRegistration, openTerminalCommandRegistration, configureLaunchJsonCommandRegistration); context.subscriptions.push(cliUpdateCommandRegistration, settingsCommandRegistration, openLocalSettingsCommandRegistration, openGlobalSettingsCommandRegistration, runAppHostCommandRegistration, debugAppHostCommandRegistration); - const debugConfigProvider = new AspireDebugConfigurationProvider(terminalProvider); + const debugConfigProvider = new AspireDebugConfigurationProvider(); context.subscriptions.push( vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Dynamic) ); @@ -114,9 +114,8 @@ async function tryExecuteCommand(commandName: string, terminalProvider: AspireTe const cliCheckExcludedCommands: string[] = ["aspire-vscode.settings", "aspire-vscode.configureLaunchJson"]; if (!cliCheckExcludedCommands.includes(commandName)) { - const cliPath = terminalProvider.getAspireCliExecutablePath(); - const isCliAvailable = await checkCliAvailableOrRedirect(cliPath); - if (!isCliAvailable) { + const result = await checkCliAvailableOrRedirect(); + if (!result.available) { return; } } diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 484ca92ec30..1b02e953ff7 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -71,3 +71,4 @@ export const csharpDevKitNotInstalled = vscode.l10n.t('C# Dev Kit is not install export const dismissLabel = vscode.l10n.t('Dismiss'); export const openCliInstallInstructions = vscode.l10n.t('See CLI installation instructions'); export const cliNotAvailable = vscode.l10n.t('Aspire CLI is not available on PATH. Please install it and restart VS Code.'); +export const cliFoundAtDefaultPath = (path: string) => vscode.l10n.t('Aspire CLI found at {0}. The extension will use this path.', path); diff --git a/extension/src/test/aspireTerminalProvider.test.ts b/extension/src/test/aspireTerminalProvider.test.ts index dc70ca4c3fb..fa139b51715 100644 --- a/extension/src/test/aspireTerminalProvider.test.ts +++ b/extension/src/test/aspireTerminalProvider.test.ts @@ -2,94 +2,58 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import * as sinon from 'sinon'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; +import * as cliPathModule from '../utils/cliPath'; suite('AspireTerminalProvider tests', () => { let terminalProvider: AspireTerminalProvider; - let configStub: sinon.SinonStub; + let resolveCliPathStub: sinon.SinonStub; let subscriptions: vscode.Disposable[]; setup(() => { subscriptions = []; terminalProvider = new AspireTerminalProvider(subscriptions); - configStub = sinon.stub(vscode.workspace, 'getConfiguration'); + resolveCliPathStub = sinon.stub(cliPathModule, 'resolveCliPath'); }); teardown(() => { - configStub.restore(); + resolveCliPathStub.restore(); subscriptions.forEach(s => s.dispose()); }); suite('getAspireCliExecutablePath', () => { - test('returns "aspire" when no custom path is configured', () => { - configStub.returns({ - get: sinon.stub().returns('') - }); + test('returns "aspire" when CLI is on PATH', async () => { + resolveCliPathStub.resolves({ cliPath: 'aspire', available: true, source: 'path' }); - const result = terminalProvider.getAspireCliExecutablePath(); + const result = await terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, 'aspire'); }); - test('returns custom path when configured', () => { - configStub.returns({ - get: sinon.stub().returns('/usr/local/bin/aspire') - }); + test('returns resolved path when CLI found at default install location', async () => { + resolveCliPathStub.resolves({ cliPath: '/home/user/.aspire/bin/aspire', available: true, source: 'default-install' }); - const result = terminalProvider.getAspireCliExecutablePath(); - assert.strictEqual(result, '/usr/local/bin/aspire'); + const result = await terminalProvider.getAspireCliExecutablePath(); + assert.strictEqual(result, '/home/user/.aspire/bin/aspire'); }); - test('returns custom path with spaces', () => { - configStub.returns({ - get: sinon.stub().returns('/my path/with spaces/aspire') - }); - - const result = terminalProvider.getAspireCliExecutablePath(); - assert.strictEqual(result, '/my path/with spaces/aspire'); - }); + test('returns configured custom path', async () => { + resolveCliPathStub.resolves({ cliPath: '/usr/local/bin/aspire', available: true, source: 'configured' }); - test('trims whitespace from configured path', () => { - configStub.returns({ - get: sinon.stub().returns(' /usr/local/bin/aspire ') - }); - - const result = terminalProvider.getAspireCliExecutablePath(); + const result = await terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, '/usr/local/bin/aspire'); }); - test('returns "aspire" when configured path is only whitespace', () => { - configStub.returns({ - get: sinon.stub().returns(' ') - }); + test('returns "aspire" when CLI is not found', async () => { + resolveCliPathStub.resolves({ cliPath: 'aspire', available: false, source: 'not-found' }); - const result = terminalProvider.getAspireCliExecutablePath(); + const result = await terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, 'aspire'); }); - test('handles Windows-style paths', () => { - configStub.returns({ - get: sinon.stub().returns('C:\\Program Files\\Aspire\\aspire.exe') - }); + test('handles Windows-style paths', async () => { + resolveCliPathStub.resolves({ cliPath: 'C:\\Program Files\\Aspire\\aspire.exe', available: true, source: 'configured' }); - const result = terminalProvider.getAspireCliExecutablePath(); + const result = await terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, 'C:\\Program Files\\Aspire\\aspire.exe'); }); - - test('handles Windows-style paths without spaces', () => { - configStub.returns({ - get: sinon.stub().returns('C:\\aspire\\aspire.exe') - }); - - const result = terminalProvider.getAspireCliExecutablePath(); - assert.strictEqual(result, 'C:\\aspire\\aspire.exe'); - }); - - test('handles paths with special characters', () => { - configStub.returns({ - get: sinon.stub().returns('/path/with$dollar/aspire') - }); - - const result = terminalProvider.getAspireCliExecutablePath(); - assert.strictEqual(result, '/path/with$dollar/aspire'); - }); }); }); diff --git a/extension/src/test/cliPath.test.ts b/extension/src/test/cliPath.test.ts new file mode 100644 index 00000000000..e70519b3ebe --- /dev/null +++ b/extension/src/test/cliPath.test.ts @@ -0,0 +1,211 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as os from 'os'; +import * as path from 'path'; +import { getDefaultCliInstallPaths, resolveCliPath, CliPathDependencies } from '../utils/cliPath'; + +const bundlePath = '/home/user/.aspire/bin/aspire'; +const globalToolPath = '/home/user/.dotnet/tools/aspire'; +const defaultPaths = [bundlePath, globalToolPath]; + +function createMockDeps(overrides: Partial = {}): CliPathDependencies { + return { + getConfiguredPath: () => '', + getDefaultPaths: () => defaultPaths, + isOnPath: async () => false, + findAtDefaultPath: async () => undefined, + tryExecute: async () => false, + setConfiguredPath: async () => {}, + ...overrides, + }; +} + +suite('utils/cliPath tests', () => { + + suite('getDefaultCliInstallPaths', () => { + test('returns bundle path (~/.aspire/bin) as first entry', () => { + const paths = getDefaultCliInstallPaths(); + const homeDir = os.homedir(); + + assert.ok(paths.length >= 2, 'Should return at least 2 default paths'); + assert.ok(paths[0].startsWith(path.join(homeDir, '.aspire', 'bin')), `First path should be bundle install: ${paths[0]}`); + }); + + test('returns global tool path (~/.dotnet/tools) as second entry', () => { + const paths = getDefaultCliInstallPaths(); + const homeDir = os.homedir(); + + assert.ok(paths[1].startsWith(path.join(homeDir, '.dotnet', 'tools')), `Second path should be global tool: ${paths[1]}`); + }); + + test('uses correct executable name for current platform', () => { + const paths = getDefaultCliInstallPaths(); + + for (const p of paths) { + const basename = path.basename(p); + if (process.platform === 'win32') { + assert.strictEqual(basename, 'aspire.exe'); + } else { + assert.strictEqual(basename, 'aspire'); + } + } + }); + }); + + suite('resolveCliPath', () => { + test('falls back to default install path when CLI is not on PATH', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + isOnPath: async () => false, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.available, true); + assert.strictEqual(result.source, 'default-install'); + assert.strictEqual(result.cliPath, bundlePath); + assert.ok(setConfiguredPath.calledOnceWith(bundlePath), 'should update the VS Code setting to the found path'); + }); + + test('updates VS Code setting when CLI found at default path but not on PATH', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + getConfiguredPath: () => '', + isOnPath: async () => false, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + }); + + await resolveCliPath(deps); + + assert.ok(setConfiguredPath.calledOnce, 'setConfiguredPath should be called once'); + assert.strictEqual(setConfiguredPath.firstCall.args[0], bundlePath, 'should set the path to the found install location'); + }); + + test('prefers PATH over default install path', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + isOnPath: async () => true, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.available, true); + assert.strictEqual(result.source, 'path'); + assert.strictEqual(result.cliPath, 'aspire'); + assert.ok(setConfiguredPath.notCalled, 'should not update settings when CLI is on PATH'); + }); + + test('clears setting when CLI is on PATH and setting was previously set to a default path', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + getConfiguredPath: () => bundlePath, + isOnPath: async () => true, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.source, 'path'); + assert.ok(setConfiguredPath.calledOnceWith(''), 'should clear the setting'); + }); + + test('clears setting when CLI is on PATH and setting was previously set to global tool path', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + getConfiguredPath: () => globalToolPath, + isOnPath: async () => true, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.source, 'path'); + assert.ok(setConfiguredPath.calledOnceWith(''), 'should clear the setting'); + }); + + test('returns not-found when CLI is not on PATH and not at any default path', async () => { + const deps = createMockDeps({ + isOnPath: async () => false, + findAtDefaultPath: async () => undefined, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.available, false); + assert.strictEqual(result.source, 'not-found'); + }); + + test('uses custom configured path when valid and not a default', async () => { + const customPath = '/custom/path/aspire'; + + const deps = createMockDeps({ + getConfiguredPath: () => customPath, + tryExecute: async (p) => p === customPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.available, true); + assert.strictEqual(result.source, 'configured'); + assert.strictEqual(result.cliPath, customPath); + }); + + test('falls through to PATH check when custom configured path is invalid', async () => { + const deps = createMockDeps({ + getConfiguredPath: () => '/bad/path/aspire', + tryExecute: async () => false, + isOnPath: async () => true, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.source, 'path'); + assert.strictEqual(result.available, true); + }); + + test('falls through to default path when custom configured path is invalid and not on PATH', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + getConfiguredPath: () => '/bad/path/aspire', + tryExecute: async () => false, + isOnPath: async () => false, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.source, 'default-install'); + assert.strictEqual(result.cliPath, bundlePath); + assert.ok(setConfiguredPath.calledOnceWith(bundlePath)); + }); + + test('does not update setting when already set to the found default path', async () => { + const setConfiguredPath = sinon.stub().resolves(); + + const deps = createMockDeps({ + getConfiguredPath: () => bundlePath, + isOnPath: async () => false, + findAtDefaultPath: async () => bundlePath, + setConfiguredPath, + }); + + const result = await resolveCliPath(deps); + + assert.strictEqual(result.source, 'default-install'); + assert.ok(setConfiguredPath.notCalled, 'should not re-set the path if it already matches'); + }); + }); +}); + diff --git a/extension/src/utils/AspireTerminalProvider.ts b/extension/src/utils/AspireTerminalProvider.ts index 35762287729..95ed6bf5426 100644 --- a/extension/src/utils/AspireTerminalProvider.ts +++ b/extension/src/utils/AspireTerminalProvider.ts @@ -5,6 +5,7 @@ import { RpcServerConnectionInfo } from '../server/AspireRpcServer'; import { DcpServerConnectionInfo } from '../dcp/types'; import { getRunSessionInfo, getSupportedCapabilities } from '../capabilities'; import { EnvironmentVariables } from './environment'; +import { resolveCliPath } from './cliPath'; import path from 'path'; export const enum AnsiColors { @@ -57,8 +58,8 @@ export class AspireTerminalProvider implements vscode.Disposable { this._dcpServerConnectionInfo = value; } - sendAspireCommandToAspireTerminal(subcommand: string, showTerminal: boolean = true) { - const cliPath = this.getAspireCliExecutablePath(); + async sendAspireCommandToAspireTerminal(subcommand: string, showTerminal: boolean = true) { + const cliPath = await this.getAspireCliExecutablePath(); // On Windows, use & to execute paths, especially those with special characters // On Unix, just use the path directly @@ -200,15 +201,9 @@ export class AspireTerminalProvider implements vscode.Disposable { } - getAspireCliExecutablePath(): string { - const aspireCliPath = vscode.workspace.getConfiguration('aspire').get('aspireCliExecutablePath', ''); - if (aspireCliPath && aspireCliPath.trim().length > 0) { - extensionLogOutputChannel.debug(`Using user-configured Aspire CLI path: ${aspireCliPath}`); - return aspireCliPath.trim(); - } - - extensionLogOutputChannel.debug('No user-configured Aspire CLI path found'); - return "aspire"; + async getAspireCliExecutablePath(): Promise { + const result = await resolveCliPath(); + return result.cliPath; } isCliDebugLoggingEnabled(): boolean { diff --git a/extension/src/utils/cliPath.ts b/extension/src/utils/cliPath.ts new file mode 100644 index 00000000000..6290ac6d945 --- /dev/null +++ b/extension/src/utils/cliPath.ts @@ -0,0 +1,194 @@ +import * as vscode from 'vscode'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { extensionLogOutputChannel } from './logging'; + +const execFileAsync = promisify(execFile); +const fsAccessAsync = promisify(fs.access); + +/** + * Gets the default installation paths for the Aspire CLI, in priority order. + * + * The CLI can be installed in two ways: + * 1. Bundle install (recommended): ~/.aspire/bin/aspire + * 2. .NET global tool: ~/.dotnet/tools/aspire + * + * @returns An array of default CLI paths to check, ordered by priority + */ +export function getDefaultCliInstallPaths(): string[] { + const homeDir = os.homedir(); + const exeName = process.platform === 'win32' ? 'aspire.exe' : 'aspire'; + + return [ + // Bundle install (recommended): ~/.aspire/bin/aspire + path.join(homeDir, '.aspire', 'bin', exeName), + // .NET global tool: ~/.dotnet/tools/aspire + path.join(homeDir, '.dotnet', 'tools', exeName), + ]; +} + +/** + * Checks if a file exists and is accessible. + */ +async function fileExists(filePath: string): Promise { + try { + await fsAccessAsync(filePath, fs.constants.F_OK); + return true; + } + catch { + return false; + } +} + +/** + * Tries to execute the CLI at the given path to verify it works. + */ +async function tryExecuteCli(cliPath: string): Promise { + try { + await execFileAsync(cliPath, ['--version'], { timeout: 5000 }); + return true; + } + catch { + return false; + } +} + +/** + * Checks if the Aspire CLI is available on the system PATH. + */ +export async function isCliOnPath(): Promise { + return await tryExecuteCli('aspire'); +} + +/** + * Finds the first default installation path where the Aspire CLI exists and is executable. + * + * @returns The path where CLI was found, or undefined if not found at any default location + */ +export async function findCliAtDefaultPath(): Promise { + for (const defaultPath of getDefaultCliInstallPaths()) { + if (await fileExists(defaultPath) && await tryExecuteCli(defaultPath)) { + return defaultPath; + } + } + + return undefined; +} + +/** + * Gets the VS Code configuration setting for the Aspire CLI path. + */ +export function getConfiguredCliPath(): string { + return vscode.workspace.getConfiguration('aspire').get('aspireCliExecutablePath', '').trim(); +} + +/** + * Updates the VS Code configuration setting for the Aspire CLI path. + * Uses ConfigurationTarget.Global to set it at the user level. + */ +export async function setConfiguredCliPath(cliPath: string): Promise { + extensionLogOutputChannel.info(`Setting aspire.aspireCliExecutablePath to: ${cliPath || '(empty)'}`); + await vscode.workspace.getConfiguration('aspire').update( + 'aspireCliExecutablePath', + cliPath || undefined, // Use undefined to remove the setting + vscode.ConfigurationTarget.Global + ); +} + +/** + * Result of checking CLI availability. + */ +export interface CliPathResolutionResult { + /** The resolved CLI path to use */ + cliPath: string; + /** Whether the CLI is available */ + available: boolean; + /** Where the CLI was found */ + source: 'path' | 'default-install' | 'configured' | 'not-found'; +} + +/** + * Dependencies for resolveCliPath that can be overridden for testing. + */ +export interface CliPathDependencies { + getConfiguredPath: () => string; + getDefaultPaths: () => string[]; + isOnPath: () => Promise; + findAtDefaultPath: () => Promise; + tryExecute: (cliPath: string) => Promise; + setConfiguredPath: (cliPath: string) => Promise; +} + +const defaultDependencies: CliPathDependencies = { + getConfiguredPath: getConfiguredCliPath, + getDefaultPaths: getDefaultCliInstallPaths, + isOnPath: isCliOnPath, + findAtDefaultPath: findCliAtDefaultPath, + tryExecute: tryExecuteCli, + setConfiguredPath: setConfiguredCliPath, +}; + +/** + * Resolves the Aspire CLI path, checking multiple locations in order: + * 1. User-configured path in VS Code settings + * 2. System PATH + * 3. Default installation directories (~/.aspire/bin, ~/.dotnet/tools) + * + * If the CLI is found at a default installation path but not on PATH, + * the VS Code setting is updated to use that path. + * + * If the CLI is on PATH and a setting was previously auto-configured to a default path, + * the setting is cleared to prefer PATH. + */ +export async function resolveCliPath(deps: CliPathDependencies = defaultDependencies): Promise { + const configuredPath = deps.getConfiguredPath(); + const defaultPaths = deps.getDefaultPaths(); + + // 1. Check if user has configured a custom path (not one of the defaults) + if (configuredPath && !defaultPaths.includes(configuredPath)) { + const isValid = await deps.tryExecute(configuredPath); + if (isValid) { + extensionLogOutputChannel.info(`Using user-configured Aspire CLI path: ${configuredPath}`); + return { cliPath: configuredPath, available: true, source: 'configured' }; + } + + extensionLogOutputChannel.warn(`Configured CLI path is invalid: ${configuredPath}`); + // Continue to check other locations + } + + // 2. Check if CLI is on PATH + const onPath = await deps.isOnPath(); + if (onPath) { + extensionLogOutputChannel.info('Aspire CLI found on system PATH'); + + // If we previously auto-set the path to a default install location, clear it + // since PATH is now working + if (defaultPaths.includes(configuredPath)) { + extensionLogOutputChannel.info('Clearing aspireCliExecutablePath setting since CLI is on PATH'); + await deps.setConfiguredPath(''); + } + + return { cliPath: 'aspire', available: true, source: 'path' }; + } + + // 3. Check default installation paths (~/.aspire/bin first, then ~/.dotnet/tools) + const foundPath = await deps.findAtDefaultPath(); + if (foundPath) { + extensionLogOutputChannel.info(`Aspire CLI found at default install location: ${foundPath}`); + + // Update the setting so future invocations use this path + if (configuredPath !== foundPath) { + extensionLogOutputChannel.info('Updating aspireCliExecutablePath setting to use default install location'); + await deps.setConfiguredPath(foundPath); + } + + return { cliPath: foundPath, available: true, source: 'default-install' }; + } + + // 4. CLI not found anywhere + extensionLogOutputChannel.warn('Aspire CLI not found on PATH or at default install locations'); + return { cliPath: 'aspire', available: false, source: 'not-found' }; +} diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index ca9f4ea3c64..bd342a5feb5 100644 --- a/extension/src/utils/configInfoProvider.ts +++ b/extension/src/utils/configInfoProvider.ts @@ -9,11 +9,13 @@ import * as strings from '../loc/strings'; * Gets configuration information from the Aspire CLI. */ export async function getConfigInfo(terminalProvider: AspireTerminalProvider): Promise { + const cliPath = await terminalProvider.getAspireCliExecutablePath(); + return new Promise((resolve) => { const args = ['config', 'info', '--json']; let output = ''; - spawnCliProcess(terminalProvider, terminalProvider.getAspireCliExecutablePath(), args, { + spawnCliProcess(terminalProvider, cliPath, args, { stdoutCallback: (data) => { output += data; }, diff --git a/extension/src/utils/workspace.ts b/extension/src/utils/workspace.ts index 302b11dc716..f1335aa87d4 100644 --- a/extension/src/utils/workspace.ts +++ b/extension/src/utils/workspace.ts @@ -1,13 +1,13 @@ import * as vscode from 'vscode'; -import { cliNotAvailable, dismissLabel, dontShowAgainLabel, doYouWantToSetDefaultApphost, noLabel, noWorkspaceOpen, openCliInstallInstructions, selectDefaultLaunchApphost, yesLabel } from '../loc/strings'; +import { 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, execFile } from 'child_process'; +import { ChildProcessWithoutNullStreams } from 'child_process'; import { AspireSettingsFile } from './cliTypes'; import { extensionLogOutputChannel } from './logging'; import { EnvironmentVariables } from './environment'; -import { promisify } from 'util'; +import { resolveCliPath } from './cliPath'; /** * Common file patterns to exclude from workspace file searches. @@ -158,13 +158,14 @@ export async function checkForExistingAppHostPathInWorkspace(terminalProvider: A extensionLogOutputChannel.info('Searching for AppHost projects using CLI command: aspire extension get-apphosts'); let proc: ChildProcessWithoutNullStreams; + const cliPath = await terminalProvider.getAspireCliExecutablePath(); new Promise((resolve, reject) => { const args = ['extension', 'get-apphosts']; if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') { args.push('--cli-wait-for-debugger'); } - proc = spawnCliProcess(terminalProvider, terminalProvider.getAspireCliExecutablePath(), args, { + proc = spawnCliProcess(terminalProvider, cliPath, args, { errorCallback: error => { extensionLogOutputChannel.error(`Error executing get-apphosts command: ${error}`); reject(); @@ -268,44 +269,38 @@ async function promptToAddAppHostPathToSettingsFile(result: AppHostProjectSearch extensionLogOutputChannel.info(`Successfully set appHostPath to: ${appHostToUse} in ${settingsFileLocation.fsPath}`); } -const execFileAsync = promisify(execFile); - -let cliAvailableOnPath: boolean | undefined = undefined; - /** - * Checks if the Aspire CLI is available. If not, shows a message prompting to open Aspire CLI installation steps on the repo. - * @param cliPath The path to the Aspire CLI executable - * @returns true if CLI is available, false otherwise + * Checks if the Aspire CLI is available. If not found on PATH, it checks the default + * installation directory and updates the VS Code setting accordingly. + * + * If not available, shows a message prompting to open Aspire CLI installation steps. + * @returns An object containing the CLI path to use and whether CLI is available */ -export async function checkCliAvailableOrRedirect(cliPath: string): Promise { - if (cliAvailableOnPath === true) { - // Assume, for now, that CLI availability does not change during the session if it was previously confirmed - return Promise.resolve(true); +export async function checkCliAvailableOrRedirect(): Promise<{ cliPath: string; available: boolean }> { + // Resolve CLI path fresh each time — settings or PATH may have changed + const result = await resolveCliPath(); + + if (result.available) { + // Show informational message if CLI was found at default path (not on PATH) + if (result.source === 'default-install') { + extensionLogOutputChannel.info(`Using Aspire CLI from default install location: ${result.cliPath}`); + vscode.window.showInformationMessage(cliFoundAtDefaultPath(result.cliPath)); + } + + return { cliPath: result.cliPath, available: true }; } - try { - // Remove surrounding quotes if present (both single and double quotes) - let cleanPath = cliPath.trim(); - if ((cleanPath.startsWith("'") && cleanPath.endsWith("'")) || - (cleanPath.startsWith('"') && cleanPath.endsWith('"'))) { - cleanPath = cleanPath.slice(1, -1); + // CLI not found - show error message with install instructions + vscode.window.showErrorMessage( + cliNotAvailable, + openCliInstallInstructions, + dismissLabel + ).then(selection => { + if (selection === openCliInstallInstructions) { + // Go to Aspire CLI installation instruction page in external browser + vscode.env.openExternal(vscode.Uri.parse('https://aspire.dev/get-started/install-cli/')); } - await execFileAsync(cleanPath, ['--version'], { timeout: 5000 }); - cliAvailableOnPath = true; - return true; - } catch (error) { - cliAvailableOnPath = false; - vscode.window.showErrorMessage( - cliNotAvailable, - openCliInstallInstructions, - dismissLabel - ).then(selection => { - if (selection === openCliInstallInstructions) { - // Go to Aspire CLI installation instruction page in external browser - vscode.env.openExternal(vscode.Uri.parse('https://aspire.dev/get-started/install-cli/')); - } - }); + }); - return false; - } + return { cliPath: result.cliPath, available: false }; } diff --git a/global.json b/global.json index 087505bbcae..39ccee4a4d2 100644 --- a/global.json +++ b/global.json @@ -33,8 +33,8 @@ "msbuild-sdks": { "Microsoft.Build.NoTargets": "3.7.0", "Microsoft.Build.Traversal": "3.2.0", - "Microsoft.DotNet.Arcade.Sdk": "11.0.0-beta.25610.3", - "Microsoft.DotNet.Helix.Sdk": "11.0.0-beta.25610.3", - "Microsoft.DotNet.SharedFramework.Sdk": "11.0.0-beta.25610.3" + "Microsoft.DotNet.Arcade.Sdk": "10.0.0-beta.26110.1", + "Microsoft.DotNet.Helix.Sdk": "10.0.0-beta.26110.1", + "Microsoft.DotNet.SharedFramework.Sdk": "10.0.0-beta.26110.1" } } diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentContext.cs index 738fb0d2ec0..cbd4163d04c 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentContext.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentContext.cs @@ -8,7 +8,7 @@ namespace Aspire.Hosting.Kubernetes; internal sealed class KubernetesEnvironmentContext(KubernetesEnvironmentResource environment, ILogger logger) { - private readonly Dictionary _kubernetesComponents = []; + private readonly Dictionary _kubernetesComponents = new(new ResourceNameComparer()); public ILogger Logger => logger; diff --git a/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs index fee7f2ccda8..e18fa6d9492 100644 --- a/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.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 System.Runtime.CompilerServices; @@ -9,7 +9,6 @@ using Aspire.Cli.Tests.TestServices; using Microsoft.Extensions.DependencyInjection; using Aspire.Cli.Utils; -using Aspire.TestUtilities; using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -269,7 +268,6 @@ public async Task DeployCommandSucceedsEndToEnd() } [Fact] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/11217")] public async Task DeployCommandIncludesDeployFlagInArguments() { using var tempRepo = TemporaryWorkspace.Create(outputHelper); diff --git a/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs index 85dc01e97f3..e46e24a6026 100644 --- a/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.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 System.Text.Json; @@ -7,7 +7,6 @@ using Aspire.Cli.Projects; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; -using Aspire.TestUtilities; using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -55,7 +54,6 @@ public async Task ExtensionInternalCommand_WithNoSubcommand_ReturnsZero() } [Fact] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/12304")] public async Task GetAppHostsCommand_WithSingleProject_ReturnsSuccessWithValidJson() { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -97,7 +95,6 @@ public async Task GetAppHostsCommand_WithSingleProject_ReturnsSuccessWithValidJs } [Fact] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/12300")] public async Task GetAppHostsCommand_WithMultipleProjects_ReturnsSuccessWithAllCandidates() { using var workspace = TemporaryWorkspace.Create(outputHelper); diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt index 0214785534c..04e5588f32e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt @@ -29,26 +29,26 @@ Steps with no dependencies run first, followed by steps that depend on them. 13. login-to-acr-aca-env-acr 14. push-prereq 15. push-api-service - 16. update-api-service-provisionable-resource - 17. provision-api-service-website - 18. print-api-service-summary - 19. provision-aca-env - 20. provision-cache-containerapp - 21. print-cache-summary - 22. push-python-app - 23. provision-python-app-containerapp - 24. provision-storage - 25. provision-azure-bicep-resources - 26. print-dashboard-url-aas-env - 27. print-dashboard-url-aca-env - 28. print-python-app-summary - 29. deploy - 30. deploy-api-service - 31. deploy-cache - 32. deploy-python-app - 33. diagnostics - 34. publish-prereq - 35. publish-azure634f9 + 16. provision-api-service-website + 17. print-api-service-summary + 18. provision-aca-env + 19. provision-cache-containerapp + 20. print-cache-summary + 21. push-python-app + 22. provision-python-app-containerapp + 23. provision-storage + 24. provision-azure-bicep-resources + 25. print-dashboard-url-aas-env + 26. print-dashboard-url-aca-env + 27. print-python-app-summary + 28. deploy + 29. deploy-api-service + 30. deploy-cache + 31. deploy-python-app + 32. diagnostics + 33. publish-prereq + 34. publish-azure634f9 + 35. validate-appservice-config-aas-env 36. publish 37. publish-manifest 38. push @@ -182,7 +182,7 @@ Step: provision-aca-env-acr Step: provision-api-service-website Description: Provisions the Azure Bicep resource api-service-website using Azure infrastructure. - Dependencies: ✓ create-provisioning-context, ✓ provision-aas-env, ✓ push-api-service, ✓ update-api-service-provisionable-resource + Dependencies: ✓ create-provisioning-context, ✓ provision-aas-env, ✓ push-api-service Resource: api-service-website (AzureAppServiceWebSiteResource) Tags: provision-infra @@ -212,7 +212,7 @@ Step: provision-storage Step: publish Description: Aggregation step for all publish operations. All publish steps should be required by this step. - Dependencies: ✓ publish-azure634f9 + Dependencies: ✓ publish-azure634f9, ✓ validate-appservice-config-aas-env Step: publish-azure634f9 Description: Publishes the Azure environment configuration for azure634f9. @@ -245,10 +245,10 @@ Step: push-python-app Resource: python-app (ContainerResource) Tags: push-container-image -Step: update-api-service-provisionable-resource - Dependencies: ✓ create-provisioning-context - Resource: api-service-website (AzureAppServiceWebSiteResource) - Tags: update-website-provisionable-resource +Step: validate-appservice-config-aas-env + Description: Validates Azure App Service configuration for aas-env. + Dependencies: ✓ publish-prereq + Resource: aas-env (AzureAppServiceEnvironmentResource) Step: validate-azure-login Description: Validates Azure CLI authentication before deployment. @@ -309,13 +309,13 @@ If targeting 'create-provisioning-context': If targeting 'deploy': Direct dependencies: build-api-service, build-python-app, create-provisioning-context, print-api-service-summary, print-cache-summary, print-dashboard-url-aas-env, print-dashboard-url-aca-env, print-python-app-summary, provision-azure-bicep-resources, validate-azure-login - Total steps: 28 + Total steps: 27 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | build-python-app | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage | update-api-service-provisionable-resource (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env | provision-aca-env (parallel) [6] provision-cache-containerapp | push-prereq (parallel) [7] print-cache-summary | push-api-service | push-python-app (parallel) @@ -326,13 +326,13 @@ If targeting 'deploy': If targeting 'deploy-api-service': Direct dependencies: print-api-service-summary - Total steps: 17 + Total steps: 16 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | update-api-service-provisionable-resource (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env (parallel) [6] push-prereq [7] push-api-service @@ -407,13 +407,13 @@ If targeting 'login-to-acr-aca-env-acr': If targeting 'print-api-service-summary': Direct dependencies: provision-api-service-website - Total steps: 16 + Total steps: 15 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | update-api-service-provisionable-resource (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env (parallel) [6] push-prereq [7] push-api-service @@ -435,13 +435,13 @@ If targeting 'print-cache-summary': If targeting 'print-dashboard-url-aas-env': Direct dependencies: provision-aas-env, provision-azure-bicep-resources - Total steps: 23 + Total steps: 22 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | build-python-app | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage | update-api-service-provisionable-resource (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env | provision-aca-env (parallel) [6] provision-cache-containerapp | push-prereq (parallel) [7] push-api-service | push-python-app (parallel) @@ -451,13 +451,13 @@ If targeting 'print-dashboard-url-aas-env': If targeting 'print-dashboard-url-aca-env': Direct dependencies: provision-aca-env, provision-azure-bicep-resources - Total steps: 23 + Total steps: 22 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | build-python-app | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage | update-api-service-provisionable-resource (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env | provision-aca-env (parallel) [6] provision-cache-containerapp | push-prereq (parallel) [7] push-api-service | push-python-app (parallel) @@ -529,14 +529,14 @@ If targeting 'provision-aca-env-acr': [4] provision-aca-env-acr If targeting 'provision-api-service-website': - Direct dependencies: create-provisioning-context, provision-aas-env, push-api-service, update-api-service-provisionable-resource - Total steps: 15 + Direct dependencies: create-provisioning-context, provision-aas-env, push-api-service + Total steps: 14 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | update-api-service-provisionable-resource (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env (parallel) [6] push-prereq [7] push-api-service @@ -544,13 +544,13 @@ If targeting 'provision-api-service-website': If targeting 'provision-azure-bicep-resources': Direct dependencies: create-provisioning-context, deploy-prereq, provision-aas-env, provision-aas-env-acr, provision-aca-env, provision-aca-env-acr, provision-api-service-website, provision-cache-containerapp, provision-python-app-containerapp, provision-storage - Total steps: 22 + Total steps: 21 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | build-python-app | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage | update-api-service-provisionable-resource (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env | provision-aca-env (parallel) [6] provision-cache-containerapp | push-prereq (parallel) [7] push-api-service | push-python-app (parallel) @@ -594,12 +594,12 @@ If targeting 'provision-storage': [4] provision-storage If targeting 'publish': - Direct dependencies: publish-azure634f9 - Total steps: 4 + Direct dependencies: publish-azure634f9, validate-appservice-config-aas-env + Total steps: 5 Execution order: [0] process-parameters [1] publish-prereq - [2] publish-azure634f9 + [2] publish-azure634f9 | validate-appservice-config-aas-env (parallel) [3] publish If targeting 'publish-azure634f9': @@ -675,15 +675,13 @@ If targeting 'push-python-app': [6] push-prereq [7] push-python-app -If targeting 'update-api-service-provisionable-resource': - Direct dependencies: create-provisioning-context - Total steps: 5 +If targeting 'validate-appservice-config-aas-env': + Direct dependencies: publish-prereq + Total steps: 3 Execution order: [0] process-parameters - [1] deploy-prereq - [2] validate-azure-login - [3] create-provisioning-context - [4] update-api-service-provisionable-resource + [1] publish-prereq + [2] validate-appservice-config-aas-env If targeting 'validate-azure-login': Direct dependencies: deploy-prereq diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs index 15034bb96c0..ddfe4aae3b4 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs @@ -652,7 +652,6 @@ public async Task PushImageToRegistry_WithLocalRegistry_OnlyTagsImage() } [Fact] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/13878")] public async Task PushImageToRegistry_WithRemoteRegistry_PushesImage() { using var tempDir = new TestTempDirectory(); diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index a3da353c498..654b8cf29b4 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -407,6 +407,48 @@ public async Task KubernetesWithProjectResources() await settingsTask; } + [Fact] + public async Task KubernetesMapsPortsForBaitAndSwitchResources() + { + using var tempDir = new TestTempDirectory(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path); + builder.AddKubernetesEnvironment("env"); + var api = builder.AddExecutable("api", "node", ".") + .PublishAsDockerFile() + .WithHttpEndpoint(env: "PORT"); + builder.AddContainer("gateway", "nginx") + .WithHttpEndpoint(targetPort: 8080) + .WithReference(api.GetEndpoint("http")); + var app = builder.Build(); + app.Run(); + // Assert + var expectedFiles = new[] + { + "Chart.yaml", + "values.yaml", + "templates/api/deployment.yaml", + "templates/api/service.yaml", + "templates/api/config.yaml", + "templates/gateway/deployment.yaml", + "templates/gateway/config.yaml" + }; + SettingsTask settingsTask = default!; + foreach (var expectedFile in expectedFiles) + { + var filePath = Path.Combine(tempDir.Path, expectedFile); + var fileExtension = Path.GetExtension(filePath)[1..]; + if (settingsTask is null) + { + settingsTask = Verify(File.ReadAllText(filePath), fileExtension); + } + else + { + settingsTask = settingsTask.AppendContentAsFile(File.ReadAllText(filePath), fileExtension); + } + } + await settingsTask; + } + private sealed class TestProject : IProjectMetadata { public string ProjectPath => "another-path"; diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#00.verified.yaml new file mode 100644 index 00000000000..e4179697054 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#00.verified.yaml @@ -0,0 +1,11 @@ +apiVersion: "v2" +name: "aspire-hosting-tests" +version: "0.1.0" +kubeVersion: ">= 1.18.0-0" +description: "Aspire Helm Chart" +type: "application" +keywords: + - "aspire" + - "kubernetes" +appVersion: "0.1.0" +deprecated: false diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#01.verified.yaml new file mode 100644 index 00000000000..9bb8e2495d4 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#01.verified.yaml @@ -0,0 +1,10 @@ +parameters: + api: + api_image: "api:latest" +secrets: {} +config: + api: + PORT: "8000" + gateway: + API_HTTP: "http://api-service:8000" + services__api__http__0: "http://api-service:8000" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#02.verified.yaml new file mode 100644 index 00000000000..7c0045b550b --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#02.verified.yaml @@ -0,0 +1,40 @@ +--- +apiVersion: "apps/v1" +kind: "Deployment" +metadata: + name: "api-deployment" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" +spec: + template: + metadata: + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" + spec: + containers: + - image: "{{ .Values.parameters.api.api_image }}" + name: "api" + envFrom: + - configMapRef: + name: "api-config" + ports: + - name: "http" + protocol: "TCP" + containerPort: 8000 + imagePullPolicy: "IfNotPresent" + selector: + matchLabels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" + replicas: 1 + revisionHistoryLimit: 3 + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: "RollingUpdate" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#03.verified.yaml new file mode 100644 index 00000000000..a3bfbdbc5d2 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#03.verified.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: "v1" +kind: "Service" +metadata: + name: "api-service" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" +spec: + type: "ClusterIP" + selector: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" + ports: + - name: "http" + protocol: "TCP" + port: 8000 + targetPort: 8000 diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#04.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#04.verified.yaml new file mode 100644 index 00000000000..2b756089179 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#04.verified.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "api-config" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + PORT: "{{ .Values.config.api.PORT }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#05.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#05.verified.yaml new file mode 100644 index 00000000000..7abdfd9076b --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#05.verified.yaml @@ -0,0 +1,40 @@ +--- +apiVersion: "apps/v1" +kind: "Deployment" +metadata: + name: "gateway-deployment" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "gateway" + app.kubernetes.io/instance: "{{ .Release.Name }}" +spec: + template: + metadata: + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "gateway" + app.kubernetes.io/instance: "{{ .Release.Name }}" + spec: + containers: + - image: "nginx:latest" + name: "gateway" + envFrom: + - configMapRef: + name: "gateway-config" + ports: + - name: "http" + protocol: "TCP" + containerPort: 8080 + imagePullPolicy: "IfNotPresent" + selector: + matchLabels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "gateway" + app.kubernetes.io/instance: "{{ .Release.Name }}" + replicas: 1 + revisionHistoryLimit: 3 + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: "RollingUpdate" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#06.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#06.verified.yaml new file mode 100644 index 00000000000..190928c781d --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#06.verified.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "gateway-config" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "gateway" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + API_HTTP: "{{ .Values.config.gateway.API_HTTP }}" + services__api__http__0: "{{ .Values.config.gateway.services__api__http__0 }}" diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs index edfc9542a61..5af54367a6d 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.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. #pragma warning disable CS0618 // Type or member is obsolete @@ -13,7 +13,6 @@ using Aspire.Hosting.Pipelines; using Aspire.Hosting.Tests.Publishing; using Aspire.Hosting.Utils; -using Aspire.TestUtilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -2027,7 +2026,6 @@ public async Task FilterStepsForExecution_WithRequiredBy_IncludesTransitiveDepen } [Fact] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/13083")] public async Task ProcessParametersStep_ValidatesBehavior() { // Arrange diff --git a/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs b/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs index e0659ee6e79..daca5107af6 100644 --- a/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs +++ b/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs @@ -4,7 +4,6 @@ using System.Net; using Aspire.Hosting.Testing; using Aspire.Hosting.Utils; -using Aspire.TestUtilities; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http.Resilience; @@ -401,7 +400,6 @@ public async Task WithHttpCommand_CallsGetResponseCallback_AfterSendingRequest() } [Fact] - [QuarantinedTest("https://github.com/dotnet/aspire/issues/8101")] public async Task WithHttpCommand_EnablesCommandOnceResourceIsRunning() { // Arrange diff --git a/tools/perf/Measure-StartupPerformance.ps1 b/tools/perf/Measure-StartupPerformance.ps1 new file mode 100644 index 00000000000..626adff10ed --- /dev/null +++ b/tools/perf/Measure-StartupPerformance.ps1 @@ -0,0 +1,678 @@ +<# +.SYNOPSIS + Measures .NET Aspire application startup performance by collecting ETW traces. + +.DESCRIPTION + This script runs an Aspire application, collects a performance trace + using dotnet-trace, and computes the startup time from AspireEventSource events. + The trace collection ends when the DcpModelCreationStop event is fired. + +.PARAMETER ProjectPath + Path to the AppHost project (.csproj) to measure. Can be absolute or relative. + Defaults to the TestShop.AppHost project in the playground folder. + +.PARAMETER Iterations + Number of times to run the scenario and collect traces. Defaults to 1. + +.PARAMETER PreserveTraces + If specified, trace files are preserved after the run. By default, traces are + stored in a temporary folder and deleted after analysis. + +.PARAMETER TraceOutputDirectory + Directory where trace files will be saved when PreserveTraces is set. + Defaults to a 'traces' subdirectory in the script folder. + +.PARAMETER SkipBuild + If specified, skips building the project before running. + +.PARAMETER TraceDurationSeconds + Duration in seconds for the trace collection. Defaults to 60 (1 minute). + The value is automatically converted to the dd:hh:mm:ss format required by dotnet-trace. + +.PARAMETER PauseBetweenIterationsSeconds + Number of seconds to pause between iterations. Defaults to 15. + Set to 0 to disable the pause. + +.PARAMETER Verbose + If specified, shows detailed output during execution. + +.EXAMPLE + .\Measure-StartupPerformance.ps1 + +.EXAMPLE + .\Measure-StartupPerformance.ps1 -Iterations 5 + +.EXAMPLE + .\Measure-StartupPerformance.ps1 -ProjectPath "C:\MyApp\MyApp.AppHost.csproj" -Iterations 3 + +.EXAMPLE + .\Measure-StartupPerformance.ps1 -Iterations 3 -PreserveTraces -TraceOutputDirectory "C:\traces" + +.EXAMPLE + .\Measure-StartupPerformance.ps1 -TraceDurationSeconds 120 + +.EXAMPLE + .\Measure-StartupPerformance.ps1 -Iterations 5 -PauseBetweenIterationsSeconds 30 + +.NOTES + Requires: + - PowerShell 7+ + - dotnet-trace global tool (dotnet tool install -g dotnet-trace) + - .NET SDK +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$ProjectPath, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 100)] + [int]$Iterations = 1, + + [Parameter(Mandatory = $false)] + [switch]$PreserveTraces, + + [Parameter(Mandatory = $false)] + [string]$TraceOutputDirectory, + + [Parameter(Mandatory = $false)] + [switch]$SkipBuild, + + [Parameter(Mandatory = $false)] + [ValidateRange(1, 86400)] + [int]$TraceDurationSeconds = 60, + + [Parameter(Mandatory = $false)] + [ValidateRange(0, 3600)] + [int]$PauseBetweenIterationsSeconds = 45 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Constants +$EventSourceName = 'Microsoft-Aspire-Hosting' +$DcpModelCreationStartEventId = 17 +$DcpModelCreationStopEventId = 18 + +# Get repository root (script is in tools/perf) +$ScriptDir = $PSScriptRoot +$RepoRoot = (Resolve-Path (Join-Path $ScriptDir '..' '..')).Path + +# Resolve project path +if (-not $ProjectPath) { + # Default to TestShop.AppHost + $ProjectPath = Join-Path $RepoRoot 'playground' 'TestShop' 'TestShop.AppHost' 'TestShop.AppHost.csproj' +} +elseif (-not [System.IO.Path]::IsPathRooted($ProjectPath)) { + # Relative path - resolve from current directory + $ProjectPath = (Resolve-Path $ProjectPath -ErrorAction Stop).Path +} + +$AppHostProject = $ProjectPath +$AppHostDir = Split-Path $AppHostProject -Parent +$AppHostName = [System.IO.Path]::GetFileNameWithoutExtension($AppHostProject) + +# Determine output directory for traces - always use temp directory unless explicitly specified +if ($TraceOutputDirectory) { + $OutputDirectory = $TraceOutputDirectory +} +else { + # Always use a temp directory for traces + $OutputDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-perf-$([System.Guid]::NewGuid().ToString('N').Substring(0, 8))" +} + +# Only delete temp directory if not preserving traces and no custom directory was specified +$ShouldCleanupDirectory = -not $PreserveTraces -and -not $TraceOutputDirectory + +# Ensure output directory exists +if (-not (Test-Path $OutputDirectory)) { + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null +} + +# Verify prerequisites +function Test-Prerequisites { + Write-Host "Checking prerequisites..." -ForegroundColor Cyan + + # Check dotnet-trace is installed + $dotnetTrace = Get-Command 'dotnet-trace' -ErrorAction SilentlyContinue + if (-not $dotnetTrace) { + throw "dotnet-trace is not installed. Install it with: dotnet tool install -g dotnet-trace" + } + Write-Verbose "dotnet-trace found at: $($dotnetTrace.Source)" + + # Check project exists + if (-not (Test-Path $AppHostProject)) { + throw "AppHost project not found at: $AppHostProject" + } + Write-Verbose "AppHost project found at: $AppHostProject" + + Write-Host "Prerequisites check passed." -ForegroundColor Green +} + +# Build the project +function Build-AppHost { + Write-Host "Building $AppHostName..." -ForegroundColor Cyan + + Push-Location $AppHostDir + try { + $buildOutput = & dotnet build -c Release --nologo 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host ($buildOutput -join "`n") -ForegroundColor Red + throw "Failed to build $AppHostName" + } + Write-Verbose ($buildOutput -join "`n") + Write-Host "Build completed successfully." -ForegroundColor Green + } + finally { + Pop-Location + } +} + +# Run a single iteration of the performance test +function Invoke-PerformanceIteration { + param( + [int]$IterationNumber, + [string]$TraceOutputPath + ) + + Write-Host "`nIteration $IterationNumber" -ForegroundColor Yellow + Write-Host ("-" * 40) -ForegroundColor Yellow + + $nettracePath = "$TraceOutputPath.nettrace" + $appProcess = $null + $traceProcess = $null + + try { + # Find the compiled executable - we need the path to launch it + $exePath = $null + $dllPath = $null + + # Search in multiple possible output locations: + # 1. Arcade-style: artifacts/bin//Release// + # 2. Traditional: /bin/Release// + $searchPaths = @( + (Join-Path $RepoRoot 'artifacts' 'bin' $AppHostName 'Release'), + (Join-Path $AppHostDir 'bin' 'Release') + ) + + foreach ($basePath in $searchPaths) { + if (-not (Test-Path $basePath)) { + continue + } + + # Find TFM subdirectories (e.g., net8.0, net9.0, net10.0) + $tfmDirs = Get-ChildItem -Path $basePath -Directory -Filter 'net*' -ErrorAction SilentlyContinue + foreach ($tfmDir in $tfmDirs) { + $candidateExe = Join-Path $tfmDir.FullName "$AppHostName.exe" + $candidateDll = Join-Path $tfmDir.FullName "$AppHostName.dll" + + if (Test-Path $candidateExe) { + $exePath = $candidateExe + Write-Verbose "Found executable at: $exePath" + break + } + elseif (Test-Path $candidateDll) { + $dllPath = $candidateDll + Write-Verbose "Found DLL at: $dllPath" + break + } + } + + if ($exePath -or $dllPath) { + break + } + } + + if (-not $exePath -and -not $dllPath) { + $searchedPaths = $searchPaths -join "`n - " + throw "Could not find compiled executable or DLL. Searched in:`n - $searchedPaths`nPlease build the project first (without -SkipBuild)." + } + + # Read launchSettings.json to get environment variables + $launchSettingsPath = Join-Path $AppHostDir 'Properties' 'launchSettings.json' + $envVars = @{} + if (Test-Path $launchSettingsPath) { + Write-Verbose "Reading launch settings from: $launchSettingsPath" + try { + # Read the file and remove JSON comments (// style) before parsing + # Only remove lines that start with // (after optional whitespace) to avoid breaking URLs like https:// + $jsonLines = Get-Content $launchSettingsPath + $filteredLines = $jsonLines | Where-Object { $_.Trim() -notmatch '^//' } + $jsonContent = $filteredLines -join "`n" + $launchSettings = $jsonContent | ConvertFrom-Json + + # Try to find a suitable profile (prefer 'http' for simplicity, then first available) + $profile = $null + if ($launchSettings.profiles.http) { + $profile = $launchSettings.profiles.http + Write-Verbose "Using 'http' launch profile" + } + elseif ($launchSettings.profiles.https) { + $profile = $launchSettings.profiles.https + Write-Verbose "Using 'https' launch profile" + } + else { + # Use first profile that has environmentVariables + foreach ($prop in $launchSettings.profiles.PSObject.Properties) { + if ($prop.Value.environmentVariables) { + $profile = $prop.Value + Write-Verbose "Using '$($prop.Name)' launch profile" + break + } + } + } + + if ($profile -and $profile.environmentVariables) { + foreach ($prop in $profile.environmentVariables.PSObject.Properties) { + $envVars[$prop.Name] = $prop.Value + Write-Verbose " Environment: $($prop.Name)=$($prop.Value)" + } + } + + # Use applicationUrl to set ASPNETCORE_URLS if not already set + if ($profile -and $profile.applicationUrl -and -not $envVars.ContainsKey('ASPNETCORE_URLS')) { + $envVars['ASPNETCORE_URLS'] = $profile.applicationUrl + Write-Verbose " Environment: ASPNETCORE_URLS=$($profile.applicationUrl) (from applicationUrl)" + } + } + catch { + Write-Warning "Failed to parse launchSettings.json: $_" + } + } + else { + Write-Verbose "No launchSettings.json found at: $launchSettingsPath" + } + + # Always ensure Development environment is set + if (-not $envVars.ContainsKey('DOTNET_ENVIRONMENT')) { + $envVars['DOTNET_ENVIRONMENT'] = 'Development' + } + if (-not $envVars.ContainsKey('ASPNETCORE_ENVIRONMENT')) { + $envVars['ASPNETCORE_ENVIRONMENT'] = 'Development' + } + + # Start the AppHost application as a separate process + Write-Host "Starting $AppHostName..." -ForegroundColor Cyan + + $appPsi = [System.Diagnostics.ProcessStartInfo]::new() + if ($exePath) { + $appPsi.FileName = $exePath + $appPsi.Arguments = '' + } + else { + $appPsi.FileName = 'dotnet' + $appPsi.Arguments = "`"$dllPath`"" + } + $appPsi.WorkingDirectory = $AppHostDir + $appPsi.UseShellExecute = $false + $appPsi.RedirectStandardOutput = $true + $appPsi.RedirectStandardError = $true + $appPsi.CreateNoWindow = $true + + # Set environment variables from launchSettings.json + foreach ($key in $envVars.Keys) { + $appPsi.Environment[$key] = $envVars[$key] + } + + $appProcess = [System.Diagnostics.Process]::Start($appPsi) + $appPid = $appProcess.Id + + Write-Verbose "$AppHostName started with PID: $appPid" + + # Give the process a moment to initialize before attaching + Start-Sleep -Milliseconds 200 + + # Verify the process is still running + if ($appProcess.HasExited) { + $stdout = $appProcess.StandardOutput.ReadToEnd() + $stderr = $appProcess.StandardError.ReadToEnd() + throw "Application exited immediately with code $($appProcess.ExitCode).`nStdOut: $stdout`nStdErr: $stderr" + } + + # Start dotnet-trace to attach to the running process + Write-Host "Attaching trace collection to PID $appPid..." -ForegroundColor Cyan + + # Use dotnet-trace with the EventSource provider + # Format: ProviderName:Keywords:Level + # Keywords=0xFFFFFFFF (all), Level=5 (Verbose) + $providers = "${EventSourceName}" + + # Convert TraceDurationSeconds to dd:hh:mm:ss format required by dotnet-trace + $days = [math]::Floor($TraceDurationSeconds / 86400) + $hours = [math]::Floor(($TraceDurationSeconds % 86400) / 3600) + $minutes = [math]::Floor(($TraceDurationSeconds % 3600) / 60) + $seconds = $TraceDurationSeconds % 60 + $traceDuration = '{0:00}:{1:00}:{2:00}:{3:00}' -f $days, $hours, $minutes, $seconds + + $traceArgs = @( + 'collect', + '--process-id', $appPid, + '--providers', $providers, + '--output', $nettracePath, + '--format', 'nettrace', + '--duration', $traceDuration, + '--buffersize', '8192' + ) + + Write-Verbose "dotnet-trace arguments: $($traceArgs -join ' ')" + + $tracePsi = [System.Diagnostics.ProcessStartInfo]::new() + $tracePsi.FileName = 'dotnet-trace' + $tracePsi.Arguments = $traceArgs -join ' ' + $tracePsi.WorkingDirectory = $AppHostDir + $tracePsi.UseShellExecute = $false + $tracePsi.RedirectStandardOutput = $true + $tracePsi.RedirectStandardError = $true + $tracePsi.CreateNoWindow = $true + + $traceProcess = [System.Diagnostics.Process]::Start($tracePsi) + + Write-Host "Collecting performance trace..." -ForegroundColor Cyan + + # Wait for trace to complete + $traceProcess.WaitForExit() + + # Read app process output (what was captured while trace was running) + # Use async read to avoid blocking - read whatever is available + $appStdout = "" + $appStderr = "" + if ($appProcess -and -not $appProcess.HasExited) { + # Process is still running, we can try to read available output + # Note: ReadToEnd would block, so we read what's available after stopping + } + + $traceOutput = $traceProcess.StandardOutput.ReadToEnd() + $traceError = $traceProcess.StandardError.ReadToEnd() + + if ($traceOutput) { Write-Verbose "dotnet-trace output: $traceOutput" } + if ($traceError) { Write-Verbose "dotnet-trace stderr: $traceError" } + + # Check if trace file was created despite any errors + # dotnet-trace may report errors during cleanup but the trace file is often still valid + if ($traceProcess.ExitCode -ne 0) { + if (Test-Path $nettracePath) { + Write-Warning "dotnet-trace exited with code $($traceProcess.ExitCode), but trace file was created. Attempting to analyze." + } + else { + Write-Warning "dotnet-trace exited with code $($traceProcess.ExitCode) and no trace file was created." + return $null + } + } + + Write-Host "Trace collection completed." -ForegroundColor Green + + return $nettracePath + } + finally { + # Clean up the application process and capture its output + if ($appProcess) { + # Read any remaining output before killing the process + $appStdout = "" + $appStderr = "" + try { + # Give a moment for any buffered output + Start-Sleep -Milliseconds 100 + + # We need to read asynchronously since the process may still be running + # Read what's available without blocking indefinitely + $stdoutTask = $appProcess.StandardOutput.ReadToEndAsync() + $stderrTask = $appProcess.StandardError.ReadToEndAsync() + + # Wait briefly for output + [System.Threading.Tasks.Task]::WaitAll(@($stdoutTask, $stderrTask), 1000) | Out-Null + + if ($stdoutTask.IsCompleted) { + $appStdout = $stdoutTask.Result + } + if ($stderrTask.IsCompleted) { + $appStderr = $stderrTask.Result + } + } + catch { + # Ignore errors reading output + } + + if ($appStdout) { + Write-Verbose "Application stdout:`n$appStdout" + } + if ($appStderr) { + Write-Verbose "Application stderr:`n$appStderr" + } + + if (-not $appProcess.HasExited) { + Write-Verbose "Stopping $AppHostName (PID: $($appProcess.Id))..." + try { + # Try graceful shutdown first + $appProcess.Kill($true) + $appProcess.WaitForExit(5000) | Out-Null + } + catch { + Write-Warning "Failed to stop application: $_" + } + } + $appProcess.Dispose() + } + + # Clean up trace process + if ($traceProcess) { + if (-not $traceProcess.HasExited) { + try { + $traceProcess.Kill() + $traceProcess.WaitForExit(2000) | Out-Null + } + catch { + # Ignore errors killing trace process + } + } + $traceProcess.Dispose() + } + } +} + +# Path to the trace analyzer tool +$TraceAnalyzerDir = Join-Path $ScriptDir 'TraceAnalyzer' +$TraceAnalyzerProject = Join-Path $TraceAnalyzerDir 'TraceAnalyzer.csproj' + +# Build the trace analyzer tool +function Build-TraceAnalyzer { + if (-not (Test-Path $TraceAnalyzerProject)) { + Write-Warning "TraceAnalyzer project not found at: $TraceAnalyzerProject" + return $false + } + + Write-Verbose "Building TraceAnalyzer tool..." + $buildOutput = & dotnet build $TraceAnalyzerProject -c Release --verbosity quiet 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to build TraceAnalyzer: $buildOutput" + return $false + } + + Write-Verbose "TraceAnalyzer built successfully" + return $true +} + +# Parse nettrace file using the TraceAnalyzer tool +function Get-StartupTiming { + param( + [string]$TracePath + ) + + Write-Host "Analyzing trace: $TracePath" -ForegroundColor Cyan + + if (-not (Test-Path $TracePath)) { + Write-Warning "Trace file not found: $TracePath" + return $null + } + + try { + $output = & dotnet run --project $TraceAnalyzerProject -c Release --no-build -- $TracePath 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warning "TraceAnalyzer failed: $output" + return $null + } + + $result = $output | Select-Object -Last 1 + if ($result -eq 'null') { + Write-Warning "Could not find DcpModelCreation events in the trace" + return $null + } + + $duration = [double]::Parse($result, [System.Globalization.CultureInfo]::InvariantCulture) + Write-Verbose "Calculated duration: $duration ms" + return $duration + } + catch { + Write-Warning "Error parsing trace: $_" + return $null + } +} + +# Main execution +function Main { + Write-Host "==================================================" -ForegroundColor Cyan + Write-Host " Aspire Startup Performance Measurement" -ForegroundColor Cyan + Write-Host "==================================================" -ForegroundColor Cyan + Write-Host "" + Write-Host "Project: $AppHostName" + Write-Host "Project Path: $AppHostProject" + Write-Host "Iterations: $Iterations" + Write-Host "Trace Duration: $TraceDurationSeconds seconds" + Write-Host "Pause Between Iterations: $PauseBetweenIterationsSeconds seconds" + Write-Host "Preserve Traces: $PreserveTraces" + if ($PreserveTraces -or $TraceOutputDirectory) { + Write-Host "Trace Directory: $OutputDirectory" + } + Write-Host "" + + Test-Prerequisites + + # Build the TraceAnalyzer tool for parsing traces + $traceAnalyzerAvailable = Build-TraceAnalyzer + + # Ensure output directory exists + if (-not (Test-Path $OutputDirectory)) { + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + } + + if (-not $SkipBuild) { + Build-AppHost + } + else { + Write-Host "Skipping build (SkipBuild flag set)" -ForegroundColor Yellow + } + + $results = @() + $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' + + try { + for ($i = 1; $i -le $Iterations; $i++) { + $traceBaseName = "${AppHostName}_startup_${timestamp}_iter${i}" + $traceOutputPath = Join-Path $OutputDirectory $traceBaseName + + $tracePath = Invoke-PerformanceIteration -IterationNumber $i -TraceOutputPath $traceOutputPath + + if ($tracePath -and (Test-Path $tracePath)) { + $duration = $null + if ($traceAnalyzerAvailable) { + $duration = Get-StartupTiming -TracePath $tracePath + } + + if ($null -ne $duration) { + $results += [PSCustomObject]@{ + Iteration = $i + TracePath = $tracePath + StartupTimeMs = [math]::Round($duration, 2) + } + Write-Host "Startup time: $([math]::Round($duration, 2)) ms" -ForegroundColor Green + } + else { + $results += [PSCustomObject]@{ + Iteration = $i + TracePath = $tracePath + StartupTimeMs = $null + } + Write-Host "Trace collected: $tracePath" -ForegroundColor Green + } + } + else { + Write-Warning "No trace file generated for iteration $i" + } + + # Pause between iterations + if ($i -lt $Iterations -and $PauseBetweenIterationsSeconds -gt 0) { + Write-Verbose "Pausing for $PauseBetweenIterationsSeconds seconds before next iteration..." + Start-Sleep -Seconds $PauseBetweenIterationsSeconds + } + } + } + finally { + # Clean up temporary trace directory if not preserving traces + if ($ShouldCleanupDirectory -and (Test-Path $OutputDirectory)) { + Write-Verbose "Cleaning up temporary trace directory: $OutputDirectory" + Remove-Item -Path $OutputDirectory -Recurse -Force -ErrorAction SilentlyContinue + } + } + + # Summary + Write-Host "" + Write-Host "==================================================" -ForegroundColor Cyan + Write-Host " Results Summary" -ForegroundColor Cyan + Write-Host "==================================================" -ForegroundColor Cyan + + # Wrap in @() to ensure array even with single/null results + $validResults = @($results | Where-Object { $null -ne $_.StartupTimeMs }) + + if ($validResults.Count -gt 0) { + Write-Host "" + # Only show TracePath in summary if PreserveTraces is set + if ($PreserveTraces) { + $results | Format-Table -AutoSize + } + else { + $results | Select-Object Iteration, StartupTimeMs | Format-Table -AutoSize + } + + $times = @($validResults | ForEach-Object { $_.StartupTimeMs }) + $avg = ($times | Measure-Object -Average).Average + $min = ($times | Measure-Object -Minimum).Minimum + $max = ($times | Measure-Object -Maximum).Maximum + + Write-Host "" + Write-Host "Statistics:" -ForegroundColor Yellow + Write-Host " Successful iterations: $($validResults.Count) / $Iterations" + Write-Host " Minimum: $([math]::Round($min, 2)) ms" + Write-Host " Maximum: $([math]::Round($max, 2)) ms" + Write-Host " Average: $([math]::Round($avg, 2)) ms" + + if ($validResults.Count -gt 1) { + $stdDev = [math]::Sqrt(($times | ForEach-Object { [math]::Pow($_ - $avg, 2) } | Measure-Object -Average).Average) + Write-Host " Std Dev: $([math]::Round($stdDev, 2)) ms" + } + + if ($PreserveTraces) { + Write-Host "" + Write-Host "Trace files saved to: $OutputDirectory" -ForegroundColor Cyan + } + } + elseif ($results.Count -gt 0) { + Write-Host "" + Write-Host "Collected $($results.Count) trace(s) but could not extract timing." -ForegroundColor Yellow + if ($PreserveTraces) { + Write-Host "" + Write-Host "Trace files saved to: $OutputDirectory" -ForegroundColor Cyan + $results | Select-Object Iteration, TracePath | Format-Table -AutoSize + Write-Host "" + Write-Host "Open traces in PerfView or Visual Studio to analyze startup timing." -ForegroundColor Yellow + } + } + else { + Write-Warning "No traces were collected." + } + + return $results +} + +# Run the script +Main diff --git a/tools/perf/TraceAnalyzer/Program.cs b/tools/perf/TraceAnalyzer/Program.cs new file mode 100644 index 00000000000..76ffe45d44d --- /dev/null +++ b/tools/perf/TraceAnalyzer/Program.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. + +// Tool to analyze .nettrace files and extract Aspire startup timing information. +// Usage: dotnet run -- +// Output: Prints the startup duration in milliseconds to stdout, or "null" if events not found. + +using Microsoft.Diagnostics.Tracing; + +if (args.Length == 0) +{ + Console.Error.WriteLine("Usage: TraceAnalyzer "); + return 1; +} + +var tracePath = args[0]; + +if (!File.Exists(tracePath)) +{ + Console.Error.WriteLine($"Error: File not found: {tracePath}"); + return 1; +} + +// Event IDs from AspireEventSource +const int DcpModelCreationStartEventId = 17; +const int DcpModelCreationStopEventId = 18; + +const string AspireHostingProviderName = "Microsoft-Aspire-Hosting"; + +try +{ + double? startTime = null; + double? stopTime = null; + + using (var source = new EventPipeEventSource(tracePath)) + { + source.Dynamic.AddCallbackForProviderEvents((string pName, string eName) => + { + if (pName != AspireHostingProviderName) + { + return EventFilterResponse.RejectProvider; + } + if (eName == null || eName.StartsWith("DcpModelCreation", StringComparison.Ordinal)) + { + return EventFilterResponse.AcceptEvent; + } + return EventFilterResponse.RejectEvent; + }, + (TraceEvent traceEvent) => + { + if ((int)traceEvent.ID == DcpModelCreationStartEventId) + { + startTime = traceEvent.TimeStampRelativeMSec; + } + else if ((int)traceEvent.ID == DcpModelCreationStopEventId) + { + stopTime = traceEvent.TimeStampRelativeMSec; + } + }); + + source.Process(); + } + + if (startTime.HasValue && stopTime.HasValue) + { + var duration = stopTime.Value - startTime.Value; + Console.WriteLine(duration.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)); + return 0; + } + else + { + Console.WriteLine("null"); + return 0; + } +} +catch (Exception ex) +{ + Console.Error.WriteLine($"Error parsing trace: {ex.Message}"); + return 1; +} diff --git a/tools/perf/TraceAnalyzer/TraceAnalyzer.csproj b/tools/perf/TraceAnalyzer/TraceAnalyzer.csproj new file mode 100644 index 00000000000..f984521fbc3 --- /dev/null +++ b/tools/perf/TraceAnalyzer/TraceAnalyzer.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + enable + enable + + false + + + + + + + From 2fda109b6f2f209f04478007adfaf394e9b2022f Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 24 Feb 2026 07:42:26 +0800 Subject: [PATCH 149/256] Fix Aspire CLI ANSI detection (#14624) --- src/Aspire.Cli/Utils/CliHostEnvironment.cs | 36 ++++- .../Utils/CliHostEnvironmentTests.cs | 137 ++++++------------ 2 files changed, 76 insertions(+), 97 deletions(-) diff --git a/src/Aspire.Cli/Utils/CliHostEnvironment.cs b/src/Aspire.Cli/Utils/CliHostEnvironment.cs index ffd66f43209..a8a378f705a 100644 --- a/src/Aspire.Cli/Utils/CliHostEnvironment.cs +++ b/src/Aspire.Cli/Utils/CliHostEnvironment.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Extensions.Configuration; +using Spectre.Console; namespace Aspire.Cli.Utils; @@ -90,6 +91,28 @@ public CliHostEnvironment(IConfiguration configuration, bool nonInteractive) } } + private static bool DetectAnsiSupport(IConfiguration configuration) + { + if (!TryDetectAnsiSupportConfiguration(configuration, out var supportsAnsi)) + { + // If there is no explicit configuration to enable or disable ANSI support, attempt to detect it. + // This is required because some terminals don't support ANSI output, e.g. https://github.com/dotnet/aspire/issues/13737 + + // TODO: Creating a fake console here is a hack to run ANSI detection logic. + // Update this to use AnsiCapabilities once it's available in Spectre.Console 0.60+ instead of creating a full AnsiConsole instance. + var ansiConsole = AnsiConsole.Create(new AnsiConsoleSettings + { + Out = new AnsiConsoleOutput(TextWriter.Null), + Ansi = AnsiSupport.Detect, + ColorSystem = ColorSystemSupport.Detect + }); + + supportsAnsi = ansiConsole.Profile.Capabilities.Ansi; + } + + return supportsAnsi; + } + private static bool DetectInteractiveInput(IConfiguration configuration) { // Check if explicitly disabled via configuration @@ -130,23 +153,26 @@ private static bool DetectInteractiveOutput(IConfiguration configuration) return true; } - private static bool DetectAnsiSupport(IConfiguration configuration) + private static bool TryDetectAnsiSupportConfiguration(IConfiguration configuration, out bool supportsAnsi) { // Check for ASPIRE_ANSI_PASS_THRU to force ANSI even when redirected if (IsAnsiPassThruEnabled(configuration)) { + supportsAnsi = true; return true; } - // ANSI codes are supported even in CI environments for colored output - // Only disable if explicitly configured + // Check for NO_COLOR to explicitly disable ANSI output. + // If neither override is set, return false to let the caller fall back to Spectre detection. var noColor = configuration["NO_COLOR"]; if (!string.IsNullOrEmpty(noColor)) { - return false; + supportsAnsi = false; + return true; } - return true; + supportsAnsi = default; + return false; } private static bool IsAnsiPassThruEnabled(IConfiguration configuration) diff --git a/tests/Aspire.Cli.Tests/Utils/CliHostEnvironmentTests.cs b/tests/Aspire.Cli.Tests/Utils/CliHostEnvironmentTests.cs index 33d27a65c0e..5b64d923d74 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliHostEnvironmentTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliHostEnvironmentTests.cs @@ -13,10 +13,10 @@ public void SupportsInteractiveInput_ReturnsTrue_WhenNoConfigSet() { // Arrange var configuration = new ConfigurationBuilder().Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert Assert.True(env.SupportsInteractiveInput); } @@ -26,25 +26,12 @@ public void SupportsInteractiveOutput_ReturnsTrue_WhenNoConfigSet() { // Arrange var configuration = new ConfigurationBuilder().Build(); - - // Act - var env = new CliHostEnvironment(configuration, nonInteractive: false); - - // Assert - Assert.True(env.SupportsInteractiveOutput); - } - [Fact] - public void SupportsAnsi_ReturnsTrue_WhenNoConfigSet() - { - // Arrange - var configuration = new ConfigurationBuilder().Build(); - // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert - Assert.True(env.SupportsAnsi); + Assert.True(env.SupportsInteractiveOutput); } [Theory] @@ -59,10 +46,10 @@ public void SupportsInteractiveInput_ReturnsFalse_WhenNonInteractiveSet(string k [key] = value }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert Assert.False(env.SupportsInteractiveInput); } @@ -79,10 +66,10 @@ public void SupportsInteractiveOutput_ReturnsFalse_WhenNonInteractiveSet(string [key] = value }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert Assert.False(env.SupportsInteractiveOutput); } @@ -102,10 +89,10 @@ public void SupportsInteractiveInput_ReturnsFalse_InCIEnvironment(string envVar, [envVar] = value }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert Assert.False(env.SupportsInteractiveInput); } @@ -124,33 +111,12 @@ public void SupportsInteractiveOutput_ReturnsFalse_InCIEnvironment(string envVar [envVar] = value }) .Build(); - - // Act - var env = new CliHostEnvironment(configuration, nonInteractive: false); - - // Assert - Assert.False(env.SupportsInteractiveOutput); - } - [Theory] - [InlineData("CI", "true")] - [InlineData("CI", "1")] - [InlineData("GITHUB_ACTIONS", "true")] - public void SupportsAnsi_ReturnsTrue_InCIEnvironment(string envVar, string value) - { - // Arrange - ANSI should still be supported in CI for colored output - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [envVar] = value - }) - .Build(); - // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert - Assert.True(env.SupportsAnsi); + Assert.False(env.SupportsInteractiveOutput); } [Fact] @@ -163,10 +129,10 @@ public void SupportsAnsi_ReturnsFalse_WhenNO_COLORSet() ["NO_COLOR"] = "1" }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert Assert.False(env.SupportsAnsi); } @@ -176,10 +142,10 @@ public void SupportsInteractiveInput_ReturnsFalse_WhenNonInteractiveTrue() { // Arrange var configuration = new ConfigurationBuilder().Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: true); - + // Assert Assert.False(env.SupportsInteractiveInput); } @@ -189,25 +155,12 @@ public void SupportsInteractiveOutput_ReturnsFalse_WhenNonInteractiveTrue() { // Arrange var configuration = new ConfigurationBuilder().Build(); - - // Act - var env = new CliHostEnvironment(configuration, nonInteractive: true); - - // Assert - Assert.False(env.SupportsInteractiveOutput); - } - [Fact] - public void SupportsAnsi_ReturnsTrue_WhenNonInteractiveTrue() - { - // Arrange - ANSI should still be supported even in non-interactive mode - var configuration = new ConfigurationBuilder().Build(); - // Act var env = new CliHostEnvironment(configuration, nonInteractive: true); - + // Assert - Assert.True(env.SupportsAnsi); + Assert.False(env.SupportsInteractiveOutput); } [Fact] @@ -220,10 +173,10 @@ public void SupportsInteractiveInput_ReturnsTrue_WhenPlaygroundModeSet() ["ASPIRE_PLAYGROUND"] = "true" }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert Assert.True(env.SupportsInteractiveInput); } @@ -238,10 +191,10 @@ public void SupportsInteractiveOutput_ReturnsTrue_WhenPlaygroundModeSet() ["ASPIRE_PLAYGROUND"] = "true" }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert Assert.True(env.SupportsInteractiveOutput); } @@ -257,10 +210,10 @@ public void SupportsInteractiveInput_ReturnsTrue_WhenPlaygroundModeSet_EvenInCI( ["CI"] = "true" }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert Assert.True(env.SupportsInteractiveInput); } @@ -276,10 +229,10 @@ public void SupportsInteractiveOutput_ReturnsTrue_WhenPlaygroundModeSet_EvenInCI ["GITHUB_ACTIONS"] = "true" }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert Assert.True(env.SupportsInteractiveOutput); } @@ -294,10 +247,10 @@ public void SupportsInteractiveInput_ReturnsFalse_WhenNonInteractiveIsTrue_EvenW ["ASPIRE_PLAYGROUND"] = "true" }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: true); - + // Assert // --non-interactive takes precedence over ASPIRE_PLAYGROUND Assert.False(env.SupportsInteractiveInput); @@ -313,10 +266,10 @@ public void SupportsInteractiveOutput_ReturnsFalse_WhenNonInteractiveIsTrue_Even ["ASPIRE_PLAYGROUND"] = "true" }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: true); - + // Assert // --non-interactive takes precedence over ASPIRE_PLAYGROUND Assert.False(env.SupportsInteractiveOutput); @@ -333,10 +286,10 @@ public void SupportsInteractiveInput_ReturnsFalse_WhenPlaygroundModeSetToFalse() ["CI"] = "true" }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert Assert.False(env.SupportsInteractiveInput); } @@ -351,10 +304,10 @@ public void SupportsAnsi_ReturnsTrue_WhenPlaygroundModeSet() ["ASPIRE_PLAYGROUND"] = "true" }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert Assert.True(env.SupportsAnsi); } @@ -370,28 +323,28 @@ public void SupportsAnsi_ReturnsTrue_WhenPlaygroundModeSet_EvenWithNO_COLOR() ["NO_COLOR"] = "1" }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert Assert.True(env.SupportsAnsi); } [Fact] - public void SupportsAnsi_ReturnsTrue_WhenPlaygroundModeSet_WithNonInteractive() + public void SupportsAnsi_ReturnsTrue_WhenAnsiPassThruSet_WithNonInteractive() { - // Arrange - ASPIRE_PLAYGROUND should enable ANSI even with --non-interactive + // Arrange - ASPIRE_ANSI_PASS_THRU explicitly enables ANSI even with --non-interactive var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["ASPIRE_PLAYGROUND"] = "true" + ["ASPIRE_ANSI_PASS_THRU"] = "true" }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: true); - + // Assert Assert.True(env.SupportsAnsi); } @@ -408,10 +361,10 @@ public void SupportsAnsi_ReturnsTrue_WhenAnsiPassThruSet(string key, string valu [key] = value }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert Assert.True(env.SupportsAnsi); } @@ -427,10 +380,10 @@ public void SupportsAnsi_ReturnsTrue_WhenAnsiPassThruSet_EvenWithNO_COLOR() ["NO_COLOR"] = "1" }) .Build(); - + // Act var env = new CliHostEnvironment(configuration, nonInteractive: false); - + // Assert Assert.True(env.SupportsAnsi); } From 5b74ea52822ea017593318faa0bc8f60274ddb54 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 08:36:59 +0800 Subject: [PATCH 150/256] Add FluentUI packages to dependency-update skill's special handling list (#14614) Co-authored-by: JamesNK <303201+JamesNK@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .github/skills/dependency-update/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/skills/dependency-update/SKILL.md b/.github/skills/dependency-update/SKILL.md index aa883249335..6b54b37b4ce 100644 --- a/.github/skills/dependency-update/SKILL.md +++ b/.github/skills/dependency-update/SKILL.md @@ -313,6 +313,7 @@ Some external dependencies have known constraints: - **`Humanizer.Core`** — Version 3.x ships a Roslyn analyzer that requires `System.Collections.Immutable` 9.0.0, which is incompatible with the .NET 8 SDK. Cannot update until the upstream issue is fixed ([Humanizr/Humanizer#1672](https://github.com/Humanizr/Humanizer/issues/1672)). - **`StreamJsonRpc`** — Version 2.24.x ships a Roslyn analyzer targeting Roslyn 4.14.0, incompatible with the .NET 8 SDK. Cannot update until the upstream issue is fixed ([microsoft/vs-streamjsonrpc#1399](https://github.com/microsoft/vs-streamjsonrpc/issues/1399)). - **`Azure.Monitor.OpenTelemetry.Exporter`** — Version 1.6.0 introduced AOT warnings. Hold at 1.5.0 until resolved. Version is in `eng/Versions.props`. +- **`Microsoft.FluentUI.AspNetCore.Components`** and **`Microsoft.FluentUI.AspNetCore.Components.Icons`** — Must not be automatically updated. Updates to these packages often have breaking changes and require careful manual testing of the dashboard. Always flag these for human review and manual update. ## Important Constraints From c53f1559674585f4522c6496e61b8340e53ed56e Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 24 Feb 2026 12:42:58 +1100 Subject: [PATCH 151/256] Update Hex1b.* packages from 0.90.0 to 0.97.0 (#14635) Co-authored-by: Mitch Denny Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4f04edc0fb1..29036ca1deb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -98,9 +98,9 @@ - - - + + + From e2051e9eba6d4af0ff7ff0d4c2a038cdf9ea4885 Mon Sep 17 00:00:00 2001 From: "dotnet-maestro[bot]" <42748379+dotnet-maestro[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:50:27 -0800 Subject: [PATCH 152/256] Update dependencies from https://github.com/microsoft/dcp build 0.22.7 (#14633) On relative base path root Microsoft.DeveloperControlPlane.darwin-amd64 , Microsoft.DeveloperControlPlane.darwin-arm64 , Microsoft.DeveloperControlPlane.linux-amd64 , Microsoft.DeveloperControlPlane.linux-arm64 , Microsoft.DeveloperControlPlane.linux-musl-amd64 , Microsoft.DeveloperControlPlane.windows-amd64 , Microsoft.DeveloperControlPlane.windows-arm64 From Version 0.22.6 -> To Version 0.22.7 Co-authored-by: dotnet-maestro[bot] --- eng/Version.Details.xml | 28 ++++++++++++++-------------- eng/Versions.props | 14 +++++++------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 2939c20d53c..08c07891994 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,33 +1,33 @@ - + https://github.com/microsoft/dcp - 9585d3bbfad8a356770096fcda944349da4145f1 + e6844e7cee9edb9fbe3d2315a8817fd3b565979f - + https://github.com/microsoft/dcp - 9585d3bbfad8a356770096fcda944349da4145f1 + e6844e7cee9edb9fbe3d2315a8817fd3b565979f - + https://github.com/microsoft/dcp - 9585d3bbfad8a356770096fcda944349da4145f1 + e6844e7cee9edb9fbe3d2315a8817fd3b565979f - + https://github.com/microsoft/dcp - 9585d3bbfad8a356770096fcda944349da4145f1 + e6844e7cee9edb9fbe3d2315a8817fd3b565979f - + https://github.com/microsoft/dcp - 9585d3bbfad8a356770096fcda944349da4145f1 + e6844e7cee9edb9fbe3d2315a8817fd3b565979f - + https://github.com/microsoft/dcp - 9585d3bbfad8a356770096fcda944349da4145f1 + e6844e7cee9edb9fbe3d2315a8817fd3b565979f - + https://github.com/microsoft/dcp - 9585d3bbfad8a356770096fcda944349da4145f1 + e6844e7cee9edb9fbe3d2315a8817fd3b565979f https://dev.azure.com/dnceng/internal/_git/dotnet-extensions diff --git a/eng/Versions.props b/eng/Versions.props index 3845d154491..8ecb3b7c5bb 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -30,13 +30,13 @@ 8.0.100-rtm.23512.16 - 0.22.6 - 0.22.6 - 0.22.6 - 0.22.6 - 0.22.6 - 0.22.6 - 0.22.6 + 0.22.7 + 0.22.7 + 0.22.7 + 0.22.7 + 0.22.7 + 0.22.7 + 0.22.7 10.0.0-beta.26119.2 10.0.0-beta.26119.2 From 1cd02253eeb8ee112a64efe576c9708c663f5193 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 24 Feb 2026 15:16:46 +1100 Subject: [PATCH 153/256] Redirect human-readable messages to stderr when --format json is used (#14572) * Redirect human-readable messages to stderr when --format json is used When running `aspire run --detach --format json`, messages like "Finding apphosts..." and "Stopping previous instance..." were written to stdout alongside the JSON output, making it impossible to parse the JSON programmatically. Add a `UseStderrForMessages` property to IInteractionService. When enabled, ConsoleInteractionService routes all human-readable display methods through stderr while keeping DisplayRawText on stdout for structured output. The property is set in ExecuteDetachedAsync when the output format is JSON. Fixes #14423 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR feedback: replace UseStderrForMessages with ConsoleOutput enum - Add ConsoleOutput enum (Standard, Error) for explicit console targeting - Replace DIM UseStderrForMessages with standard DefaultConsole property - Add optional ConsoleOutput parameter to DisplayRawText for explicit targeting - Update all IInteractionService implementations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use Throw for multiple apphosts when --format json; add E2E test When --format json is used with aspire run --detach, use MultipleAppHostProjectsFoundBehavior.Throw instead of Prompt to prevent interactive selection UI from polluting stdout JSON output. Users should specify --project explicitly in this case. Added MultipleAppHostTests E2E test that creates two single-file apphosts and verifies the selection prompt appears when multiple are found. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix MultipleAppHostTests: use real aspire new projects and assert on errors The previous test used stub single-file apphosts that couldn't resolve the Aspire.AppHost.Sdk, so the selection prompt appeared but the selected apphost failed to build. The test passed vacuously. Now creates two real projects via aspire new, verifies the selection prompt appears, selects one, and asserts the apphost actually starts. Adds explicit error assertions for SDK resolution and missing project failures to fail fast with descriptive messages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rewrite test: validate --detach --format json produces valid JSON Instead of testing multiple-apphost selection prompts, the test now validates the core PR concern: that aspire run --detach --format json produces well-formed JSON on stdout without human-readable message pollution. The test creates a single project, runs aspire run --detach first, then runs aspire run --detach --format json > output.json and validates the output file is parseable JSON with expected fields. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix detach test: wait for prompt, not nonexistent message The WaitUntil was looking for 'The apphost is running in the background.' which doesn't exist. The actual output is the AppHost summary table followed by the shell prompt. Use WaitForSuccessPrompt directly which correctly waits for the [N OK] $ prompt. 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/ConfigCommand.cs | 3 +- src/Aspire.Cli/Commands/DocsGetCommand.cs | 6 +- src/Aspire.Cli/Commands/DocsListCommand.cs | 3 +- src/Aspire.Cli/Commands/DocsSearchCommand.cs | 3 +- src/Aspire.Cli/Commands/DoctorCommand.cs | 3 +- .../Commands/ExtensionInternalCommand.cs | 3 +- src/Aspire.Cli/Commands/LogsCommand.cs | 6 +- src/Aspire.Cli/Commands/PsCommand.cs | 3 +- src/Aspire.Cli/Commands/ResourcesCommand.cs | 6 +- src/Aspire.Cli/Commands/RunCommand.cs | 18 ++- .../Commands/TelemetryLogsCommand.cs | 6 +- .../Commands/TelemetrySpansCommand.cs | 6 +- .../Commands/TelemetryTracesCommand.cs | 6 +- .../Interaction/ConsoleInteractionService.cs | 55 ++++--- src/Aspire.Cli/Interaction/ConsoleOutput.cs | 20 +++ .../ExtensionInteractionService.cs | 10 +- .../Interaction/IInteractionService.cs | 9 +- .../MultipleAppHostTests.cs | 141 ++++++++++++++++++ .../Commands/NewCommandTests.cs | 4 +- ...PublishCommandPromptingIntegrationTests.cs | 5 +- .../Commands/UpdateCommandTests.cs | 8 +- .../Templating/DotNetTemplateFactoryTests.cs | 4 +- .../TestConsoleInteractionService.cs | 3 +- .../TestExtensionInteractionService.cs | 3 +- 24 files changed, 282 insertions(+), 52 deletions(-) create mode 100644 src/Aspire.Cli/Interaction/ConsoleOutput.cs create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs diff --git a/src/Aspire.Cli/Commands/ConfigCommand.cs b/src/Aspire.Cli/Commands/ConfigCommand.cs index a02bccabb5b..7a927e6b365 100644 --- a/src/Aspire.Cli/Commands/ConfigCommand.cs +++ b/src/Aspire.Cli/Commands/ConfigCommand.cs @@ -429,7 +429,8 @@ private Task ExecuteAsync(bool useJson) // Use DisplayRawText to avoid Spectre.Console word wrapping which breaks JSON strings if (InteractionService is ConsoleInteractionService consoleService) { - consoleService.DisplayRawText(json); + // Structured output always goes to stdout. + consoleService.DisplayRawText(json, ConsoleOutput.Standard); } else { diff --git a/src/Aspire.Cli/Commands/DocsGetCommand.cs b/src/Aspire.Cli/Commands/DocsGetCommand.cs index 9924547c7ce..e90365d56b1 100644 --- a/src/Aspire.Cli/Commands/DocsGetCommand.cs +++ b/src/Aspire.Cli/Commands/DocsGetCommand.cs @@ -81,13 +81,15 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (format is OutputFormat.Json) { var json = JsonSerializer.Serialize(doc, JsonSourceGenerationContext.RelaxedEscaping.DocsContent); - InteractionService.DisplayRawText(json); + // Structured output always goes to stdout. + InteractionService.DisplayRawText(json, ConsoleOutput.Standard); } else { // Format the markdown for better terminal readability var formatted = FormatMarkdownForTerminal(doc.Content); - InteractionService.DisplayRawText(formatted); + // Structured output always goes to stdout. + InteractionService.DisplayRawText(formatted, ConsoleOutput.Standard); } return ExitCodeConstants.Success; diff --git a/src/Aspire.Cli/Commands/DocsListCommand.cs b/src/Aspire.Cli/Commands/DocsListCommand.cs index 896b38d1c34..35d71f38bff 100644 --- a/src/Aspire.Cli/Commands/DocsListCommand.cs +++ b/src/Aspire.Cli/Commands/DocsListCommand.cs @@ -68,7 +68,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (format is OutputFormat.Json) { var json = JsonSerializer.Serialize(docs.ToArray(), JsonSourceGenerationContext.RelaxedEscaping.DocsListItemArray); - InteractionService.DisplayRawText(json); + // Structured output always goes to stdout. + InteractionService.DisplayRawText(json, ConsoleOutput.Standard); } else { diff --git a/src/Aspire.Cli/Commands/DocsSearchCommand.cs b/src/Aspire.Cli/Commands/DocsSearchCommand.cs index 338fbd30476..49a26a0d140 100644 --- a/src/Aspire.Cli/Commands/DocsSearchCommand.cs +++ b/src/Aspire.Cli/Commands/DocsSearchCommand.cs @@ -82,7 +82,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (format is OutputFormat.Json) { var json = JsonSerializer.Serialize(response.Results.ToArray(), JsonSourceGenerationContext.RelaxedEscaping.SearchResultArray); - InteractionService.DisplayRawText(json); + // Structured output always goes to stdout. + InteractionService.DisplayRawText(json, ConsoleOutput.Standard); } else { diff --git a/src/Aspire.Cli/Commands/DoctorCommand.cs b/src/Aspire.Cli/Commands/DoctorCommand.cs index f7290678661..16b0393dee7 100644 --- a/src/Aspire.Cli/Commands/DoctorCommand.cs +++ b/src/Aspire.Cli/Commands/DoctorCommand.cs @@ -83,7 +83,8 @@ private void OutputJson(IReadOnlyList results) var json = System.Text.Json.JsonSerializer.Serialize(response, JsonSourceGenerationContext.RelaxedEscaping.DoctorCheckResponse); // Use DisplayRawText to write directly to console without any formatting - InteractionService.DisplayRawText(json); + // Structured output always goes to stdout. + InteractionService.DisplayRawText(json, ConsoleOutput.Standard); } private void OutputHumanReadable(IReadOnlyList results) diff --git a/src/Aspire.Cli/Commands/ExtensionInternalCommand.cs b/src/Aspire.Cli/Commands/ExtensionInternalCommand.cs index 9bbcd19c59e..a157ee1295e 100644 --- a/src/Aspire.Cli/Commands/ExtensionInternalCommand.cs +++ b/src/Aspire.Cli/Commands/ExtensionInternalCommand.cs @@ -48,7 +48,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell SelectedProjectFile = result.SelectedProjectFile?.FullName, AllProjectFileCandidates = result.AllProjectFileCandidates.Select(f => f.FullName).ToList() }, BackchannelJsonSerializerContext.Default.AppHostProjectSearchResultPoco); - InteractionService.DisplayRawText(json); + // Structured output always goes to stdout. + InteractionService.DisplayRawText(json, ConsoleOutput.Standard); return ExitCodeConstants.Success; } catch diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs index e4dd45dca33..0a3b6e030d8 100644 --- a/src/Aspire.Cli/Commands/LogsCommand.cs +++ b/src/Aspire.Cli/Commands/LogsCommand.cs @@ -220,7 +220,8 @@ private async Task ExecuteGetAsync( }).ToArray() }; var json = JsonSerializer.Serialize(logsOutput, LogsCommandJsonContext.Snapshot.LogsOutput); - _interactionService.DisplayRawText(json); + // Structured output always goes to stdout. + _interactionService.DisplayRawText(json, ConsoleOutput.Standard); } else { @@ -327,7 +328,8 @@ private void OutputLogLine(ResourceLogLine logLine, OutputFormat format) IsError = logLine.IsError }; var output = JsonSerializer.Serialize(logLineJson, LogsCommandJsonContext.Ndjson.LogLineJson); - _interactionService.DisplayRawText(output); + // Structured output always goes to stdout. + _interactionService.DisplayRawText(output, ConsoleOutput.Standard); } else if (_hostEnvironment.SupportsAnsi) { diff --git a/src/Aspire.Cli/Commands/PsCommand.cs b/src/Aspire.Cli/Commands/PsCommand.cs index 51538606230..a4375a239dd 100644 --- a/src/Aspire.Cli/Commands/PsCommand.cs +++ b/src/Aspire.Cli/Commands/PsCommand.cs @@ -118,7 +118,8 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell if (format == OutputFormat.Json) { var json = JsonSerializer.Serialize(appHostInfos, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo); - _interactionService.DisplayRawText(json); + // Structured output always goes to stdout. + _interactionService.DisplayRawText(json, ConsoleOutput.Standard); } else { diff --git a/src/Aspire.Cli/Commands/ResourcesCommand.cs b/src/Aspire.Cli/Commands/ResourcesCommand.cs index 96de50e814f..f1ce701b32f 100644 --- a/src/Aspire.Cli/Commands/ResourcesCommand.cs +++ b/src/Aspire.Cli/Commands/ResourcesCommand.cs @@ -175,7 +175,8 @@ private async Task ExecuteSnapshotAsync(IAppHostAuxiliaryBackchannel connec { var output = new ResourcesOutput { Resources = resourceList.ToArray() }; var json = JsonSerializer.Serialize(output, ResourcesCommandJsonContext.RelaxedEscaping.ResourcesOutput); - _interactionService.DisplayRawText(json); + // Structured output always goes to stdout. + _interactionService.DisplayRawText(json, ConsoleOutput.Standard); } else { @@ -212,7 +213,8 @@ private async Task ExecuteWatchAsync(IAppHostAuxiliaryBackchannel connectio { // NDJSON output - compact, one object per line for streaming var json = JsonSerializer.Serialize(resourceJson, ResourcesCommandJsonContext.Ndjson.ResourceJson); - _interactionService.DisplayRawText(json); + // Structured output always goes to stdout. + _interactionService.DisplayRawText(json, ConsoleOutput.Standard); } else { diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index cb752d79f70..2193657b9e1 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -628,10 +628,23 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? { var format = parseResult.GetValue(s_formatOption); + // When outputting JSON, write all console to stderr by default. + // Only content explicitly sent to stdout (JSON results) appears on stdout. + if (format == OutputFormat.Json) + { + _interactionService.Console = ConsoleOutput.Error; + } + // Failure mode 1: Project not found + // When outputting JSON, use Throw instead of Prompt to avoid polluting stdout + // with interactive selection UI. The user should specify --project explicitly. + var multipleAppHostBehavior = format == OutputFormat.Json + ? MultipleAppHostProjectsFoundBehavior.Throw + : MultipleAppHostProjectsFoundBehavior.Prompt; + var searchResult = await _projectLocator.UseOrFindAppHostProjectFileAsync( passedAppHostProjectFile, - MultipleAppHostProjectsFoundBehavior.Prompt, + multipleAppHostBehavior, createSettingsFile: false, cancellationToken); @@ -872,7 +885,8 @@ private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? dashboardUrls?.BaseUrlWithLoginToken, childLogFile); var json = JsonSerializer.Serialize(result, RunCommandJsonContext.RelaxedEscaping.DetachOutputInfo); - _interactionService.DisplayRawText(json); + // Structured output always goes to stdout. + _interactionService.DisplayRawText(json, ConsoleOutput.Standard); } else { diff --git a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs index 5312b6be831..25620ada75c 100644 --- a/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryLogsCommand.cs @@ -171,7 +171,8 @@ private async Task GetLogsSnapshotAsync(HttpClient client, string url, Outp if (format == OutputFormat.Json) { - _interactionService.DisplayRawText(json); + // Structured output always goes to stdout. + _interactionService.DisplayRawText(json, ConsoleOutput.Standard); } else { @@ -199,7 +200,8 @@ private async Task StreamLogsAsync(HttpClient client, string url, OutputFor { if (format == OutputFormat.Json) { - _interactionService.DisplayRawText(line); + // Structured output always goes to stdout. + _interactionService.DisplayRawText(line, ConsoleOutput.Standard); } else { diff --git a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs index ba29aa1f7fd..488a63d1545 100644 --- a/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetrySpansCommand.cs @@ -172,7 +172,8 @@ private async Task GetSpansSnapshotAsync(HttpClient client, string url, Out if (format == OutputFormat.Json) { - _interactionService.DisplayRawText(json); + // Structured output always goes to stdout. + _interactionService.DisplayRawText(json, ConsoleOutput.Standard); } else { @@ -201,7 +202,8 @@ private async Task StreamSpansAsync(HttpClient client, string url, OutputFo { if (format == OutputFormat.Json) { - _interactionService.DisplayRawText(line); + // Structured output always goes to stdout. + _interactionService.DisplayRawText(line, ConsoleOutput.Standard); } else { diff --git a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs index 442d664a383..1cd8eea865b 100644 --- a/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryTracesCommand.cs @@ -131,7 +131,8 @@ private async Task FetchSingleTraceAsync( if (format == OutputFormat.Json) { - _interactionService.DisplayRawText(json); + // Structured output always goes to stdout. + _interactionService.DisplayRawText(json, ConsoleOutput.Standard); } else { @@ -199,7 +200,8 @@ private async Task FetchTracesAsync( if (format == OutputFormat.Json) { - _interactionService.DisplayRawText(json); + // Structured output always goes to stdout. + _interactionService.DisplayRawText(json, ConsoleOutput.Standard); } else { diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index a653548422b..8f4f22c464a 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -23,6 +23,13 @@ internal class ConsoleInteractionService : IInteractionService private readonly ICliHostEnvironment _hostEnvironment; private int _inStatus; + /// + /// Console used for human-readable messages; routes to stderr when is set to . + /// + private IAnsiConsole MessageConsole => Console == ConsoleOutput.Error ? _errorConsole : _outConsole; + + public ConsoleOutput Console { get; set; } + public ConsoleInteractionService(ConsoleEnvironment consoleEnvironment, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment) { ArgumentNullException.ThrowIfNull(consoleEnvironment); @@ -55,7 +62,7 @@ public async Task ShowStatusAsync(string statusText, Func> action) try { - return await _outConsole.Status() + return await MessageConsole.Status() .Spinner(Spinner.Known.Dots3) .StartAsync(statusText, (context) => action()); } @@ -86,7 +93,7 @@ public void ShowStatus(string statusText, Action action) try { - _outConsole.Status() + MessageConsole.Status() .Spinner(Spinner.Known.Dots3) .Start(statusText, (context) => action()); } @@ -187,12 +194,12 @@ public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, stri var cliInformationalVersion = VersionHelper.GetDefaultTemplateVersion(); DisplayError(InteractionServiceStrings.AppHostNotCompatibleConsiderUpgrading); - Console.WriteLine(); - _outConsole.MarkupLine( + MessageConsole.WriteLine(); + MessageConsole.MarkupLine( $"\t[bold]{InteractionServiceStrings.AspireHostingSDKVersion}[/]: {appHostHostingVersion.EscapeMarkup()}"); - _outConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.AspireCLIVersion}[/]: {cliInformationalVersion.EscapeMarkup()}"); - _outConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.RequiredCapability}[/]: {ex.RequiredCapability.EscapeMarkup()}"); - Console.WriteLine(); + MessageConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.AspireCLIVersion}[/]: {cliInformationalVersion.EscapeMarkup()}"); + MessageConsole.MarkupLine($"\t[bold]{InteractionServiceStrings.RequiredCapability}[/]: {ex.RequiredCapability.EscapeMarkup()}"); + MessageConsole.WriteLine(); return ExitCodeConstants.AppHostIncompatible; } @@ -206,32 +213,36 @@ public void DisplayMessage(string emoji, string message) // This is a hack to deal with emoji of different size. We write the emoji then move the cursor to aboslute column 4 // on the same line before writing the message. This ensures that the message starts at the same position regardless // of the emoji used. I'm not OCD .. you are! - _outConsole.Markup($":{emoji}:"); - _outConsole.Write("\u001b[4G"); - _outConsole.MarkupLine(message); + var console = MessageConsole; + console.Markup($":{emoji}:"); + console.Write("\u001b[4G"); + console.MarkupLine(message); } public void DisplayPlainText(string message) { // Write directly to avoid Spectre.Console line wrapping - _outConsole.Profile.Out.Writer.WriteLine(message); + MessageConsole.Profile.Out.Writer.WriteLine(message); } - public void DisplayRawText(string text) + public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { - // Write raw text directly to avoid console wrapping - _outConsole.Profile.Out.Writer.WriteLine(text); + // Write raw text directly to avoid console wrapping. + // When consoleOverride is null, respect the Console setting. + var effectiveConsole = consoleOverride ?? Console; + var target = effectiveConsole == ConsoleOutput.Error ? _errorConsole : _outConsole; + target.Profile.Out.Writer.WriteLine(text); } public void DisplayMarkdown(string markdown) { var spectreMarkup = MarkdownToSpectreConverter.ConvertToSpectre(markdown); - _outConsole.MarkupLine(spectreMarkup); + MessageConsole.MarkupLine(spectreMarkup); } public void DisplayMarkupLine(string markup) { - _outConsole.MarkupLine(markup); + MessageConsole.MarkupLine(markup); } public void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false) @@ -247,7 +258,7 @@ public void WriteConsoleLog(string message, int? lineNumber = null, string? type }; var prefix = lineNumber.HasValue ? $"#{lineNumber.Value}: " : ""; - _outConsole.WriteLine($"{prefix}{message}", style); + MessageConsole.WriteLine($"{prefix}{message}", style); } public void DisplaySuccess(string message) @@ -261,18 +272,18 @@ public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { if (stream == "stdout") { - _outConsole.MarkupLineInterpolated($"{line.EscapeMarkup()}"); + MessageConsole.MarkupLineInterpolated($"{line.EscapeMarkup()}"); } else { - _outConsole.MarkupLineInterpolated($"[red]{line.EscapeMarkup()}[/]"); + MessageConsole.MarkupLineInterpolated($"[red]{line.EscapeMarkup()}[/]"); } } } public void DisplayCancellationMessage() { - _outConsole.WriteLine(); + MessageConsole.WriteLine(); DisplayMessage("stop_sign", $"[teal bold]{InteractionServiceStrings.StoppingAspire}[/]"); } @@ -289,12 +300,12 @@ public Task ConfirmAsync(string promptText, bool defaultValue = true, Canc public void DisplaySubtleMessage(string message, bool escapeMarkup = true) { var displayMessage = escapeMarkup ? message.EscapeMarkup() : message; - _outConsole.MarkupLine($"[dim]{displayMessage}[/]"); + MessageConsole.MarkupLine($"[dim]{displayMessage}[/]"); } public void DisplayEmptyLine() { - _outConsole.WriteLine(); + MessageConsole.WriteLine(); } private const string UpdateUrl = "https://aka.ms/aspire/update"; diff --git a/src/Aspire.Cli/Interaction/ConsoleOutput.cs b/src/Aspire.Cli/Interaction/ConsoleOutput.cs new file mode 100644 index 00000000000..fcafc5c4793 --- /dev/null +++ b/src/Aspire.Cli/Interaction/ConsoleOutput.cs @@ -0,0 +1,20 @@ +// 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.Interaction; + +/// +/// Specifies which console output stream to use. +/// +internal enum ConsoleOutput +{ + /// + /// Standard output (stdout). + /// + Standard, + + /// + /// Standard error (stderr). + /// + Error +} diff --git a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs index 82e2265f62e..80113c16829 100644 --- a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs @@ -299,11 +299,17 @@ public void DisplayPlainText(string text) _consoleInteractionService.DisplayPlainText(text); } - public void DisplayRawText(string text) + public ConsoleOutput Console + { + get => _consoleInteractionService.Console; + set => _consoleInteractionService.Console = value; + } + + public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayPlainTextAsync(text, _cancellationToken)); Debug.Assert(result); - _consoleInteractionService.DisplayRawText(text); + _consoleInteractionService.DisplayRawText(text, consoleOverride); } public void DisplayMarkdown(string markdown) diff --git a/src/Aspire.Cli/Interaction/IInteractionService.cs b/src/Aspire.Cli/Interaction/IInteractionService.cs index 0d69084c299..01625eb0b93 100644 --- a/src/Aspire.Cli/Interaction/IInteractionService.cs +++ b/src/Aspire.Cli/Interaction/IInteractionService.cs @@ -18,7 +18,7 @@ internal interface IInteractionService void DisplayError(string errorMessage); void DisplayMessage(string emoji, string message); void DisplayPlainText(string text); - void DisplayRawText(string text); + void DisplayRawText(string text, ConsoleOutput? consoleOverride = null); void DisplayMarkdown(string markdown); void DisplayMarkupLine(string markup); void DisplaySuccess(string message); @@ -27,6 +27,13 @@ internal interface IInteractionService void DisplayCancellationMessage(); void DisplayEmptyLine(); + /// + /// Gets or sets the default console output stream for human-readable messages. + /// When set to , display methods route output to stderr + /// so that structured output (e.g., JSON) on stdout remains parseable. + /// + ConsoleOutput Console { get; set; } + void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null); void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs new file mode 100644 index 00000000000..a963b5291de --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs @@ -0,0 +1,141 @@ +// 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; +using Aspire.Cli.Tests.Utils; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// Tests that aspire run --detach --format json produces well-formed JSON +/// without human-readable messages polluting stdout. +/// +public sealed class MultipleAppHostTests(ITestOutputHelper output) +{ + [Fact] + public async Task DetachFormatJsonProducesValidJson() + { + var workspace = TemporaryWorkspace.Create(output); + + var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); + var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); + var isCI = CliE2ETestHelpers.IsRunningInCI; + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(DetachFormatJsonProducesValidJson)); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + // aspire new prompts + var waitingForTemplateSelectionPrompt = new CellPatternSearcher() + .FindPattern("> Starter App"); + + var waitingForProjectNamePrompt = new CellPatternSearcher() + .Find("Enter the project name ("); + + var waitingForOutputPathPrompt = new CellPatternSearcher() + .Find("Enter the output path:"); + + var waitingForUrlsPrompt = new CellPatternSearcher() + .Find("Use *.dev.localhost URLs"); + + var waitingForRedisPrompt = new CellPatternSearcher() + .Find("Use Redis Cache"); + + var waitingForTestPrompt = new CellPatternSearcher() + .Find("Do you want to create a test project?"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + sequenceBuilder.PrepareEnvironment(workspace, counter); + + if (isCI) + { + sequenceBuilder.InstallAspireCliFromPullRequest(prNumber, counter); + sequenceBuilder.SourceAspireCliEnvironment(counter); + sequenceBuilder.VerifyAspireCliVersion(commitSha, counter); + } + + // Create a single project using aspire new + sequenceBuilder.Type("aspire new") + .Enter() + .WaitUntil(s => waitingForTemplateSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(30)) + .Enter() // select Starter App + .WaitUntil(s => waitingForProjectNamePrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Type("TestApp") + .Enter() + .WaitUntil(s => waitingForOutputPathPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForUrlsPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForRedisPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitUntil(s => waitingForTestPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(10)) + .Enter() + .WaitForSuccessPrompt(counter); + + sequenceBuilder.ClearScreen(counter); + + // Navigate into the project directory + sequenceBuilder + .Type("cd TestApp") + .Enter() + .WaitForSuccessPrompt(counter); + + // First: launch the apphost with --detach (interactive, no JSON) + // Just wait for the command to complete (WaitForSuccessPrompt waits for the shell prompt) + sequenceBuilder + .Type("aspire run --detach") + .Enter() + .WaitForSuccessPrompt(counter); + + sequenceBuilder.ClearScreen(counter); + + // Second: launch again with --detach --format json, redirecting stdout to a file. + // This tests that the JSON output is well-formed and not polluted by human-readable messages. + // stderr is left visible in the terminal for debugging (human-readable messages go to stderr + // when --format json is used, which is exactly what this PR validates). + sequenceBuilder + .Type("aspire run --detach --format json > output.json") + .Enter() + .WaitForSuccessPrompt(counter); + + sequenceBuilder.ClearScreen(counter); + + // Validate the JSON output file is well-formed by using python to parse it + sequenceBuilder + .Type("python3 -c \"import json; data = json.load(open('output.json')); print('JSON_VALID'); print('appHostPath' in data); print('appHostPid' in data)\"") + .Enter() + .WaitForSuccessPrompt(counter); + + // Also cat the file so we can see it in the recording + sequenceBuilder + .Type("cat output.json") + .Enter() + .WaitForSuccessPrompt(counter); + + // Clean up: stop any running instances + sequenceBuilder + .Type("aspire stop --all 2>/dev/null || true") + .Enter() + .WaitForSuccessPrompt(counter) + .Type("exit") + .Enter(); + + var sequence = sequenceBuilder.Build(); + + await sequence.ApplyAsync(terminal, TestContext.Current.CancellationToken); + + await pendingRun; + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index adb1530ad89..787c6363e4f 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -896,6 +896,8 @@ public override Task PromptForOutputPath(string path, CancellationToken internal sealed class OrderTrackingInteractionService(List operationOrder) : IInteractionService { + public ConsoleOutput Console { get; set; } + public Task ShowStatusAsync(string statusText, Func> action) { return action(); @@ -950,7 +952,7 @@ public void DisplayCancellationMessage() { } public void DisplaySubtleMessage(string message, bool escapeMarkup = true) { } public void DisplayEmptyLine() { } public void DisplayPlainText(string text) { } - public void DisplayRawText(string text) { } + public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } public void DisplayMarkdown(string markdown) { } public void DisplayMarkupLine(string markup) { } public void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false) { } diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 9931df588db..3fbcd729d64 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -850,6 +850,7 @@ internal sealed class TestConsoleInteractionServiceWithPromptTracking : IInterac private readonly Queue<(string response, ResponseType type)> _responses = new(); private bool _shouldCancel; + public ConsoleOutput Console { get; set; } public List StringPromptCalls { get; } = []; public List SelectionPromptCalls { get; } = []; // Using object to handle generic types public List BooleanPromptCalls { get; } = []; @@ -946,7 +947,7 @@ public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { } public void DisplayCancellationMessage() { } public void DisplayEmptyLine() { } public void DisplayPlainText(string text) { } - public void DisplayRawText(string text) { } + public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } public void DisplayMarkdown(string markdown) { } public void DisplayMarkupLine(string markup) { } @@ -955,7 +956,7 @@ public void DisplayVersionUpdateNotification(string newerVersion, string? update public void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false) { var messageType = isErrorMessage ? "error" : "info"; - Console.WriteLine($"#{lineNumber} [{messageType}] {message}"); + System.Console.WriteLine($"#{lineNumber} [{messageType}] {message}"); } } diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 165e9544d47..bc0b0d052f6 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -940,6 +940,12 @@ internal sealed class CancellationTrackingInteractionService : IInteractionServi { private readonly IInteractionService _innerService; + public ConsoleOutput Console + { + get => _innerService.Console; + set => _innerService.Console = value; + } + public Action? OnCancellationMessageDisplayed { get; set; } public CancellationTrackingInteractionService(IInteractionService innerService) @@ -962,7 +968,7 @@ public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, stri public void DisplayError(string errorMessage) => _innerService.DisplayError(errorMessage); public void DisplayMessage(string emoji, string message) => _innerService.DisplayMessage(emoji, message); public void DisplayPlainText(string text) => _innerService.DisplayPlainText(text); - public void DisplayRawText(string text) => _innerService.DisplayRawText(text); + public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) => _innerService.DisplayRawText(text, consoleOverride); public void DisplayMarkdown(string markdown) => _innerService.DisplayMarkdown(markdown); public void DisplayMarkupLine(string markup) => _innerService.DisplayMarkupLine(markup); public void DisplaySuccess(string message) => _innerService.DisplaySuccess(message); diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index bbd51fec810..f82a23a06ce 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -409,6 +409,8 @@ public bool IsFeatureEnabled(string featureFlag, bool defaultValue) private sealed class TestInteractionService : IInteractionService { + public ConsoleOutput Console { get; set; } + public Task PromptForSelectionAsync(string prompt, IEnumerable choices, Func displaySelector, CancellationToken cancellationToken) where T : notnull => throw new NotImplementedException(); @@ -437,7 +439,7 @@ public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { } public void DisplayCancellationMessage() { } public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => 0; public void DisplayPlainText(string text) { } - public void DisplayRawText(string text) { } + public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } public void DisplayMarkdown(string markdown) { } public void DisplayMarkupLine(string markup) { } public void DisplaySubtleMessage(string message, bool escapeMarkup = true) { } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs index d161927e1ec..fe77711c78f 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs @@ -10,6 +10,7 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class TestConsoleInteractionService : IInteractionService { + public ConsoleOutput Console { get; set; } public Action? DisplayErrorCallback { get; set; } public Action? DisplaySubtleMessageCallback { get; set; } public Action? DisplayConsoleWriteLineMessage { get; set; } @@ -113,7 +114,7 @@ public void DisplayPlainText(string text) { } - public void DisplayRawText(string text) + public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs index e52688de100..10ba19a3215 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs @@ -11,6 +11,7 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class TestExtensionInteractionService(IServiceProvider serviceProvider) : IExtensionInteractionService { + public ConsoleOutput Console { get; set; } public Action? DisplayErrorCallback { get; set; } public Action? DisplaySubtleMessageCallback { get; set; } public Action? DisplayConsoleWriteLineMessage { get; set; } @@ -126,7 +127,7 @@ public void DisplayPlainText(string text) { } - public void DisplayRawText(string text) + public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) { } From 08b1c8819ef61e32abb6c7890bf495208165ceaf Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 24 Feb 2026 16:00:13 +1100 Subject: [PATCH 154/256] Fix deployment E2E test for WithCompactResourceNaming experimental attribute (#14630) Add #pragma warning disable ASPIREACANAMING001 to the generated apphost.cs in AcaCompactNamingDeploymentTests so the test compiles after the [Experimental] attribute was added to WithCompactResourceNaming(). Co-authored-by: Mitch Denny Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AcaCompactNamingDeploymentTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs index b8945597d64..058d1be7673 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs @@ -141,6 +141,10 @@ private async Task DeployWithCompactNamingFixesStorageCollisionCore(Cancellation """; content = content.Replace(buildRunPattern, replacement); + + // Suppress experimental diagnostic for WithCompactResourceNaming + content = "#pragma warning disable ASPIREACANAMING001\n" + content; + File.WriteAllText(appHostFilePath, content); output.WriteLine($"Modified apphost.cs with long env name + compact naming + volume"); From 08e7ce39ad1786afc832c47156a7be37e23adec3 Mon Sep 17 00:00:00 2001 From: David Negstad <50252651+danegsta@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:40:47 -0800 Subject: [PATCH 155/256] Use scheme instead of endpoint name when registering service discovery environment variables (#14626) * Use scheme instead of endpoint name when registering service discovery environment variables * Respond to PR comments * Simplify OTLP extraction to avoid service discovery lookup --- .../DevTunnelResourceBuilderExtensions.cs | 23 ++++++++++- src/Aspire.Hosting.Maui/MauiOtlpExtensions.cs | 30 ++++----------- .../ReferenceEnvironmentInjectionFlags.cs | 2 +- .../ResourceBuilderExtensions.cs | 28 ++++++++++++-- ...rComposeWithProjectResources.verified.yaml | 10 ++--- ...netesWithProjectResources#01.verified.yaml | 10 ++--- ...netesWithProjectResources#06.verified.yaml | 10 ++--- .../MauiPlatformExtensionsTests.cs | 12 +++--- .../WithReferenceTests.cs | 38 +++++++++---------- 9 files changed, 92 insertions(+), 71 deletions(-) diff --git a/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs b/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs index 4bd09c4fbb6..06931ab9b8c 100644 --- a/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.DevTunnels/DevTunnelResourceBuilderExtensions.cs @@ -414,7 +414,7 @@ private static EndpointReference CreateEndpointReferenceWithError(DevTunnelResou /// /// Injects service discovery and endpoint information as environment variables from the dev tunnel resource into the destination resource, using the tunneled resource's name as the service name. /// Each endpoint defined on the target resource will be injected using the format defined by the on the destination resource, i.e. - /// either "services__{sourceResourceName}__{endpointName}__{endpointIndex}={uriString}" for .NET service discovery, or "{RESOURCE_ENDPOINT}={uri}" for endpoint injection. + /// either "services__{sourceResourceName}__{endpointScheme}__{endpointIndex}={uriString}" for .NET service discovery, or "{RESOURCE_ENDPOINT}={uri}" for endpoint injection. /// /// /// Referencing a dev tunnel will delay the start of the resource until the referenced dev tunnel's endpoint is allocated. @@ -446,6 +446,8 @@ public static IResourceBuilder WithReference(this IResourc var flags = injectionAnnotation?.Flags ?? ReferenceEnvironmentInjectionFlags.All; // Add environment variables for each tunnel port that references an endpoint on the target resource + var schemeIndexTracker = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var port in tunnelResource.Resource.Ports.Where(p => p.TargetEndpoint.Resource == targetResource.Resource)) { var serviceName = targetResource.Resource.Name; @@ -455,7 +457,24 @@ public static IResourceBuilder WithReference(this IResourc if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.ServiceDiscovery)) { - context.EnvironmentVariables[$"services__{serviceName}__{endpointName}__0"] = port.TunnelEndpoint; + // Use the endpoint's scheme (not name) in the service discovery key so that + // .NET service discovery can correctly match the scheme segment to the URI scheme. + var scheme = port.TargetEndpoint.Scheme; + if (!schemeIndexTracker.TryGetValue(scheme, out var index)) + { + index = 0; + } + + // Find the next unused index for this scheme in case of collisions with other callbacks. + var key = $"services__{serviceName}__{scheme}__{index}"; + while (context.EnvironmentVariables.ContainsKey(key)) + { + index++; + key = $"services__{serviceName}__{scheme}__{index}"; + } + + context.EnvironmentVariables[key] = port.TunnelEndpoint; + schemeIndexTracker[scheme] = index + 1; } if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.Endpoints)) diff --git a/src/Aspire.Hosting.Maui/MauiOtlpExtensions.cs b/src/Aspire.Hosting.Maui/MauiOtlpExtensions.cs index a7af3faf05e..9ad48dd919d 100644 --- a/src/Aspire.Hosting.Maui/MauiOtlpExtensions.cs +++ b/src/Aspire.Hosting.Maui/MauiOtlpExtensions.cs @@ -131,36 +131,20 @@ private static OtlpDevTunnelConfigurationAnnotation CreateOtlpDevTunnelInfrastru /// /// Applies OTLP configuration to a specific MAUI platform resource. - /// Uses service discovery through WithReference to get the tunneled endpoint, then overrides OTEL_EXPORTER_OTLP_ENDPOINT. + /// Gets the tunneled endpoint directly and sets OTEL_EXPORTER_OTLP_ENDPOINT. /// private static void ApplyOtlpConfigurationToPlatform( IResourceBuilder platformBuilder, OtlpDevTunnelConfigurationAnnotation tunnelConfig) where T : IMauiPlatformResource, IResourceWithEnvironment { - // Use WithReference to inject service discovery variables for the stub through the dev tunnel - // This adds SERVICES____OTLP__0=https://tunnel-url which we'll use and then clean up - platformBuilder.WithReference(tunnelConfig.OtlpStubBuilder, tunnelConfig.DevTunnel); + // Get the tunnel endpoint for the OTLP stub directly, bypassing service discovery injection + var tunnelEndpoint = tunnelConfig.DevTunnel.GetEndpoint(tunnelConfig.OtlpStub, "otlp"); - // Override OTEL_EXPORTER_OTLP_ENDPOINT with the tunneled URL and clean up extra variables - platformBuilder.WithEnvironment(context => - { - // Read the service discovery variable that WithReference just added - // Format: services__{resourcename}__otlp__0 (lowercase) - var serviceDiscoveryKey = $"services__{tunnelConfig.OtlpStub.Name}__otlp__0"; - if (context.EnvironmentVariables.TryGetValue(serviceDiscoveryKey, out var tunnelUrl)) - { - // Override OTEL_EXPORTER_OTLP_ENDPOINT with the tunnel URL - context.EnvironmentVariables["OTEL_EXPORTER_OTLP_ENDPOINT"] = tunnelUrl; - - // Remove the service discovery variables since we're using direct OTLP configuration - context.EnvironmentVariables.Remove(serviceDiscoveryKey); + // Ensure the platform resource waits for the tunnel to be ready + platformBuilder.WithReferenceRelationship(tunnelConfig.DevTunnel); - // Also remove the {RESOURCENAME}_{ENDPOINTNAME} format variable (e.g., MAUI_APP-OTLP_OTLP) - // The resource name is encoded and uppercased when DevTunnelsResourceBuilderExtensions.WithReference is invoked - var directEndpointKey = $"{EnvironmentVariableNameEncoder.Encode(tunnelConfig.OtlpStub.Name).ToUpperInvariant()}_OTLP"; - context.EnvironmentVariables.Remove(directEndpointKey); - } - }); + // Set OTEL_EXPORTER_OTLP_ENDPOINT directly to the tunnel endpoint URL + platformBuilder.WithEnvironment("OTEL_EXPORTER_OTLP_ENDPOINT", tunnelEndpoint); } } diff --git a/src/Aspire.Hosting/ApplicationModel/ReferenceEnvironmentInjectionFlags.cs b/src/Aspire.Hosting/ApplicationModel/ReferenceEnvironmentInjectionFlags.cs index d25d11b21ec..9d528bf101c 100644 --- a/src/Aspire.Hosting/ApplicationModel/ReferenceEnvironmentInjectionFlags.cs +++ b/src/Aspire.Hosting/ApplicationModel/ReferenceEnvironmentInjectionFlags.cs @@ -25,7 +25,7 @@ public enum ReferenceEnvironmentInjectionFlags ConnectionProperties = 1 << 1, /// - /// Each endpoint defined on the resource will be injected using the format "services__{resourceName}__{endpointName}__{endpointIndex}". + /// Each endpoint defined on the resource will be injected using the format "services__{resourceName}__{endpointScheme}__{endpointIndex}". /// ServiceDiscovery = 1 << 2, diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index 3695ec12ca9..6a20b833732 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -457,6 +457,9 @@ private static Action CreateEndpointReferenceEnviron context.Resource.TryGetLastAnnotation(out var injectionAnnotation); var flags = injectionAnnotation?.Flags ?? ReferenceEnvironmentInjectionFlags.All; + // 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)) { if (specificEndpointName != null && !string.Equals(endpoint.EndpointName, specificEndpointName, StringComparison.OrdinalIgnoreCase)) @@ -483,7 +486,24 @@ private static Action CreateEndpointReferenceEnviron if (flags.HasFlag(ReferenceEnvironmentInjectionFlags.ServiceDiscovery)) { - context.EnvironmentVariables[$"services__{serviceName}__{endpointName}__0"] = endpoint; + // Use the endpoint's scheme (not name) in the service discovery key so that + // .NET service discovery can correctly match the scheme segment to the URI scheme. + var scheme = endpoint.Scheme; + if (!schemeIndexTracker.TryGetValue(scheme, out var index)) + { + index = 0; + } + + // Find the next unused index for this scheme in case of collisions with other callbacks. + var key = $"services__{serviceName}__{scheme}__{index}"; + while (context.EnvironmentVariables.ContainsKey(key)) + { + index++; + key = $"services__{serviceName}__{scheme}__{index}"; + } + + context.EnvironmentVariables[key] = endpoint; + schemeIndexTracker[scheme] = index + 1; } } }; @@ -602,7 +622,7 @@ public static ReferenceExpression GetConnectionProperty(this IResourceWithConnec /// /// Injects service discovery and endpoint information as environment variables from the project resource into the destination resource, using the source resource's name as the service name. /// Each endpoint defined on the project resource will be injected using the format defined by the on the destination resource, i.e. - /// either "services__{sourceResourceName}__{endpointName}__{endpointIndex}={uriString}" for .NET service discovery, or "{RESOURCE_ENDPOINT}={uri}" for endpoint injection. + /// either "services__{sourceResourceName}__{endpointScheme}__{endpointIndex}={uriString}" for .NET service discovery, or "{RESOURCE_ENDPOINT}={uri}" for endpoint injection. /// /// The destination resource. /// The resource where the service discovery information will be injected. @@ -622,7 +642,7 @@ public static IResourceBuilder WithReference(this IR /// /// Injects service discovery and endpoint information as environment variables from the project resource into the destination resource, using the source resource's name as the service name. /// Each endpoint defined on the project resource will be injected using the format defined by the on the destination resource, i.e. - /// either "services__{name}__{endpointName}__{endpointIndex}={uriString}" for .NET service discovery, or "{name}_{ENDPOINT}={uri}" for endpoint injection. + /// either "services__{name}__{endpointScheme}__{endpointIndex}={uriString}" for .NET service discovery, or "{name}_{ENDPOINT}={uri}" for endpoint injection. /// /// The destination resource. /// The resource where the service discovery information will be injected. @@ -770,7 +790,7 @@ public static IResourceBuilder WithReference(this IR /// /// Injects service discovery and endpoint information from the specified endpoint into the project resource using the source resource's name as the service name. /// Each endpoint uri will be injected using the format defined by the on the destination resource, i.e. - /// either "services__{name}__{endpointName}__{endpointIndex}={uriString}" for .NET service discovery, or "{NAME}_{ENDPOINT}={uri}" for endpoint injection. + /// either "services__{name}__{endpointScheme}__{endpointIndex}={uriString}" for .NET service discovery, or "{NAME}_{ENDPOINT}={uri}" for endpoint injection. /// /// The destination resource. /// The resource where the service discovery information will be injected. diff --git a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.yaml b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.yaml index 9908cf8e083..58de5541176 100644 --- a/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.yaml +++ b/tests/Aspire.Hosting.Docker.Tests/Snapshots/DockerComposePublisherTests.DockerComposeWithProjectResources.verified.yaml @@ -1,4 +1,4 @@ -services: +services: docker-compose-dashboard: image: "mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest" ports: @@ -33,13 +33,13 @@ services: services__project1__http__0: "http://project1:${PROJECT1_PORT}" PROJECT1_HTTPS: "https://project1:${PROJECT1_PORT}" PROJECT1_CUSTOM1: "http://project1:8000" - services__project1__custom1__0: "http://project1:8000" + services__project1__http__1: "http://project1:8000" PROJECT1_CUSTOM2: "http://project1:8001" - services__project1__custom2__0: "http://project1:8001" + services__project1__http__2: "http://project1:8001" PROJECT1_CUSTOM3: "http://project1:7002" - services__project1__custom3__0: "http://project1:7002" + services__project1__http__3: "http://project1:7002" PROJECT1_CUSTOM4: "http://project1:7004" - services__project1__custom4__0: "http://project1:7004" + services__project1__http__4: "http://project1:7004" networks: - "aspire" networks: diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml index 6875575cfda..70534489e39 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#01.verified.yaml @@ -1,4 +1,4 @@ -parameters: +parameters: project1: port_http: 8080 project1_image: "project1:latest" @@ -9,10 +9,10 @@ config: ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" api: PROJECT1_CUSTOM1: "http://project1-service:8000" - services__project1__custom1__0: "http://project1-service:8000" + services__project1__http__1: "http://project1-service:8000" PROJECT1_CUSTOM2: "http://project1-service:8001" - services__project1__custom2__0: "http://project1-service:8001" + services__project1__http__2: "http://project1-service:8001" PROJECT1_CUSTOM3: "http://project1-service:7002" - services__project1__custom3__0: "http://project1-service:7002" + services__project1__http__3: "http://project1-service:7002" PROJECT1_CUSTOM4: "http://project1-service:7004" - services__project1__custom4__0: "http://project1-service:7004" + services__project1__http__4: "http://project1-service:7004" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml index 421858fefae..1f6c4407daa 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesWithProjectResources#06.verified.yaml @@ -1,4 +1,4 @@ ---- +--- apiVersion: "v1" kind: "ConfigMap" metadata: @@ -13,10 +13,10 @@ data: PROJECT1_HTTPS: "https://project1-service:{{ .Values.parameters.project1.port_http }}" services__project1__https__0: "https://project1-service:{{ .Values.parameters.project1.port_http }}" PROJECT1_CUSTOM1: "{{ .Values.config.api.PROJECT1_CUSTOM1 }}" - services__project1__custom1__0: "{{ .Values.config.api.services__project1__custom1__0 }}" + services__project1__http__1: "{{ .Values.config.api.services__project1__http__1 }}" PROJECT1_CUSTOM2: "{{ .Values.config.api.PROJECT1_CUSTOM2 }}" - services__project1__custom2__0: "{{ .Values.config.api.services__project1__custom2__0 }}" + services__project1__http__2: "{{ .Values.config.api.services__project1__http__2 }}" PROJECT1_CUSTOM3: "{{ .Values.config.api.PROJECT1_CUSTOM3 }}" - services__project1__custom3__0: "{{ .Values.config.api.services__project1__custom3__0 }}" + services__project1__http__3: "{{ .Values.config.api.services__project1__http__3 }}" PROJECT1_CUSTOM4: "{{ .Values.config.api.PROJECT1_CUSTOM4 }}" - services__project1__custom4__0: "{{ .Values.config.api.services__project1__custom4__0 }}" + services__project1__http__4: "{{ .Values.config.api.services__project1__http__4 }}" diff --git a/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs b/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs index 3813adb0a2e..8c4c4613b7e 100644 --- a/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs +++ b/tests/Aspire.Hosting.Maui.Tests/MauiPlatformExtensionsTests.cs @@ -617,7 +617,7 @@ public void WithOtlpDevTunnel_MultiplePlatforms_SharesSameInfrastructure(Platfor [Theory] [MemberData(nameof(AllPlatforms))] - public async Task WithOtlpDevTunnel_CleansUpIntermediateEnvironmentVariables(PlatformTestConfig config) + public async Task WithOtlpDevTunnel_SetsEndpointWithoutIntermediateEnvironmentVariables(PlatformTestConfig config) { // Arrange var projectContent = CreateProjectContent(config.RequiredTfm); @@ -644,18 +644,16 @@ public async Task WithOtlpDevTunnel_CleansUpIntermediateEnvironmentVariables(Pla DistributedApplicationOperation.Run, TestServiceProvider.Instance); - // Assert + // Assert - OTEL_EXPORTER_OTLP_ENDPOINT should be set directly from the tunnel endpoint Assert.True(envVars.TryGetValue("OTEL_EXPORTER_OTLP_ENDPOINT", out var endpointValue)); Assert.False(string.IsNullOrWhiteSpace(endpointValue)); Assert.True(Uri.TryCreate(endpointValue, UriKind.Absolute, out _)); + // No intermediate service discovery or endpoint env vars should be present var tunnelConfig = maui.Resource.Annotations.OfType().Single(); var stubName = tunnelConfig.OtlpStub.Name; - var serviceDiscoveryKey = $"services__{stubName}__otlp__0"; - Assert.DoesNotContain(serviceDiscoveryKey, envVars.Keys); - - var directEndpointKey = $"{EnvironmentVariableNameEncoder.Encode(stubName).ToUpperInvariant()}_OTLP"; - Assert.DoesNotContain(directEndpointKey, envVars.Keys); + Assert.DoesNotContain(envVars.Keys, k => k.StartsWith($"services__{stubName}__")); + Assert.DoesNotContain(envVars.Keys, k => k.StartsWith($"{EnvironmentVariableNameEncoder.Encode(stubName).ToUpperInvariant()}_")); } finally { diff --git a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs index 1458970ce3d..3ab0bb97b03 100644 --- a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs +++ b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs @@ -27,7 +27,7 @@ public async Task ResourceWithSingleEndpointProducesSimplifiedEnvironmentVariabl // Call environment variable callbacks. var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); - Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]); + Assert.Equal("https://localhost:2000", config["services__projecta__https__0"]); Assert.Equal("https://localhost:2000", config["PROJECTA_MYBINDING"]); Assert.True(projectB.Resource.TryGetAnnotationsOfType(out var relationships)); @@ -50,9 +50,9 @@ public async Task ResourceNamesWithDashesAreEncodedInEnvironmentVariables() var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); - Assert.Equal("https://localhost:2000", config["services__project-a__mybinding__0"]); + Assert.Equal("https://localhost:2000", config["services__project-a__https__0"]); Assert.Equal("https://localhost:2000", config["PROJECT_A_MYBINDING"]); - Assert.DoesNotContain("services__project_a__mybinding__0", config.Keys); + Assert.DoesNotContain("services__project_a__https__0", config.Keys); Assert.DoesNotContain("PROJECT-A_MYBINDING", config.Keys); } @@ -70,9 +70,9 @@ public async Task OverriddenServiceNamesAreEncodedInEnvironmentVariables() var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); - Assert.Equal("https://localhost:2000", config["services__custom-name__mybinding__0"]); + Assert.Equal("https://localhost:2000", config["services__custom-name__https__0"]); Assert.Equal("https://localhost:2000", config["custom_name_MYBINDING"]); - Assert.DoesNotContain("services__custom_name__mybinding__0", config.Keys); + Assert.DoesNotContain("services__custom_name__https__0", config.Keys); Assert.DoesNotContain("custom-name_MYBINDING", config.Keys); } @@ -103,28 +103,28 @@ public async Task ResourceWithEndpointRespectsCustomEnvironmentVariableNaming(Re switch (flags) { case ReferenceEnvironmentInjectionFlags.All: - Assert.Equal("https://localhost:2000", config["services__custom__mybinding__0"]); + Assert.Equal("https://localhost:2000", config["services__custom__https__0"]); Assert.Equal("https://localhost:2000", config["custom_MYBINDING"]); break; case ReferenceEnvironmentInjectionFlags.ConnectionProperties: Assert.False(config.ContainsKey("custom_MYBINDING")); - Assert.False(config.ContainsKey("services__custom__mybinding__0")); + Assert.False(config.ContainsKey("services__custom__https__0")); break; case ReferenceEnvironmentInjectionFlags.ConnectionString: Assert.False(config.ContainsKey("custom_MYBINDING")); - Assert.False(config.ContainsKey("services__custom__mybinding__0")); + Assert.False(config.ContainsKey("services__custom__https__0")); break; case ReferenceEnvironmentInjectionFlags.ServiceDiscovery: Assert.False(config.ContainsKey("custom_MYBINDING")); - Assert.True(config.ContainsKey("services__custom__mybinding__0")); + Assert.True(config.ContainsKey("services__custom__https__0")); break; case ReferenceEnvironmentInjectionFlags.Endpoints: Assert.True(config.ContainsKey("custom_MYBINDING")); - Assert.False(config.ContainsKey("services__custom__mybinding__0")); + Assert.False(config.ContainsKey("services__custom__https__0")); break; case ReferenceEnvironmentInjectionFlags.None: Assert.False(config.ContainsKey("custom_MYBINDING")); - Assert.False(config.ContainsKey("services__custom__mybinding__0")); + Assert.False(config.ContainsKey("services__custom__https__0")); break; } } @@ -149,8 +149,8 @@ public async Task ResourceWithConflictingEndpointsProducesFullyScopedEnvironment // Call environment variable callbacks. var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); - Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]); - Assert.Equal("https://localhost:3000", config["services__projecta__myconflictingbinding__0"]); + Assert.Equal("https://localhost:2000", config["services__projecta__https__0"]); + Assert.Equal("https://localhost:3000", config["services__projecta__https__1"]); Assert.Equal("https://localhost:2000", config["PROJECTA_MYBINDING"]); Assert.Equal("https://localhost:3000", config["PROJECTA_MYCONFLICTINGBINDING"]); @@ -177,8 +177,8 @@ public async Task ResourceWithNonConflictingEndpointsProducesAllVariantsOfEnviro // Call environment variable callbacks. var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); - Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]); - Assert.Equal("http://localhost:3000", config["services__projecta__mynonconflictingbinding__0"]); + Assert.Equal("https://localhost:2000", config["services__projecta__https__0"]); + Assert.Equal("http://localhost:3000", config["services__projecta__http__0"]); Assert.Equal("https://localhost:2000", config["PROJECTA_MYBINDING"]); Assert.Equal("http://localhost:3000", config["PROJECTA_MYNONCONFLICTINGBINDING"]); @@ -203,8 +203,8 @@ public async Task ResourceWithConflictingEndpointsProducesAllEnvironmentVariable // Call environment variable callbacks. var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); - Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]); - Assert.Equal("https://localhost:3000", config["services__projecta__mybinding2__0"]); + Assert.Equal("https://localhost:2000", config["services__projecta__https__0"]); + Assert.Equal("https://localhost:3000", config["services__projecta__https__1"]); Assert.Equal("https://localhost:2000", config["PROJECTA_MYBINDING"]); Assert.Equal("https://localhost:3000", config["PROJECTA_MYBINDING2"]); @@ -232,8 +232,8 @@ public async Task ResourceWithEndpointsProducesAllEnvironmentVariables() // Call environment variable callbacks. var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); - Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]); - Assert.Equal("http://localhost:3000", config["services__projecta__mybinding2__0"]); + Assert.Equal("https://localhost:2000", config["services__projecta__https__0"]); + Assert.Equal("http://localhost:3000", config["services__projecta__http__0"]); Assert.Equal("https://localhost:2000", config["PROJECTA_MYBINDING"]); Assert.Equal("http://localhost:3000", config["PROJECTA_MYBINDING2"]); From 04a0245521120a9781f864713ab6caef4896e4e3 Mon Sep 17 00:00:00 2001 From: "Maddy Montaquila (Leger)" Date: Tue, 24 Feb 2026 02:00:04 -0500 Subject: [PATCH 156/256] CLI: Grouped help, command renames, and ergonomics cleanup (#14599) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: David Fowler Co-authored-by: James Newton-King --- .../workflows/polyglot-validation/test-go.sh | 2 +- .../polyglot-validation/test-java.sh | 2 +- .../polyglot-validation/test-python.sh | 2 +- .../polyglot-validation/test-rust.sh | 2 +- .../polyglot-validation/test-typescript.sh | 2 +- docs/specs/bundle.md | 8 +- docs/specs/dashboard-http-api.md | 54 ++-- docs/specs/polyglot-apphost-testing.md | 26 +- docs/specs/polyglot-apphost.md | 10 +- .../Agents/CommonAgentApplicators.cs | 2 +- src/Aspire.Cli/Commands/AddCommand.cs | 4 +- src/Aspire.Cli/Commands/AgentCommand.cs | 2 + src/Aspire.Cli/Commands/BaseCommand.cs | 7 + src/Aspire.Cli/Commands/CacheCommand.cs | 2 + src/Aspire.Cli/Commands/ConfigCommand.cs | 2 + src/Aspire.Cli/Commands/DeployCommand.cs | 2 + ...ResourcesCommand.cs => DescribeCommand.cs} | 37 ++- src/Aspire.Cli/Commands/DoCommand.cs | 2 + src/Aspire.Cli/Commands/DocsCommand.cs | 2 + src/Aspire.Cli/Commands/DoctorCommand.cs | 2 + src/Aspire.Cli/Commands/GroupedHelpAction.cs | 30 ++ src/Aspire.Cli/Commands/GroupedHelpWriter.cs | 304 ++++++++++++++++++ src/Aspire.Cli/Commands/HelpGroups.cs | 40 +++ src/Aspire.Cli/Commands/InitCommand.cs | 6 +- src/Aspire.Cli/Commands/LogsCommand.cs | 2 + src/Aspire.Cli/Commands/NewCommand.cs | 6 +- src/Aspire.Cli/Commands/PsCommand.cs | 2 + src/Aspire.Cli/Commands/PublishCommand.cs | 2 + src/Aspire.Cli/Commands/ResourceCommand.cs | 2 + src/Aspire.Cli/Commands/RestartCommand.cs | 2 + src/Aspire.Cli/Commands/RootCommand.cs | 39 ++- src/Aspire.Cli/Commands/RunCommand.cs | 2 + src/Aspire.Cli/Commands/Sdk/SdkCommand.cs | 1 + src/Aspire.Cli/Commands/SetupCommand.cs | 2 + src/Aspire.Cli/Commands/StartCommand.cs | 2 + src/Aspire.Cli/Commands/StopCommand.cs | 2 + src/Aspire.Cli/Commands/TelemetryCommand.cs | 4 +- src/Aspire.Cli/Commands/UpdateCommand.cs | 2 + src/Aspire.Cli/Commands/WaitCommand.cs | 2 + src/Aspire.Cli/Program.cs | 6 +- src/Aspire.Cli/Properties/launchSettings.json | 5 + src/Aspire.Cli/README.md | 304 +++++------------- .../Resources/AddCommandStrings.resx | 2 +- .../Resources/AgentCommandStrings.resx | 2 +- .../Resources/ConfigCommandStrings.resx | 2 +- .../Resources/DeployCommandStrings.resx | 2 +- ....cs => DescribeCommandStrings.Designer.cs} | 10 +- ...rings.resx => DescribeCommandStrings.resx} | 8 +- .../Resources/DoctorCommandStrings.resx | 4 +- .../Resources/HelpGroupStrings.Designer.cs | 149 +++++++++ .../Resources/HelpGroupStrings.resx | 150 +++++++++ .../Resources/InitCommandStrings.resx | 2 +- .../Resources/LogsCommandStrings.resx | 4 +- .../Resources/NewCommandStrings.resx | 2 +- .../Resources/PsCommandStrings.resx | 4 +- .../Resources/PublishCommandStrings.resx | 2 +- .../Resources/RootCommandStrings.Designer.cs | 9 + .../Resources/RootCommandStrings.resx | 3 + .../Resources/RunCommandStrings.resx | 4 +- .../Resources/TelemetryCommandStrings.resx | 2 +- .../Resources/xlf/AddCommandStrings.cs.xlf | 4 +- .../Resources/xlf/AddCommandStrings.de.xlf | 4 +- .../Resources/xlf/AddCommandStrings.es.xlf | 4 +- .../Resources/xlf/AddCommandStrings.fr.xlf | 4 +- .../Resources/xlf/AddCommandStrings.it.xlf | 4 +- .../Resources/xlf/AddCommandStrings.ja.xlf | 4 +- .../Resources/xlf/AddCommandStrings.ko.xlf | 4 +- .../Resources/xlf/AddCommandStrings.pl.xlf | 4 +- .../Resources/xlf/AddCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/AddCommandStrings.ru.xlf | 4 +- .../Resources/xlf/AddCommandStrings.tr.xlf | 4 +- .../xlf/AddCommandStrings.zh-Hans.xlf | 4 +- .../xlf/AddCommandStrings.zh-Hant.xlf | 4 +- .../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 +- .../Resources/xlf/ConfigCommandStrings.cs.xlf | 4 +- .../Resources/xlf/ConfigCommandStrings.de.xlf | 4 +- .../Resources/xlf/ConfigCommandStrings.es.xlf | 4 +- .../Resources/xlf/ConfigCommandStrings.fr.xlf | 4 +- .../Resources/xlf/ConfigCommandStrings.it.xlf | 4 +- .../Resources/xlf/ConfigCommandStrings.ja.xlf | 4 +- .../Resources/xlf/ConfigCommandStrings.ko.xlf | 4 +- .../Resources/xlf/ConfigCommandStrings.pl.xlf | 4 +- .../xlf/ConfigCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/ConfigCommandStrings.ru.xlf | 4 +- .../Resources/xlf/ConfigCommandStrings.tr.xlf | 4 +- .../xlf/ConfigCommandStrings.zh-Hans.xlf | 4 +- .../xlf/ConfigCommandStrings.zh-Hant.xlf | 4 +- .../Resources/xlf/DeployCommandStrings.cs.xlf | 4 +- .../Resources/xlf/DeployCommandStrings.de.xlf | 4 +- .../Resources/xlf/DeployCommandStrings.es.xlf | 4 +- .../Resources/xlf/DeployCommandStrings.fr.xlf | 4 +- .../Resources/xlf/DeployCommandStrings.it.xlf | 4 +- .../Resources/xlf/DeployCommandStrings.ja.xlf | 4 +- .../Resources/xlf/DeployCommandStrings.ko.xlf | 4 +- .../Resources/xlf/DeployCommandStrings.pl.xlf | 4 +- .../xlf/DeployCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/DeployCommandStrings.ru.xlf | 4 +- .../Resources/xlf/DeployCommandStrings.tr.xlf | 4 +- .../xlf/DeployCommandStrings.zh-Hans.xlf | 4 +- .../xlf/DeployCommandStrings.zh-Hant.xlf | 4 +- ...s.cs.xlf => DescribeCommandStrings.cs.xlf} | 22 +- ...s.de.xlf => DescribeCommandStrings.de.xlf} | 22 +- ...s.es.xlf => DescribeCommandStrings.es.xlf} | 22 +- ...s.fr.xlf => DescribeCommandStrings.fr.xlf} | 22 +- ...s.it.xlf => DescribeCommandStrings.it.xlf} | 22 +- ...s.ja.xlf => DescribeCommandStrings.ja.xlf} | 22 +- ...s.ko.xlf => DescribeCommandStrings.ko.xlf} | 22 +- ...s.pl.xlf => DescribeCommandStrings.pl.xlf} | 22 +- ...R.xlf => DescribeCommandStrings.pt-BR.xlf} | 22 +- ...s.ru.xlf => DescribeCommandStrings.ru.xlf} | 22 +- ...s.tr.xlf => DescribeCommandStrings.tr.xlf} | 22 +- ...xlf => DescribeCommandStrings.zh-Hans.xlf} | 22 +- ...xlf => DescribeCommandStrings.zh-Hant.xlf} | 22 +- .../Resources/xlf/DoctorCommandStrings.cs.xlf | 8 +- .../Resources/xlf/DoctorCommandStrings.de.xlf | 8 +- .../Resources/xlf/DoctorCommandStrings.es.xlf | 8 +- .../Resources/xlf/DoctorCommandStrings.fr.xlf | 8 +- .../Resources/xlf/DoctorCommandStrings.it.xlf | 8 +- .../Resources/xlf/DoctorCommandStrings.ja.xlf | 8 +- .../Resources/xlf/DoctorCommandStrings.ko.xlf | 8 +- .../Resources/xlf/DoctorCommandStrings.pl.xlf | 8 +- .../xlf/DoctorCommandStrings.pt-BR.xlf | 8 +- .../Resources/xlf/DoctorCommandStrings.ru.xlf | 8 +- .../Resources/xlf/DoctorCommandStrings.tr.xlf | 8 +- .../xlf/DoctorCommandStrings.zh-Hans.xlf | 8 +- .../xlf/DoctorCommandStrings.zh-Hant.xlf | 8 +- .../Resources/xlf/HelpGroupStrings.cs.xlf | 57 ++++ .../Resources/xlf/HelpGroupStrings.de.xlf | 57 ++++ .../Resources/xlf/HelpGroupStrings.es.xlf | 57 ++++ .../Resources/xlf/HelpGroupStrings.fr.xlf | 57 ++++ .../Resources/xlf/HelpGroupStrings.it.xlf | 57 ++++ .../Resources/xlf/HelpGroupStrings.ja.xlf | 57 ++++ .../Resources/xlf/HelpGroupStrings.ko.xlf | 57 ++++ .../Resources/xlf/HelpGroupStrings.pl.xlf | 57 ++++ .../Resources/xlf/HelpGroupStrings.pt-BR.xlf | 57 ++++ .../Resources/xlf/HelpGroupStrings.ru.xlf | 57 ++++ .../Resources/xlf/HelpGroupStrings.tr.xlf | 57 ++++ .../xlf/HelpGroupStrings.zh-Hans.xlf | 57 ++++ .../xlf/HelpGroupStrings.zh-Hant.xlf | 57 ++++ .../Resources/xlf/InitCommandStrings.cs.xlf | 4 +- .../Resources/xlf/InitCommandStrings.de.xlf | 4 +- .../Resources/xlf/InitCommandStrings.es.xlf | 4 +- .../Resources/xlf/InitCommandStrings.fr.xlf | 4 +- .../Resources/xlf/InitCommandStrings.it.xlf | 4 +- .../Resources/xlf/InitCommandStrings.ja.xlf | 4 +- .../Resources/xlf/InitCommandStrings.ko.xlf | 4 +- .../Resources/xlf/InitCommandStrings.pl.xlf | 4 +- .../xlf/InitCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/InitCommandStrings.ru.xlf | 4 +- .../Resources/xlf/InitCommandStrings.tr.xlf | 4 +- .../xlf/InitCommandStrings.zh-Hans.xlf | 4 +- .../xlf/InitCommandStrings.zh-Hant.xlf | 4 +- .../Resources/xlf/LogsCommandStrings.cs.xlf | 8 +- .../Resources/xlf/LogsCommandStrings.de.xlf | 8 +- .../Resources/xlf/LogsCommandStrings.es.xlf | 8 +- .../Resources/xlf/LogsCommandStrings.fr.xlf | 8 +- .../Resources/xlf/LogsCommandStrings.it.xlf | 8 +- .../Resources/xlf/LogsCommandStrings.ja.xlf | 8 +- .../Resources/xlf/LogsCommandStrings.ko.xlf | 8 +- .../Resources/xlf/LogsCommandStrings.pl.xlf | 8 +- .../xlf/LogsCommandStrings.pt-BR.xlf | 8 +- .../Resources/xlf/LogsCommandStrings.ru.xlf | 8 +- .../Resources/xlf/LogsCommandStrings.tr.xlf | 8 +- .../xlf/LogsCommandStrings.zh-Hans.xlf | 8 +- .../xlf/LogsCommandStrings.zh-Hant.xlf | 8 +- .../Resources/xlf/NewCommandStrings.cs.xlf | 4 +- .../Resources/xlf/NewCommandStrings.de.xlf | 4 +- .../Resources/xlf/NewCommandStrings.es.xlf | 4 +- .../Resources/xlf/NewCommandStrings.fr.xlf | 4 +- .../Resources/xlf/NewCommandStrings.it.xlf | 4 +- .../Resources/xlf/NewCommandStrings.ja.xlf | 4 +- .../Resources/xlf/NewCommandStrings.ko.xlf | 4 +- .../Resources/xlf/NewCommandStrings.pl.xlf | 4 +- .../Resources/xlf/NewCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/NewCommandStrings.ru.xlf | 4 +- .../Resources/xlf/NewCommandStrings.tr.xlf | 4 +- .../xlf/NewCommandStrings.zh-Hans.xlf | 4 +- .../xlf/NewCommandStrings.zh-Hant.xlf | 4 +- .../Resources/xlf/PsCommandStrings.cs.xlf | 8 +- .../Resources/xlf/PsCommandStrings.de.xlf | 8 +- .../Resources/xlf/PsCommandStrings.es.xlf | 8 +- .../Resources/xlf/PsCommandStrings.fr.xlf | 8 +- .../Resources/xlf/PsCommandStrings.it.xlf | 8 +- .../Resources/xlf/PsCommandStrings.ja.xlf | 8 +- .../Resources/xlf/PsCommandStrings.ko.xlf | 8 +- .../Resources/xlf/PsCommandStrings.pl.xlf | 8 +- .../Resources/xlf/PsCommandStrings.pt-BR.xlf | 8 +- .../Resources/xlf/PsCommandStrings.ru.xlf | 8 +- .../Resources/xlf/PsCommandStrings.tr.xlf | 8 +- .../xlf/PsCommandStrings.zh-Hans.xlf | 8 +- .../xlf/PsCommandStrings.zh-Hant.xlf | 8 +- .../xlf/PublishCommandStrings.cs.xlf | 4 +- .../xlf/PublishCommandStrings.de.xlf | 4 +- .../xlf/PublishCommandStrings.es.xlf | 4 +- .../xlf/PublishCommandStrings.fr.xlf | 4 +- .../xlf/PublishCommandStrings.it.xlf | 4 +- .../xlf/PublishCommandStrings.ja.xlf | 4 +- .../xlf/PublishCommandStrings.ko.xlf | 4 +- .../xlf/PublishCommandStrings.pl.xlf | 4 +- .../xlf/PublishCommandStrings.pt-BR.xlf | 4 +- .../xlf/PublishCommandStrings.ru.xlf | 4 +- .../xlf/PublishCommandStrings.tr.xlf | 4 +- .../xlf/PublishCommandStrings.zh-Hans.xlf | 4 +- .../xlf/PublishCommandStrings.zh-Hant.xlf | 4 +- .../Resources/xlf/RootCommandStrings.cs.xlf | 7 +- .../Resources/xlf/RootCommandStrings.de.xlf | 7 +- .../Resources/xlf/RootCommandStrings.es.xlf | 7 +- .../Resources/xlf/RootCommandStrings.fr.xlf | 7 +- .../Resources/xlf/RootCommandStrings.it.xlf | 7 +- .../Resources/xlf/RootCommandStrings.ja.xlf | 7 +- .../Resources/xlf/RootCommandStrings.ko.xlf | 7 +- .../Resources/xlf/RootCommandStrings.pl.xlf | 7 +- .../xlf/RootCommandStrings.pt-BR.xlf | 7 +- .../Resources/xlf/RootCommandStrings.ru.xlf | 7 +- .../Resources/xlf/RootCommandStrings.tr.xlf | 7 +- .../xlf/RootCommandStrings.zh-Hans.xlf | 7 +- .../xlf/RootCommandStrings.zh-Hant.xlf | 7 +- .../Resources/xlf/RunCommandStrings.cs.xlf | 8 +- .../Resources/xlf/RunCommandStrings.de.xlf | 8 +- .../Resources/xlf/RunCommandStrings.es.xlf | 8 +- .../Resources/xlf/RunCommandStrings.fr.xlf | 8 +- .../Resources/xlf/RunCommandStrings.it.xlf | 8 +- .../Resources/xlf/RunCommandStrings.ja.xlf | 8 +- .../Resources/xlf/RunCommandStrings.ko.xlf | 8 +- .../Resources/xlf/RunCommandStrings.pl.xlf | 8 +- .../Resources/xlf/RunCommandStrings.pt-BR.xlf | 8 +- .../Resources/xlf/RunCommandStrings.ru.xlf | 8 +- .../Resources/xlf/RunCommandStrings.tr.xlf | 8 +- .../xlf/RunCommandStrings.zh-Hans.xlf | 8 +- .../xlf/RunCommandStrings.zh-Hant.xlf | 8 +- .../xlf/TelemetryCommandStrings.cs.xlf | 4 +- .../xlf/TelemetryCommandStrings.de.xlf | 4 +- .../xlf/TelemetryCommandStrings.es.xlf | 4 +- .../xlf/TelemetryCommandStrings.fr.xlf | 4 +- .../xlf/TelemetryCommandStrings.it.xlf | 4 +- .../xlf/TelemetryCommandStrings.ja.xlf | 4 +- .../xlf/TelemetryCommandStrings.ko.xlf | 4 +- .../xlf/TelemetryCommandStrings.pl.xlf | 4 +- .../xlf/TelemetryCommandStrings.pt-BR.xlf | 4 +- .../xlf/TelemetryCommandStrings.ru.xlf | 4 +- .../xlf/TelemetryCommandStrings.tr.xlf | 4 +- .../xlf/TelemetryCommandStrings.zh-Hans.xlf | 4 +- .../xlf/TelemetryCommandStrings.zh-Hant.xlf | 4 +- ...ommandTests.cs => DescribeCommandTests.cs} | 16 +- ...ommandTests.cs => DescribeCommandTests.cs} | 75 +++-- .../Commands/RootCommandTests.cs | 46 +++ .../Commands/TelemetryCommandTests.cs | 8 +- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 2 +- 260 files changed, 2450 insertions(+), 947 deletions(-) rename src/Aspire.Cli/Commands/{ResourcesCommand.cs => DescribeCommand.cs} (90%) create mode 100644 src/Aspire.Cli/Commands/GroupedHelpAction.cs create mode 100644 src/Aspire.Cli/Commands/GroupedHelpWriter.cs create mode 100644 src/Aspire.Cli/Commands/HelpGroups.cs rename src/Aspire.Cli/Resources/{ResourcesCommandStrings.Designer.cs => DescribeCommandStrings.Designer.cs} (90%) rename src/Aspire.Cli/Resources/{ResourcesCommandStrings.resx => DescribeCommandStrings.resx} (95%) create mode 100644 src/Aspire.Cli/Resources/HelpGroupStrings.Designer.cs create mode 100644 src/Aspire.Cli/Resources/HelpGroupStrings.resx rename src/Aspire.Cli/Resources/xlf/{ResourcesCommandStrings.cs.xlf => DescribeCommandStrings.cs.xlf} (77%) rename src/Aspire.Cli/Resources/xlf/{ResourcesCommandStrings.de.xlf => DescribeCommandStrings.de.xlf} (76%) rename src/Aspire.Cli/Resources/xlf/{ResourcesCommandStrings.es.xlf => DescribeCommandStrings.es.xlf} (77%) rename src/Aspire.Cli/Resources/xlf/{ResourcesCommandStrings.fr.xlf => DescribeCommandStrings.fr.xlf} (76%) rename src/Aspire.Cli/Resources/xlf/{ResourcesCommandStrings.it.xlf => DescribeCommandStrings.it.xlf} (76%) rename src/Aspire.Cli/Resources/xlf/{ResourcesCommandStrings.ja.xlf => DescribeCommandStrings.ja.xlf} (76%) rename src/Aspire.Cli/Resources/xlf/{ResourcesCommandStrings.ko.xlf => DescribeCommandStrings.ko.xlf} (77%) rename src/Aspire.Cli/Resources/xlf/{ResourcesCommandStrings.pl.xlf => DescribeCommandStrings.pl.xlf} (77%) rename src/Aspire.Cli/Resources/xlf/{ResourcesCommandStrings.pt-BR.xlf => DescribeCommandStrings.pt-BR.xlf} (77%) rename src/Aspire.Cli/Resources/xlf/{ResourcesCommandStrings.ru.xlf => DescribeCommandStrings.ru.xlf} (75%) rename src/Aspire.Cli/Resources/xlf/{ResourcesCommandStrings.tr.xlf => DescribeCommandStrings.tr.xlf} (76%) rename src/Aspire.Cli/Resources/xlf/{ResourcesCommandStrings.zh-Hans.xlf => DescribeCommandStrings.zh-Hans.xlf} (76%) rename src/Aspire.Cli/Resources/xlf/{ResourcesCommandStrings.zh-Hant.xlf => DescribeCommandStrings.zh-Hant.xlf} (77%) create mode 100644 src/Aspire.Cli/Resources/xlf/HelpGroupStrings.cs.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/HelpGroupStrings.de.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/HelpGroupStrings.es.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/HelpGroupStrings.fr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/HelpGroupStrings.it.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/HelpGroupStrings.ja.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/HelpGroupStrings.ko.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/HelpGroupStrings.pl.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/HelpGroupStrings.pt-BR.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/HelpGroupStrings.ru.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/HelpGroupStrings.tr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/HelpGroupStrings.zh-Hans.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/HelpGroupStrings.zh-Hant.xlf rename tests/Aspire.Cli.EndToEnd.Tests/{ResourcesCommandTests.cs => DescribeCommandTests.cs} (91%) rename tests/Aspire.Cli.Tests/Commands/{ResourcesCommandTests.cs => DescribeCommandTests.cs} (75%) diff --git a/.github/workflows/polyglot-validation/test-go.sh b/.github/workflows/polyglot-validation/test-go.sh index a13ac52b798..25a9c2766ab 100755 --- a/.github/workflows/polyglot-validation/test-go.sh +++ b/.github/workflows/polyglot-validation/test-go.sh @@ -21,7 +21,7 @@ cd "$WORK_DIR" # Initialize Go AppHost echo "Creating Go apphost project..." -aspire init -l go --non-interactive -d +aspire init --language go --non-interactive -d # Add Redis integration echo "Adding Redis integration..." diff --git a/.github/workflows/polyglot-validation/test-java.sh b/.github/workflows/polyglot-validation/test-java.sh index 28c91567709..df5d0f68cd0 100755 --- a/.github/workflows/polyglot-validation/test-java.sh +++ b/.github/workflows/polyglot-validation/test-java.sh @@ -21,7 +21,7 @@ cd "$WORK_DIR" # Initialize Java AppHost echo "Creating Java apphost project..." -aspire init -l java --non-interactive -d +aspire init --language java --non-interactive -d # Add Redis integration echo "Adding Redis integration..." diff --git a/.github/workflows/polyglot-validation/test-python.sh b/.github/workflows/polyglot-validation/test-python.sh index e510a15d404..d7d91f1308a 100755 --- a/.github/workflows/polyglot-validation/test-python.sh +++ b/.github/workflows/polyglot-validation/test-python.sh @@ -21,7 +21,7 @@ cd "$WORK_DIR" # Initialize Python AppHost echo "Creating Python apphost project..." -aspire init -l python --non-interactive -d +aspire init --language python --non-interactive -d # Add Redis integration echo "Adding Redis integration..." diff --git a/.github/workflows/polyglot-validation/test-rust.sh b/.github/workflows/polyglot-validation/test-rust.sh index a66647d88ca..f12dad70933 100755 --- a/.github/workflows/polyglot-validation/test-rust.sh +++ b/.github/workflows/polyglot-validation/test-rust.sh @@ -21,7 +21,7 @@ cd "$WORK_DIR" # Initialize Rust AppHost echo "Creating Rust apphost project..." -aspire init -l rust --non-interactive -d +aspire init --language rust --non-interactive -d # Add Redis integration echo "Adding Redis integration..." diff --git a/.github/workflows/polyglot-validation/test-typescript.sh b/.github/workflows/polyglot-validation/test-typescript.sh index 2b4060da29c..f1e9acbc7e4 100755 --- a/.github/workflows/polyglot-validation/test-typescript.sh +++ b/.github/workflows/polyglot-validation/test-typescript.sh @@ -21,7 +21,7 @@ cd "$WORK_DIR" # Initialize TypeScript AppHost echo "Creating TypeScript apphost project..." -aspire init -l typescript --non-interactive -d +aspire init --language typescript --non-interactive -d # Add Redis integration echo "Adding Redis integration..." diff --git a/docs/specs/bundle.md b/docs/specs/bundle.md index 579eb94d202..d97c88a44bb 100644 --- a/docs/specs/bundle.md +++ b/docs/specs/bundle.md @@ -272,7 +272,7 @@ The service uses a file lock (`.aspire-bundle-lock`) in the extraction directory #### Explicit: `aspire setup` ```bash -aspire setup [--install-path ] [--force] +aspire setup [--install-path ] ``` Best for install scripts — reduces to: @@ -298,7 +298,7 @@ The file `.aspire-bundle-version` in the layout root contains the assembly infor - **Skip extraction** when version matches (normal startup is free) - **Re-extract** when CLI binary is updated (version changes) -- **Force re-extract** with `aspire setup --force` (ignores version) +- **Force re-extract** with `aspire setup --force` ### Platform Notes @@ -1314,7 +1314,7 @@ This section tracks the implementation progress of the bundle feature. - Platform-aware extraction (system `tar` on Unix, .NET `TarReader` on Windows) - Version tracking via `.aspire-bundle-version` marker file - [x] **Setup command** - `src/Aspire.Cli/Commands/SetupCommand.cs` - - `aspire setup [--install-path] [--force]` + - `aspire setup [--install-path]` - Delegates to `IBundleService.ExtractAsync()` - [x] **Self-update simplified** - `src/Aspire.Cli/Commands/UpdateCommand.cs` - `aspire update --self` downloads new CLI, swaps binary, extracts via `IBundleService` @@ -1349,7 +1349,7 @@ This section tracks the implementation progress of the bundle feature. | `src/Shared/BundleTrailer.cs` | (Deleted) Previously held trailer read/write logic | | `src/Aspire.Cli/Bundles/IBundleService.cs` | Bundle extraction interface + result enum | | `src/Aspire.Cli/Bundles/BundleService.cs` | Centralized extraction with .NET TarReader | -| `src/Aspire.Cli/Commands/SetupCommand.cs` | `aspire setup` command | +| `src/Aspire.Cli/Commands/SetupCommand.cs` | `aspire setup` command for bundle extraction | | `src/Aspire.Cli/Utils/ArchiveHelper.cs` | Shared .zip/.tar.gz extraction utility | | `tools/CreateLayout/Program.cs` | Bundle build tool (layout assembly + self-extracting binary) | | `eng/Bundle.proj` | MSBuild orchestration for bundle creation | diff --git a/docs/specs/dashboard-http-api.md b/docs/specs/dashboard-http-api.md index ec576166e1d..e1daf534b38 100644 --- a/docs/specs/dashboard-http-api.md +++ b/docs/specs/dashboard-http-api.md @@ -392,30 +392,30 @@ The streaming implementation uses a push-based architecture for efficiency: ## Part 4: CLI Commands -### `aspire telemetry` +### `aspire otel` Subcommand group for telemetry operations. ```text -aspire telemetry logs [resource] [options] -aspire telemetry spans [resource] [options] -aspire telemetry trace [traceId] [options] +aspire otel logs [resource] [options] +aspire otel spans [resource] [options] +aspire otel trace [traceId] [options] ``` ### Commands -#### `aspire telemetry logs` +#### `aspire otel logs` List or stream structured logs. ```bash -aspire telemetry logs # Recent logs -aspire telemetry logs frontend # Logs from frontend service -aspire telemetry logs --severity error # Errors and above -aspire telemetry logs --trace-id 4bf92f... # Logs correlated to a trace -aspire telemetry logs --follow # Stream in real-time -aspire telemetry logs --limit 50 # Cap results -aspire telemetry logs --json # Raw OTLP JSON output +aspire otel logs # Recent logs +aspire otel logs frontend # Logs from frontend service +aspire otel logs --severity error # Errors and above +aspire otel logs --trace-id 4bf92f... # Logs correlated to a trace +aspire otel logs --follow # Stream in real-time +aspire otel logs --limit 50 # Cap results +aspire otel logs --json # Raw OTLP JSON output ``` | Flag | Description | @@ -427,17 +427,17 @@ aspire telemetry logs --json # Raw OTLP JSON output | `--has-error` | Filter to error logs | | `--json` | Output raw OTLP JSON | -#### `aspire telemetry spans` +#### `aspire otel spans` List or stream raw spans (power user / scripting). ```bash -aspire telemetry spans # Recent spans -aspire telemetry spans catalogdb # Spans from catalogdb service -aspire telemetry spans --trace-id 4bf92f... # Spans in a trace -aspire telemetry spans --has-error # Failed spans only -aspire telemetry spans --follow # Stream in real-time -aspire telemetry spans --json | jq ... # Pipe to tools +aspire otel spans # Recent spans +aspire otel spans catalogdb # Spans from catalogdb service +aspire otel spans --trace-id 4bf92f... # Spans in a trace +aspire otel spans --has-error # Failed spans only +aspire otel spans --follow # Stream in real-time +aspire otel spans --json | jq ... # Pipe to tools ``` | Flag | Description | @@ -448,15 +448,15 @@ aspire telemetry spans --json | jq ... # Pipe to tools | `--has-error` | Filter to spans with error status | | `--json` | Output raw OTLP JSON | -#### `aspire telemetry trace` +#### `aspire otel trace` List traces or show a trace waterfall (snapshot only, no streaming). ```bash -aspire telemetry trace # List recent traces -aspire telemetry trace 4bf92f3577b34d # Show waterfall view -aspire telemetry trace --has-error # Failed traces only -aspire telemetry trace 4bf92f... --logs # Include correlated logs +aspire otel trace # List recent traces +aspire otel trace 4bf92f3577b34d # Show waterfall view +aspire otel trace --has-error # Failed traces only +aspire otel trace 4bf92f... --logs # Include correlated logs ``` **Trace list output:** @@ -495,9 +495,9 @@ c21a35b944... 3.7s catalogdb→postgres Initializing catalog ✓ | Command | `--follow` | Notes | |---------|------------|-------| -| `telemetry logs` | ✅ | Natural - like `tail -f` | -| `telemetry spans` | ✅ | Stream raw spans as they arrive | -| `telemetry trace` | ❌ | Traces are groupings with no "complete" signal | +| `otel logs` | ✅ | Natural - like `tail -f` | +| `otel spans` | ✅ | Stream raw spans as they arrive | +| `otel trace` | ❌ | Traces are groupings with no "complete" signal | ### Implementation Notes diff --git a/docs/specs/polyglot-apphost-testing.md b/docs/specs/polyglot-apphost-testing.md index e6d30937ec1..a8a73c33764 100644 --- a/docs/specs/polyglot-apphost-testing.md +++ b/docs/specs/polyglot-apphost-testing.md @@ -79,14 +79,14 @@ The Aspire CLI already has the building blocks we need: ### New CLI Commands -We add a new `aspire resources` command that exposes resource snapshots: +We add a new `aspire describe` command that exposes resource snapshots: ```bash -aspire resources [--watch] [--project ] +aspire describe [--follow] [--project ] ``` -- **`aspire resources`** - Returns a JSON snapshot of all resources -- **`aspire resources --watch`** - Streams NDJSON snapshots as resources change +- **`aspire describe`** - Returns a JSON snapshot of all resources +- **`aspire describe --follow`** - Streams NDJSON snapshots as resources change Language wrapper libraries build convenience methods on top of these primitives. @@ -134,12 +134,12 @@ aspire stop [--project ] If `--project` is not specified, stops the AppHost in the current directory (or prompts if multiple are found). -### `aspire resources` +### `aspire describe` Returns a snapshot of all resources. ```bash -aspire resources [--project ] +aspire describe [--project ] ``` **Output (JSON):** @@ -190,12 +190,12 @@ aspire resources [--project ] } ``` -### `aspire resources --watch` +### `aspire describe --follow` Streams resource snapshots as NDJSON (newline-delimited JSON). ```bash -aspire resources --watch [--project ] +aspire describe --follow [--project ] ``` **Output (NDJSON):** @@ -520,7 +520,7 @@ interface LogEntry { The TypeScript wrapper: 1. Spawns `aspire run --detach --format json` and parses the JSON output -2. Spawns `aspire resources --watch` in the background to maintain resource state +2. Spawns `aspire describe --follow` in the background to maintain resource state 3. Provides async methods that wait for state changes 4. Can collect logs via `aspire logs` piped to files before cleanup 5. Spawns `aspire stop` on cleanup @@ -539,7 +539,7 @@ class AspireApp { app.appHostPid = info.appHostPid; // Start watching resources - app.watcher = spawn('aspire', ['resources', '--watch', '--format', 'json', '--project', options.project]); + app.watcher = spawn('aspire', ['describe', '--follow', '--format', 'json', '--project', options.project]); app.watcher.stdout.on('data', (chunk) => { for (const line of chunk.toString().split('\n')) { if (line.trim()) { @@ -739,8 +739,8 @@ async def test_list_products(api_url): ## Future Work ### Phase 1: Core Implementation -- [ ] Implement `aspire resources` command -- [ ] Implement `aspire resources --watch` command +- [ ] Implement `aspire describe` command +- [ ] Implement `aspire describe --follow` command - [ ] Ensure `aspire run --detach` returns structured JSON - [ ] Update `aspire stop` to work reliably with detached instances @@ -757,7 +757,7 @@ async def test_list_products(api_url): ### Phase 4: Enhanced Features - [ ] Timeout configuration for `waitForResource` -- [ ] Resource filtering in `aspire resources` +- [ ] Resource filtering in `aspire describe` - [ ] Log streaming for debugging - [ ] Integration with test framework fixtures/hooks diff --git a/docs/specs/polyglot-apphost.md b/docs/specs/polyglot-apphost.md index d79e0f51a00..7206a3ac0fe 100644 --- a/docs/specs/polyglot-apphost.md +++ b/docs/specs/polyglot-apphost.md @@ -1469,10 +1469,10 @@ Use `dotnet run` to execute the CLI directly from source: ```bash # Create a new Python app -dotnet run --project /path/to/aspire/src/Aspire.Cli/Aspire.Cli.csproj -- init -l python +dotnet run --project /path/to/aspire/src/Aspire.Cli/Aspire.Cli.csproj -- init --language python # Create a new TypeScript app -dotnet run --project /path/to/aspire/src/Aspire.Cli/Aspire.Cli.csproj -- init -l typescript +dotnet run --project /path/to/aspire/src/Aspire.Cli/Aspire.Cli.csproj -- init --language typescript ``` The `-l` (or `--language`) flag specifies the target language for scaffolding. @@ -1495,7 +1495,7 @@ The `-d` (or `--debug`) flag enables additional diagnostic output, useful when d 2. **Testing code generators**: Modify your `ICodeGenerator` implementation, then run `aspire run` in a test app—the new generated code will be produced automatically. -3. **Testing language support**: Modify your `ILanguageSupport` implementation, then use `aspire init -l ` to test scaffolding or `aspire run` to test detection and execution. +3. **Testing language support**: Modify your `ILanguageSupport` implementation, then use `aspire init --language ` to test scaffolding or `aspire run` to test detection and execution. 4. **Inspecting generated code**: Check the `.modules/` folder in your test app to see the generated SDK files and verify they match your expectations. @@ -1503,8 +1503,8 @@ The `-d` (or `--debug`) flag enables additional diagnostic output, useful when d | Task | Command | |------|---------| -| Scaffold Python app | `dotnet run --project $ASPIRE_REPO_ROOT/src/Aspire.Cli/Aspire.Cli.csproj -- init -l python` | -| Scaffold TypeScript app | `dotnet run --project $ASPIRE_REPO_ROOT/src/Aspire.Cli/Aspire.Cli.csproj -- init -l typescript` | +| Scaffold Python app | `dotnet run --project $ASPIRE_REPO_ROOT/src/Aspire.Cli/Aspire.Cli.csproj -- init --language python` | +| Scaffold TypeScript app | `dotnet run --project $ASPIRE_REPO_ROOT/src/Aspire.Cli/Aspire.Cli.csproj -- init --language typescript` | | Run app | `dotnet run --project $ASPIRE_REPO_ROOT/src/Aspire.Cli/Aspire.Cli.csproj -- run` | | Run with debug output | `dotnet run --project $ASPIRE_REPO_ROOT/src/Aspire.Cli/Aspire.Cli.csproj -- run -d` | | Add integration | `dotnet run --project $ASPIRE_REPO_ROOT/src/Aspire.Cli/Aspire.Cli.csproj -- add` | diff --git a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs index 271c5db22c8..3e4e6fb8365 100644 --- a/src/Aspire.Cli/Agents/CommonAgentApplicators.cs +++ b/src/Aspire.Cli/Agents/CommonAgentApplicators.cs @@ -148,7 +148,7 @@ private static string NormalizeLineEndings(string content) """ --- name: aspire - description: "**WORKFLOW SKILL** - Orchestrates Aspire applications using the Aspire CLI and MCP tools for running, debugging, and managing distributed apps. USE FOR: aspire run, aspire stop, start aspire app, check aspire resources, list aspire integrations, debug aspire issues, view aspire logs, add aspire resource, aspire dashboard, update aspire apphost. DO NOT USE FOR: non-Aspire .NET apps (use dotnet CLI), container-only deployments (use docker/podman), Azure deployment after local testing (use azure-deploy skill). INVOKES: Aspire MCP tools (list_resources, list_integrations, list_structured_logs, get_doc, search_docs), bash for CLI commands. FOR SINGLE OPERATIONS: Use Aspire MCP tools directly for quick resource status or doc lookups." + description: "**WORKFLOW SKILL** - Orchestrates Aspire applications using the Aspire CLI and MCP tools for running, debugging, and managing distributed apps. USE FOR: aspire run, aspire stop, start aspire app, aspire describe, list aspire integrations, debug aspire issues, view aspire logs, add aspire resource, aspire dashboard, update aspire apphost. DO NOT USE FOR: non-Aspire .NET apps (use dotnet CLI), container-only deployments (use docker/podman), Azure deployment after local testing (use azure-deploy skill). INVOKES: Aspire MCP tools (list_resources, list_integrations, list_structured_logs, get_doc, search_docs), bash for CLI commands. FOR SINGLE OPERATIONS: Use Aspire MCP tools directly for quick resource status or doc lookups." --- # Aspire Skill diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 319f65f3ae3..72dce6dc6cd 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -19,6 +19,8 @@ namespace Aspire.Cli.Commands; internal sealed class AddCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.AppCommands; + private readonly IPackagingService _packagingService; private readonly IProjectLocator _projectLocator; private readonly IAddCommandPrompter _prompter; @@ -36,7 +38,7 @@ internal sealed class AddCommand : BaseCommand { Description = AddCommandStrings.ProjectArgumentDescription }; - private static readonly Option s_versionOption = new("--version", "-v") + private static readonly Option s_versionOption = new("--version") { Description = AddCommandStrings.VersionArgumentDescription }; diff --git a/src/Aspire.Cli/Commands/AgentCommand.cs b/src/Aspire.Cli/Commands/AgentCommand.cs index e0e35ee05d2..baa757f6987 100644 --- a/src/Aspire.Cli/Commands/AgentCommand.cs +++ b/src/Aspire.Cli/Commands/AgentCommand.cs @@ -16,6 +16,8 @@ namespace Aspire.Cli.Commands; /// internal sealed class AgentCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.ToolsAndConfiguration; + public AgentCommand( AgentMcpCommand mcpCommand, AgentInitCommand initCommand, diff --git a/src/Aspire.Cli/Commands/BaseCommand.cs b/src/Aspire.Cli/Commands/BaseCommand.cs index 16fabef28d1..632372d56b7 100644 --- a/src/Aspire.Cli/Commands/BaseCommand.cs +++ b/src/Aspire.Cli/Commands/BaseCommand.cs @@ -15,6 +15,13 @@ namespace Aspire.Cli.Commands; internal abstract class BaseCommand : Command { protected virtual bool UpdateNotificationsEnabled { get; } = true; + + /// + /// Gets the help group for this command. + /// When null, the command appears in the "Other Commands:" catch-all section. + /// + internal virtual HelpGroup HelpGroup => HelpGroup.None; + private readonly CliExecutionContext _executionContext; protected CliExecutionContext ExecutionContext => _executionContext; diff --git a/src/Aspire.Cli/Commands/CacheCommand.cs b/src/Aspire.Cli/Commands/CacheCommand.cs index ef69f774a60..30cee60ee3c 100644 --- a/src/Aspire.Cli/Commands/CacheCommand.cs +++ b/src/Aspire.Cli/Commands/CacheCommand.cs @@ -14,6 +14,8 @@ namespace Aspire.Cli.Commands; internal sealed class CacheCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.ToolsAndConfiguration; + public CacheCommand(IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, AspireCliTelemetry telemetry) : base("cache", CacheCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { diff --git a/src/Aspire.Cli/Commands/ConfigCommand.cs b/src/Aspire.Cli/Commands/ConfigCommand.cs index 7a927e6b365..3e1597ae53a 100644 --- a/src/Aspire.Cli/Commands/ConfigCommand.cs +++ b/src/Aspire.Cli/Commands/ConfigCommand.cs @@ -18,6 +18,8 @@ namespace Aspire.Cli.Commands; internal sealed class ConfigCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.ToolsAndConfiguration; + private readonly IConfiguration _configuration; private readonly IInteractionService _interactionService; diff --git a/src/Aspire.Cli/Commands/DeployCommand.cs b/src/Aspire.Cli/Commands/DeployCommand.cs index 398974babcd..765925e7047 100644 --- a/src/Aspire.Cli/Commands/DeployCommand.cs +++ b/src/Aspire.Cli/Commands/DeployCommand.cs @@ -16,6 +16,8 @@ namespace Aspire.Cli.Commands; internal sealed class DeployCommand : PipelineCommandBase { + internal override HelpGroup HelpGroup => HelpGroup.Deployment; + private readonly Option _clearCacheOption; public DeployCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) diff --git a/src/Aspire.Cli/Commands/ResourcesCommand.cs b/src/Aspire.Cli/Commands/DescribeCommand.cs similarity index 90% rename from src/Aspire.Cli/Commands/ResourcesCommand.cs rename to src/Aspire.Cli/Commands/DescribeCommand.cs index f1ce701b32f..1c5a4771b37 100644 --- a/src/Aspire.Cli/Commands/ResourcesCommand.cs +++ b/src/Aspire.Cli/Commands/DescribeCommand.cs @@ -65,45 +65,48 @@ internal sealed partial class ResourcesCommandJsonContext : JsonSerializerContex }); } -internal sealed class ResourcesCommand : BaseCommand +internal sealed class DescribeCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.Monitoring; + private readonly IInteractionService _interactionService; private readonly AppHostConnectionResolver _connectionResolver; private static readonly Argument s_resourceArgument = new("resource") { - Description = ResourcesCommandStrings.ResourceArgumentDescription, + Description = DescribeCommandStrings.ResourceArgumentDescription, Arity = ArgumentArity.ZeroOrOne }; private static readonly Option s_projectOption = new("--project") { - Description = ResourcesCommandStrings.ProjectOptionDescription + Description = DescribeCommandStrings.ProjectOptionDescription }; - private static readonly Option s_watchOption = new("--watch") + private static readonly Option s_followOption = new("--follow", "-f") { - Description = ResourcesCommandStrings.WatchOptionDescription + Description = DescribeCommandStrings.FollowOptionDescription }; private static readonly Option s_formatOption = new("--format") { - Description = ResourcesCommandStrings.JsonOptionDescription + Description = DescribeCommandStrings.JsonOptionDescription }; - public ResourcesCommand( + public DescribeCommand( IInteractionService interactionService, IAuxiliaryBackchannelMonitor backchannelMonitor, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, AspireCliTelemetry telemetry, - ILogger logger) - : base("resources", ResourcesCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) + ILogger logger) + : base("describe", DescribeCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { + Aliases.Add("resources"); _interactionService = interactionService; _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); Arguments.Add(s_resourceArgument); Options.Add(s_projectOption); - Options.Add(s_watchOption); + Options.Add(s_followOption); Options.Add(s_formatOption); } @@ -113,18 +116,18 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var resourceName = parseResult.GetValue(s_resourceArgument); var passedAppHostProjectFile = parseResult.GetValue(s_projectOption); - var watch = parseResult.GetValue(s_watchOption); + var follow = parseResult.GetValue(s_followOption); var format = parseResult.GetValue(s_formatOption); // When outputting JSON, suppress status messages to keep output machine-readable - var scanningMessage = format == OutputFormat.Json ? string.Empty : ResourcesCommandStrings.ScanningForRunningAppHosts; + var scanningMessage = format == OutputFormat.Json ? string.Empty : DescribeCommandStrings.ScanningForRunningAppHosts; var result = await _connectionResolver.ResolveConnectionAsync( passedAppHostProjectFile, scanningMessage, - ResourcesCommandStrings.SelectAppHost, - ResourcesCommandStrings.NoInScopeAppHostsShowingAll, - ResourcesCommandStrings.AppHostNotRunning, + DescribeCommandStrings.SelectAppHost, + DescribeCommandStrings.NoInScopeAppHostsShowingAll, + DescribeCommandStrings.AppHostNotRunning, cancellationToken); if (!result.Success) @@ -133,7 +136,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.Success; } - if (watch) + if (follow) { return await ExecuteWatchAsync(result.Connection!, resourceName, format, cancellationToken); } @@ -163,7 +166,7 @@ private async Task ExecuteSnapshotAsync(IAppHostAuxiliaryBackchannel connec // Check if resource was not found if (resourceName is not null && snapshots.Count == 0) { - _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ResourcesCommandStrings.ResourceNotFound, resourceName)); + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, DescribeCommandStrings.ResourceNotFound, resourceName)); return ExitCodeConstants.FailedToFindProject; } diff --git a/src/Aspire.Cli/Commands/DoCommand.cs b/src/Aspire.Cli/Commands/DoCommand.cs index 5555bec0ce3..f4d3bee3486 100644 --- a/src/Aspire.Cli/Commands/DoCommand.cs +++ b/src/Aspire.Cli/Commands/DoCommand.cs @@ -16,6 +16,8 @@ namespace Aspire.Cli.Commands; internal sealed class DoCommand : PipelineCommandBase { + internal override HelpGroup HelpGroup => HelpGroup.Deployment; + private readonly Argument _stepArgument; public DoCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) diff --git a/src/Aspire.Cli/Commands/DocsCommand.cs b/src/Aspire.Cli/Commands/DocsCommand.cs index c79d1eaefc5..1a66acc3428 100644 --- a/src/Aspire.Cli/Commands/DocsCommand.cs +++ b/src/Aspire.Cli/Commands/DocsCommand.cs @@ -16,6 +16,8 @@ namespace Aspire.Cli.Commands; /// internal sealed class DocsCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.ToolsAndConfiguration; + public DocsCommand( DocsListCommand listCommand, DocsSearchCommand searchCommand, diff --git a/src/Aspire.Cli/Commands/DoctorCommand.cs b/src/Aspire.Cli/Commands/DoctorCommand.cs index 16b0393dee7..622f7623f85 100644 --- a/src/Aspire.Cli/Commands/DoctorCommand.cs +++ b/src/Aspire.Cli/Commands/DoctorCommand.cs @@ -15,6 +15,8 @@ namespace Aspire.Cli.Commands; internal sealed class DoctorCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.ToolsAndConfiguration; + private readonly IEnvironmentChecker _environmentChecker; private readonly IAnsiConsole _ansiConsole; private static readonly Option s_formatOption = new("--format") diff --git a/src/Aspire.Cli/Commands/GroupedHelpAction.cs b/src/Aspire.Cli/Commands/GroupedHelpAction.cs new file mode 100644 index 00000000000..01ffaca0f6f --- /dev/null +++ b/src/Aspire.Cli/Commands/GroupedHelpAction.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Help; +using System.CommandLine.Invocation; +using Spectre.Console; + +namespace Aspire.Cli.Commands; + +/// +/// Replaces the default help action for the root command with grouped help output. +/// Falls back to default help for subcommands. +/// +internal sealed class GroupedHelpAction(Command rootCommand, IAnsiConsole ansiConsole) : SynchronousCommandLineAction +{ + public override int Invoke(ParseResult parseResult) + { + // Only use grouped help for the root command; subcommands get default help. + if (parseResult.CommandResult.Command == rootCommand) + { + var writer = ansiConsole.Profile.Out.Writer; + var consoleWidth = ansiConsole.Profile.Width; + GroupedHelpWriter.WriteHelp(rootCommand, writer, consoleWidth); + return 0; + } + + return new HelpAction().Invoke(parseResult); + } +} diff --git a/src/Aspire.Cli/Commands/GroupedHelpWriter.cs b/src/Aspire.Cli/Commands/GroupedHelpWriter.cs new file mode 100644 index 00000000000..a3a74cfada7 --- /dev/null +++ b/src/Aspire.Cli/Commands/GroupedHelpWriter.cs @@ -0,0 +1,304 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Aspire.Cli.Resources; + +namespace Aspire.Cli.Commands; + +/// +/// Writes grouped help output for the root command, organizing subcommands into logical categories. +/// Groups are determined by each command's property. +/// +internal static class GroupedHelpWriter +{ + /// + /// The well-known group ordering. Groups not listed here appear after these, in alphabetical order. + /// + private static readonly HelpGroup[] s_groupOrder = + [ + HelpGroup.AppCommands, + HelpGroup.ResourceManagement, + HelpGroup.Monitoring, + HelpGroup.Deployment, + HelpGroup.ToolsAndConfiguration, + ]; + + /// + /// Writes grouped help output for the given root command. + /// + /// The root command to generate help for. + /// The text writer to write help output to. + /// The maximum console width. When null, defaults to 80. + public static void WriteHelp(Command command, TextWriter writer, int? maxWidth = null) + { + var width = maxWidth ?? 80; + + // Description + if (!string.IsNullOrEmpty(command.Description)) + { + writer.WriteLine(command.Description); + writer.WriteLine(); + } + + // Usage + writer.WriteLine(HelpGroupStrings.Usage); + writer.WriteLine(GetIndent() + HelpGroupStrings.UsageSyntax); + writer.WriteLine(); + + // Collect visible subcommands and organize by group. + var grouped = new Dictionary>(); + var ungroupedCommands = new List(); + + foreach (var sub in command.Subcommands) + { + if (sub.Hidden) + { + continue; + } + + if (sub is BaseCommand baseCmd && baseCmd.HelpGroup is not HelpGroup.None) + { + if (!grouped.TryGetValue(baseCmd.HelpGroup, out var list)) + { + list = []; + grouped[baseCmd.HelpGroup] = list; + } + + list.Add(baseCmd); + } + else + { + ungroupedCommands.Add(sub); + } + } + + // Sort commands within each group by name. + foreach (var list in grouped.Values) + { + list.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + } + + // Compute the first-column width across all commands for consistent alignment. + var columnWidth = 0; + foreach (var list in grouped.Values) + { + foreach (var cmd in list) + { + var label = FormatCommandLabel(cmd); + if (label.Length > columnWidth) + { + columnWidth = label.Length; + } + } + } + + foreach (var cmd in ungroupedCommands) + { + var label = FormatCommandLabel(cmd); + if (label.Length > columnWidth) + { + columnWidth = label.Length; + } + } + + // Padding: 2 spaces indent + label + at least 2 spaces gap before description + columnWidth += 4; + + // Write groups in the defined order, then any additional groups alphabetically. + var writtenGroups = new HashSet(); + + foreach (var group in s_groupOrder) + { + if (grouped.TryGetValue(group, out var commands)) + { + WriteGroup(writer, GetGroupHeading(group), commands, columnWidth, width); + writtenGroups.Add(group); + } + } + + // Write any groups not in the well-known order (future-proofing). + foreach (var (group, commands) in grouped.OrderBy(kvp => kvp.Key)) + { + if (!writtenGroups.Contains(group)) + { + WriteGroup(writer, GetGroupHeading(group), commands, columnWidth, width); + } + } + + // Catch-all: show any registered commands not assigned to a group. + if (ungroupedCommands.Count > 0) + { + writer.WriteLine(HelpGroupStrings.OtherCommands); + foreach (var cmd in ungroupedCommands.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)) + { + var label = FormatCommandLabel(cmd); + var description = cmd.Description ?? string.Empty; + WriteTwoColumnRow(writer, label, description, columnWidth, width); + } + writer.WriteLine(); + } + + // Options + var visibleOptions = command.Options.Where(o => !o.Hidden).ToList(); + if (visibleOptions.Count > 0) + { + writer.WriteLine(HelpGroupStrings.Options); + + var optionColumnWidth = 0; + foreach (var opt in visibleOptions) + { + var label = FormatOptionLabel(opt); + if (label.Length > optionColumnWidth) + { + optionColumnWidth = label.Length; + } + } + + optionColumnWidth += 4; + + foreach (var opt in visibleOptions) + { + var label = FormatOptionLabel(opt); + var desc = opt.Description ?? string.Empty; + WriteTwoColumnRow(writer, label, desc, optionColumnWidth, width); + } + + writer.WriteLine(); + } + + // Help hint + writer.WriteLine(HelpGroupStrings.HelpHint); + } + + private static void WriteGroup(TextWriter writer, string heading, List commands, int columnWidth, int width) + { + writer.WriteLine(heading); + foreach (var cmd in commands) + { + var label = FormatCommandLabel(cmd); + var description = cmd.Description ?? string.Empty; + WriteTwoColumnRow(writer, label, description, columnWidth, width); + } + writer.WriteLine(); + } + + /// + /// Gets the localized heading string for a help group. + /// + internal static string GetGroupHeading(HelpGroup group) => group switch + { + HelpGroup.AppCommands => HelpGroupStrings.AppCommands, + HelpGroup.ResourceManagement => HelpGroupStrings.ResourceManagement, + HelpGroup.Monitoring => HelpGroupStrings.Monitoring, + HelpGroup.Deployment => HelpGroupStrings.Deployment, + HelpGroup.ToolsAndConfiguration => HelpGroupStrings.ToolsAndConfiguration, + _ => group.ToString(), + }; + + /// + /// Writes a two-column row with word-wrapping on the description column. + /// Continuation lines are indented to align with the description start. + /// + private const int IndentWidth = 2; + + private static string GetIndent(int extra = 0) => new(' ', IndentWidth + extra); + + private static void WriteTwoColumnRow(TextWriter writer, string label, string description, int columnWidth, int maxWidth) + { + var paddedLabel = label.PadRight(columnWidth); + var descriptionWidth = maxWidth - columnWidth - IndentWidth; + + // If the terminal is too narrow to wrap meaningfully, just write it all on one line. + if (descriptionWidth < 20) + { + writer.WriteLine($"{GetIndent()}{paddedLabel}{description}"); + return; + } + + var remaining = description.AsSpan(); + var firstLine = true; + + while (remaining.Length > 0) + { + if (firstLine) + { + writer.Write(GetIndent()); + writer.Write(paddedLabel); + firstLine = false; + } + else + { + // Continuation line: indent to align with description column. + writer.Write(GetIndent(columnWidth)); + } + + if (remaining.Length <= descriptionWidth) + { + writer.WriteLine(remaining); + break; + } + + // Find the last space within the allowed width for a clean word break. + var breakAt = remaining[..descriptionWidth].LastIndexOf(' '); + if (breakAt <= 0) + { + // No space found — hard break at the width limit. + breakAt = descriptionWidth; + } + + writer.WriteLine(remaining[..breakAt]); + remaining = remaining[breakAt..].TrimStart(); + } + } + + private static string FormatCommandLabel(Command cmd) + { + var args = GetArgumentSyntax(cmd); + return string.IsNullOrEmpty(args) ? cmd.Name : $"{cmd.Name} {args}"; + } + + private static string GetArgumentSyntax(Command cmd) + { + if (cmd.Arguments.Count == 0) + { + return string.Empty; + } + + var parts = new List(); + foreach (var arg in cmd.Arguments) + { + if (arg.Hidden) + { + continue; + } + + var name = $"<{arg.Name}>"; + + // Optional if minimum arity is 0. + if (arg.Arity.MinimumNumberOfValues == 0) + { + name = $"[{name}]"; + } + + parts.Add(name); + } + + return string.Join(" ", parts); + } + + private static string FormatOptionLabel(Option option) + { + // Collect all identifiers: Name may not be in Aliases in System.CommandLine 2.0. + var allNames = new HashSet(option.Aliases, StringComparer.Ordinal); + if (!string.IsNullOrEmpty(option.Name)) + { + allNames.Add(option.Name); + } + + var sorted = allNames.OrderBy(a => a.Length).ToList(); + return sorted.Count > 1 + ? $"{sorted[0]}, {sorted[1]}" + : sorted.Count > 0 ? sorted[0] : option.Name; + } +} diff --git a/src/Aspire.Cli/Commands/HelpGroups.cs b/src/Aspire.Cli/Commands/HelpGroups.cs new file mode 100644 index 00000000000..cb8af6cc997 --- /dev/null +++ b/src/Aspire.Cli/Commands/HelpGroups.cs @@ -0,0 +1,40 @@ +// 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.Commands; + +/// +/// Well-known help group categories for CLI commands. +/// +internal enum HelpGroup +{ + /// + /// No help group. The command appears in the "Other Commands:" catch-all section. + /// + None, + + /// + /// Commands for creating, running, and managing applications. + /// + AppCommands, + + /// + /// Commands for managing individual resources. + /// + ResourceManagement, + + /// + /// Commands for monitoring and observability. + /// + Monitoring, + + /// + /// Commands for deploying applications. + /// + Deployment, + + /// + /// Commands for CLI tools and configuration. + /// + ToolsAndConfiguration, +} diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 76f6caadbb7..cb255e3e0a0 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -25,6 +25,8 @@ namespace Aspire.Cli.Commands; internal sealed class InitCommand : BaseCommand, IPackageMetaPrefetchingCommand { + internal override HelpGroup HelpGroup => HelpGroup.AppCommands; + private readonly IDotNetCliRunner _runner; private readonly ICertificateService _certificateService; private readonly INewCommandPrompter _prompter; @@ -46,7 +48,7 @@ internal sealed class InitCommand : BaseCommand, IPackageMetaPrefetchingCommand Description = NewCommandStrings.SourceArgumentDescription, Recursive = true }; - private static readonly Option s_versionOption = new("--version", "-v") + private static readonly Option s_versionOption = new("--version") { Description = NewCommandStrings.VersionArgumentDescription, Recursive = true @@ -118,7 +120,7 @@ public InitCommand( // Only add --language option when polyglot support is enabled if (features.IsFeatureEnabled(KnownFeatures.PolyglotSupportEnabled, false)) { - _languageOption = new Option("--language", "-l") + _languageOption = new Option("--language") { Description = "The programming language for the AppHost (csharp, typescript)" }; diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs index 0a3b6e030d8..4530648261d 100644 --- a/src/Aspire.Cli/Commands/LogsCommand.cs +++ b/src/Aspire.Cli/Commands/LogsCommand.cs @@ -70,6 +70,8 @@ internal sealed partial class LogsCommandJsonContext : JsonSerializerContext internal sealed class LogsCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.Monitoring; + private readonly IInteractionService _interactionService; private readonly AppHostConnectionResolver _connectionResolver; private readonly ILogger _logger; diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index c350f575b93..fb336cc70d8 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -23,6 +23,8 @@ namespace Aspire.Cli.Commands; internal sealed class NewCommand : BaseCommand, IPackageMetaPrefetchingCommand { + internal override HelpGroup HelpGroup => HelpGroup.AppCommands; + private readonly IDotNetCliRunner _runner; private readonly INuGetPackageCache _nuGetPackageCache; private readonly ICertificateService _certificateService; @@ -51,7 +53,7 @@ internal sealed class NewCommand : BaseCommand, IPackageMetaPrefetchingCommand Description = NewCommandStrings.SourceArgumentDescription, Recursive = true }; - private static readonly Option s_versionOption = new("--version", "-v") + private static readonly Option s_versionOption = new("--version") { Description = NewCommandStrings.VersionArgumentDescription, Recursive = true @@ -118,7 +120,7 @@ public NewCommand( // Only add --language option when polyglot support is enabled if (_features.IsFeatureEnabled(KnownFeatures.PolyglotSupportEnabled, false)) { - _languageOption = new Option("--language", "-l") + _languageOption = new Option("--language") { Description = "The programming language for the AppHost (csharp, typescript)" }; diff --git a/src/Aspire.Cli/Commands/PsCommand.cs b/src/Aspire.Cli/Commands/PsCommand.cs index a4375a239dd..7367848a590 100644 --- a/src/Aspire.Cli/Commands/PsCommand.cs +++ b/src/Aspire.Cli/Commands/PsCommand.cs @@ -44,6 +44,8 @@ internal sealed partial class PsCommandJsonContext : JsonSerializerContext internal sealed class PsCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.AppCommands; + private readonly IInteractionService _interactionService; private readonly IAuxiliaryBackchannelMonitor _backchannelMonitor; private readonly ILogger _logger; diff --git a/src/Aspire.Cli/Commands/PublishCommand.cs b/src/Aspire.Cli/Commands/PublishCommand.cs index f7b50eb8f3b..99016f379c8 100644 --- a/src/Aspire.Cli/Commands/PublishCommand.cs +++ b/src/Aspire.Cli/Commands/PublishCommand.cs @@ -34,6 +34,8 @@ public virtual async Task PromptForPublisherAsync(IEnumerable pu internal sealed class PublishCommand : PipelineCommandBase { + internal override HelpGroup HelpGroup => HelpGroup.Deployment; + private readonly IPublishCommandPrompter _prompter; public PublishCommand(IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, IPublishCommandPrompter prompter, AspireCliTelemetry telemetry, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, ILogger logger, IAnsiConsole ansiConsole) diff --git a/src/Aspire.Cli/Commands/ResourceCommand.cs b/src/Aspire.Cli/Commands/ResourceCommand.cs index 7aac95fdf01..d96ecdc6df5 100644 --- a/src/Aspire.Cli/Commands/ResourceCommand.cs +++ b/src/Aspire.Cli/Commands/ResourceCommand.cs @@ -14,6 +14,8 @@ namespace Aspire.Cli.Commands; internal sealed class ResourceCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.ResourceManagement; + private readonly IInteractionService _interactionService; private readonly AppHostConnectionResolver _connectionResolver; private readonly ILogger _logger; diff --git a/src/Aspire.Cli/Commands/RestartCommand.cs b/src/Aspire.Cli/Commands/RestartCommand.cs index bfdb70693f9..9979d25241e 100644 --- a/src/Aspire.Cli/Commands/RestartCommand.cs +++ b/src/Aspire.Cli/Commands/RestartCommand.cs @@ -14,6 +14,8 @@ namespace Aspire.Cli.Commands; internal sealed class RestartCommand : ResourceCommandBase { + internal override HelpGroup HelpGroup => HelpGroup.ResourceManagement; + protected override string CommandName => KnownResourceCommands.RestartCommand; protected override string ProgressVerb => "Restarting"; protected override string BaseVerb => "restart"; diff --git a/src/Aspire.Cli/Commands/RootCommand.cs b/src/Aspire.Cli/Commands/RootCommand.cs index 6126c8d8761..b90785b87ba 100644 --- a/src/Aspire.Cli/Commands/RootCommand.cs +++ b/src/Aspire.Cli/Commands/RootCommand.cs @@ -4,6 +4,7 @@ using System.CommandLine; using System.CommandLine.Help; using Microsoft.Extensions.Logging; +using Spectre.Console; #if DEBUG using System.Globalization; @@ -25,10 +26,10 @@ internal sealed class RootCommand : BaseRootCommand { Description = RootCommandStrings.DebugArgumentDescription, Recursive = true, - Hidden = true // Hidden for backward compatibility, use --debug-level instead + Hidden = true // Hidden for backward compatibility, use --log-level instead }; - public static readonly Option DebugLevelOption = new("--debug-level", "-v") + public static readonly Option DebugLevelOption = new("--log-level", "-l") { Description = RootCommandStrings.DebugLevelArgumentDescription, Recursive = true @@ -36,7 +37,7 @@ internal sealed class RootCommand : BaseRootCommand public static readonly Option NonInteractiveOption = new(CommonOptionNames.NonInteractive) { - Description = "Run the command in non-interactive mode, disabling all interactive prompts and spinners", + Description = RootCommandStrings.NonInteractiveArgumentDescription, Recursive = true }; @@ -77,7 +78,7 @@ private static readonly (Option Option, Func GetArgs)[] (DebugLevelOption, pr => { var level = pr.GetValue(DebugLevelOption); - return level.HasValue ? ["--debug-level", level.Value.ToString()] : null; + return level.HasValue ? ["--log-level", level.Value.ToString()] : null; }), (WaitForDebuggerOption, pr => pr.GetValue(WaitForDebuggerOption) ? ["--wait-for-debugger"] : null), ]; @@ -103,6 +104,7 @@ public static IEnumerable GetChildProcessArgs(ParseResult parseResult) } private readonly IInteractionService _interactionService; + private readonly IAnsiConsole _ansiConsole; public RootCommand( NewCommand newCommand, @@ -114,7 +116,7 @@ public RootCommand( WaitCommand waitCommand, ResourceCommand commandCommand, PsCommand psCommand, - ResourcesCommand resourcesCommand, + DescribeCommand describeCommand, LogsCommand logsCommand, AddCommand addCommand, PublishCommand publishCommand, @@ -134,10 +136,12 @@ public RootCommand( ExtensionInternalCommand extensionInternalCommand, IBundleService bundleService, IFeatures featureFlags, - IInteractionService interactionService) + IInteractionService interactionService, + IAnsiConsole ansiConsole) : base(RootCommandStrings.Description) { _interactionService = interactionService; + _ansiConsole = ansiConsole; #if DEBUG CliWaitForDebuggerOption.Validators.Add((result) => @@ -180,9 +184,10 @@ public RootCommand( return Task.FromResult(ExitCodeConstants.Success); } - // No subcommand provided - show help but return InvalidCommand to signal usage error - // This is consistent with other parent commands (DocsCommand, SdkCommand, etc.) - new HelpAction().Invoke(context); + // No subcommand provided - show grouped help but return InvalidCommand to signal usage error + var writer = _ansiConsole.Profile.Out.Writer; + var consoleWidth = _ansiConsole.Profile.Width; + GroupedHelpWriter.WriteHelp(this, writer, consoleWidth); return Task.FromResult(ExitCodeConstants.InvalidCommand); }); @@ -195,7 +200,7 @@ public RootCommand( Subcommands.Add(waitCommand); Subcommands.Add(commandCommand); Subcommands.Add(psCommand); - Subcommands.Add(resourcesCommand); + Subcommands.Add(describeCommand); Subcommands.Add(logsCommand); Subcommands.Add(addCommand); Subcommands.Add(publishCommand); @@ -226,5 +231,19 @@ public RootCommand( Subcommands.Add(sdkCommand); } + // Replace the default --help action with grouped help output. + // Add -v as a short alias for --version. + foreach (var option in Options) + { + if (option is HelpOption helpOption) + { + helpOption.Action = new GroupedHelpAction(this, _ansiConsole); + } + else if (option is VersionOption versionOption) + { + versionOption.Aliases.Add("-v"); + } + } + } } diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 2193657b9e1..7d6ee47390f 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -53,6 +53,8 @@ internal sealed partial class RunCommandJsonContext : JsonSerializerContext internal sealed class RunCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.AppCommands; + private readonly IDotNetCliRunner _runner; private readonly IInteractionService _interactionService; private readonly ICertificateService _certificateService; diff --git a/src/Aspire.Cli/Commands/Sdk/SdkCommand.cs b/src/Aspire.Cli/Commands/Sdk/SdkCommand.cs index b3d15e46496..93705c0246d 100644 --- a/src/Aspire.Cli/Commands/Sdk/SdkCommand.cs +++ b/src/Aspire.Cli/Commands/Sdk/SdkCommand.cs @@ -26,6 +26,7 @@ public SdkCommand( AspireCliTelemetry telemetry) : base("sdk", "Commands for generating SDKs for building Aspire integrations in other languages.", features, updateNotifier, executionContext, interactionService, telemetry) { + Hidden = true; Subcommands.Add(generateCommand); Subcommands.Add(dumpCommand); } diff --git a/src/Aspire.Cli/Commands/SetupCommand.cs b/src/Aspire.Cli/Commands/SetupCommand.cs index f82e5d53093..1576b333ea7 100644 --- a/src/Aspire.Cli/Commands/SetupCommand.cs +++ b/src/Aspire.Cli/Commands/SetupCommand.cs @@ -36,6 +36,8 @@ public SetupCommand( AspireCliTelemetry telemetry) : base("setup", "Extract the embedded bundle to set up the Aspire CLI runtime.", features, updateNotifier, executionContext, interactionService, telemetry) { + // Hidden: the setup command is an implementation detail used by install scripts. + Hidden = true; _bundleService = bundleService; Options.Add(s_installPathOption); diff --git a/src/Aspire.Cli/Commands/StartCommand.cs b/src/Aspire.Cli/Commands/StartCommand.cs index 46f2d88f244..82f2d17ffb7 100644 --- a/src/Aspire.Cli/Commands/StartCommand.cs +++ b/src/Aspire.Cli/Commands/StartCommand.cs @@ -14,6 +14,8 @@ namespace Aspire.Cli.Commands; internal sealed class StartCommand : ResourceCommandBase { + internal override HelpGroup HelpGroup => HelpGroup.ResourceManagement; + protected override string CommandName => KnownResourceCommands.StartCommand; protected override string ProgressVerb => "Starting"; protected override string BaseVerb => "start"; diff --git a/src/Aspire.Cli/Commands/StopCommand.cs b/src/Aspire.Cli/Commands/StopCommand.cs index f74f1ae9a62..4b4c4a2dc8d 100644 --- a/src/Aspire.Cli/Commands/StopCommand.cs +++ b/src/Aspire.Cli/Commands/StopCommand.cs @@ -18,6 +18,8 @@ namespace Aspire.Cli.Commands; internal sealed class StopCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.AppCommands; + private readonly IInteractionService _interactionService; private readonly AppHostConnectionResolver _connectionResolver; private readonly ILogger _logger; diff --git a/src/Aspire.Cli/Commands/TelemetryCommand.cs b/src/Aspire.Cli/Commands/TelemetryCommand.cs index b6127e903c9..8bb7b69d966 100644 --- a/src/Aspire.Cli/Commands/TelemetryCommand.cs +++ b/src/Aspire.Cli/Commands/TelemetryCommand.cs @@ -16,6 +16,8 @@ namespace Aspire.Cli.Commands; /// internal sealed class TelemetryCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.Monitoring; + public TelemetryCommand( TelemetryLogsCommand logsCommand, TelemetrySpansCommand spansCommand, @@ -25,7 +27,7 @@ public TelemetryCommand( ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, AspireCliTelemetry telemetry) - : base("telemetry", TelemetryCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) + : base("otel", TelemetryCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { ArgumentNullException.ThrowIfNull(logsCommand); ArgumentNullException.ThrowIfNull(spansCommand); diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index ee6fb888ca7..b177b538f49 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -20,6 +20,8 @@ namespace Aspire.Cli.Commands; internal sealed class UpdateCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.AppCommands; + private readonly IProjectLocator _projectLocator; private readonly IPackagingService _packagingService; private readonly IAppHostProjectFactory _projectFactory; diff --git a/src/Aspire.Cli/Commands/WaitCommand.cs b/src/Aspire.Cli/Commands/WaitCommand.cs index ca89929a294..76deca53183 100644 --- a/src/Aspire.Cli/Commands/WaitCommand.cs +++ b/src/Aspire.Cli/Commands/WaitCommand.cs @@ -15,6 +15,8 @@ namespace Aspire.Cli.Commands; internal sealed class WaitCommand : BaseCommand { + internal override HelpGroup HelpGroup => HelpGroup.ResourceManagement; + private readonly IInteractionService _interactionService; private readonly AppHostConnectionResolver _connectionResolver; private readonly ILogger _logger; diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index ebc0c3ea7c1..14d7798d23b 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -69,11 +69,11 @@ private static (LogLevel? ConsoleLogLevel, bool DebugMode) ParseLoggingOptions(s // Check for --debug or -d (backward compatibility) var debugMode = args.Any(a => a == "--debug" || a == "-d"); - // Check for --debug-level or -v + // Check for --log-level or -l LogLevel? logLevel = null; for (var i = 0; i < args.Length; i++) { - if ((args[i] == "--debug-level" || args[i] == "-v") && i + 1 < args.Length) + if ((args[i] == "--log-level" || args[i] == "-l") && i + 1 < args.Length) { if (Enum.TryParse(args[i + 1], ignoreCase: true, out var parsedLevel)) { @@ -375,7 +375,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); - builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Properties/launchSettings.json b/src/Aspire.Cli/Properties/launchSettings.json index 76e92970a0e..8cd818ff2e0 100644 --- a/src/Aspire.Cli/Properties/launchSettings.json +++ b/src/Aspire.Cli/Properties/launchSettings.json @@ -56,6 +56,11 @@ "dotnetRunMessages": true, "commandLineArgs": "new -d" }, + "help": { + "commandName": "Project", + "dotnetRunMessages": true, + "commandLineArgs": "--help" + }, "run-testapphost": { "commandName": "Project", "dotnetRunMessages": true, diff --git a/src/Aspire.Cli/README.md b/src/Aspire.Cli/README.md index 88a26557290..ded5359d444 100644 --- a/src/Aspire.Cli/README.md +++ b/src/Aspire.Cli/README.md @@ -1,261 +1,119 @@ -# Aspire CLI Command Reference +# Aspire CLI -The Aspire CLI is used to create, run, and publish Aspire-based applications. The CLI is primarily interactive, providing prompts and guidance for most operations. +The Aspire CLI is used to create, run, and manage Aspire-based distributed applications. ## Usage -```cli -aspire [command] [options] +```text +aspire [options] ``` ## Global Options -- `-d, --debug` - Enable debug logging to the console -- `--wait-for-debugger` - Wait for a debugger to attach before executing the command -- `-?, -h, --help` - Show help and usage information -- `--version` - Show version information +| Option | Description | +|--------|-------------| +| `-h, /h` | Show help and usage information. | +| `-v, --version` | Show version information. | +| `-l, --log-level` | Set the minimum log level for console output (Trace, Debug, Information, Warning, Error, Critical). | +| `--non-interactive` | Run the command in non-interactive mode, disabling all interactive prompts and spinners. | +| `--nologo` | Suppress the startup banner and telemetry notice. | +| `--banner` | Display the animated Aspire CLI welcome banner. | +| `--wait-for-debugger` | Wait for a debugger to attach before executing the command. | ## Commands -### run +### App Commands -Run an Aspire app host in development mode. +| Command | Description | +|---------|-------------| +| `new` | Create a new app from an Aspire starter template. | +| `init` | Initialize Aspire in an existing codebase. | +| `add []` | Add a hosting integration to the apphost. | +| `update` | Update integrations in the Aspire project. (Preview) | +| `run` | Run an apphost in development mode. | +| `stop` | Stop a running apphost or the specified resource. | +| `ps` | List running apphosts. | -```cli -aspire run [options] [[--] ...] -``` - -**Options:** -- `--project` - The path to the Aspire app host project file -- `-w, --watch` - Start project resources in watch mode - -**Additional Arguments:** -Arguments passed to the application that is being run. - -**Description:** -Starts the Aspire app host. If no project is specified, it looks in the current directory for a *.csproj file. It will error if it can't find a .csproj, or if there are multiple in the directory. - -### new - -Create a new Aspire project. - -```cli -aspire new [command] [options] -``` +### Resource Management -**Options:** -- `-n, --name` - The name of the project to create -- `-o, --output` - The output path for the project -- `-s, --source` - The NuGet source to use for the project templates -- `-v, --version` - The version of the project templates to use +| Command | Description | +|---------|-------------| +| `start ` | Start a stopped resource. | +| `stop []` | Stop a running apphost or the specified resource. | +| `restart ` | Restart a running resource. | +| `wait ` | Wait for a resource to reach a target status. | +| `command ` | Execute a command on a resource. | -**Description:** -Creates a new Aspire project through an interactive template selection process. Pulls the latest Aspire templates and creates the project using `dotnet new`. +### Monitoring -### add +| Command | Description | +|---------|-------------| +| `describe []` | Describe resources in a running apphost. | +| `logs []` | Display logs from resources in a running apphost. | +| `otel` | View OpenTelemetry data (logs, spans, traces) from a running apphost. | -Add an integration to the Aspire project. +### Deployment -```cli -aspire add [] [options] -``` - -**Arguments:** -- `` - The name of the integration to add (e.g. redis, postgres) - -**Options:** -- `--project` - The path to the project file to add the integration to -- `-v, --version` - The version of the integration to add -- `-s, --source` - The NuGet source to use for the integration - -**Description:** -Adds an Aspire integration package to the project. If no integration name is provided, displays a selection prompt with available integrations. Integrations are given friendly names based on the package ID (e.g., `Aspire.Hosting.Redis` can be referenced as `redis`). - -### publish - -Generates deployment artifacts for an Aspire app host project. (Preview) - -```cli -aspire publish [options] [[--] ...] -``` +| Command | Description | +|---------|-------------| +| `publish` | Generate deployment artifacts for an apphost. (Preview) | +| `deploy` | Deploy an apphost to its deployment targets. (Preview) | +| `do ` | Execute a specific pipeline step and its dependencies. (Preview) | -**Options:** -- `--project` - The path to the Aspire app host project file -- `-o, --output-path` - The output path for the generated artifacts +### Tools & Configuration -**Additional Arguments:** -Arguments passed to the application that is being run. +| Command | Description | +|---------|-------------| +| `config` | Manage CLI configuration including feature flags. | +| `cache` | Manage disk cache for CLI operations. | +| `doctor` | Diagnose Aspire environment issues and verify setup. | +| `docs` | Browse and search Aspire documentation from aspire.dev. | +| `agent` | Manage AI agent specific setup. | -**Description:** -Generates deployment artifacts for the Aspire app host project using the default publisher. +## Examples -### deploy +```bash +# Create a new Aspire application +aspire new -Deploy an Aspire app host project to its supported deployment targets. (Preview) +# Run the apphost +aspire run -```cli -aspire deploy [options] [[--] ...] -``` - -**Options:** -- `--project` - The path to the Aspire app host project file -- `-o, --output-path` - The output path for deployment artifacts - -**Additional Arguments:** -Arguments passed to the application that is being run. - -**Description:** -Deploys an Aspire app host project to its supported deployment targets. Generates deployment artifacts and initiates the deployment process. - -### exec - -Run an Aspire app host to execute a command against the resource. (Preview) - -```cli -aspire exec [options] [[--] ...] -``` - -**Options:** -- `--project` - The path to the Aspire app host project file -- `-r, --resource` - The name of the target resource to execute the command against -- `-s, --start-resource` - The name of the target resource to start and execute the command against - -**Additional Arguments:** -Arguments passed to the application that is being run. - -**Description:** -Runs the Aspire app host and executes a command against a specified resource. Use either `--resource` for an existing resource or `--start-resource` to start a resource and then execute the command. - -### wait - -Wait for a resource to reach a target status. - -```cli -aspire wait [options] -``` +# Run in the background (useful for CI and agent environments) +aspire run --detach --isolated -**Arguments:** -- `` - The name of the resource to wait for +# Check resource status +aspire describe -**Options:** -- `--status` - The target status to wait for: `healthy`, `up`, `down` (default: `healthy`) -- `--timeout` - Maximum time to wait in seconds (default: 120) -- `--project` - The path to the Aspire AppHost project file +# Stream resource state changes +aspire describe --follow -**Description:** -Blocks until the specified resource reaches the desired status or the timeout is exceeded. Useful in CI/CD pipelines and scripts after starting an AppHost with `aspire run --detach`. +# View logs +aspire logs +aspire logs webapi -**Status values:** -- `healthy` (default) - Resource is running and all health checks pass (or no health checks registered) -- `up` - Resource is running, regardless of health check status -- `down` - Resource has exited, finished, or failed to start +# Stop the apphost +aspire stop -**Exit codes:** -- `0` - Resource reached the desired status -- `17` - Timeout exceeded -- `18` - Resource entered a terminal failure state while waiting for healthy/up -- `7` - Failed to find or connect to AppHost - -**Example:** -```cli -# Start an AppHost in the background, then wait for a resource +# Wait for a resource to be healthy (CI/scripts) aspire run --detach -aspire wait webapi -aspire wait redis --status up --timeout 60 -aspire wait worker --status down -``` - -### update - -Update integrations in the Aspire project. (Preview) - -```cli -aspire update [options] -``` - -**Options:** -- `--project` - The path to the project file -- `--self` - Update the Aspire CLI itself to the latest version -- `--quality ` - Quality level to update to when using --self (stable, staging, daily) - -**Description:** -Updates Aspire integration packages to their latest compatible versions. Supports both traditional package management (PackageReference with Version) and Central Package Management (CPM) using Directory.Packages.props. The command automatically detects the package management approach used in the project and updates packages accordingly. - -When using `--self`, the CLI will update itself to the latest available version for the current platform. The command automatically detects the operating system and architecture, downloads the appropriate CLI package, validates its checksum, and performs an in-place update with automatic backup and rollback on failure. If the quality level is not specified with `--self`, an interactive prompt will appear to select from the available options. - -**Quality Levels (for --self):** -- `stable` - Latest stable release version -- `staging` - Latest release candidate/staging version -- `daily` - Latest development build from main branch - -**Example:** -```cli -# Update project integrations -aspire update - -# Update CLI with interactive quality selection -aspire update --self - -# Update CLI to latest stable release -aspire update --self --quality stable - -# Update CLI to latest development build -aspire update --self --quality daily -``` +aspire wait webapi --timeout 60 -### config +# Add an integration +aspire add redis -Manage configuration settings. +# Diagnose environment issues +aspire doctor -```cli -aspire config [command] [options] +# Search Aspire documentation +aspire docs search "redis" ``` -**Subcommands:** - -#### get -Get a configuration value. - -```cli -aspire config get -``` - -**Arguments:** -- `` - The configuration key to get - -#### set -Set a configuration value. - -```cli -aspire config set [options] -``` - -**Arguments:** -- `` - The configuration key to set -- `` - The configuration value to set - -**Options:** -- `-g, --global` - Set the configuration value globally in `$HOME/.aspire/settings.json` instead of the local settings file - -#### list -List all configuration values. - -```cli -aspire config list -``` - -#### delete -Delete a configuration value. - -```cli -aspire config delete [options] -``` +## Additional documentation -**Arguments:** -- `` - The configuration key to delete +* https://aspire.dev +* https://learn.microsoft.com/dotnet/aspire -**Options:** -- `-g, --global` - Delete the configuration value from the global settings file instead of the local settings file +## Feedback & contributing -**Description:** -Manages CLI configuration settings. Configuration can be set locally (per project) or globally (user-wide). Local settings are stored in the current directory, while global settings are stored in `$HOME/.aspire/settings.json`. \ No newline at end of file +https://github.com/dotnet/aspire \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/AddCommandStrings.resx b/src/Aspire.Cli/Resources/AddCommandStrings.resx index fd33eb60df7..b2709ce9fba 100644 --- a/src/Aspire.Cli/Resources/AddCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AddCommandStrings.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Add a hosting integration to the Aspire AppHost. + Add a hosting integration to the apphost. The name of the integration to add (e.g. redis, postgres). diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.resx b/src/Aspire.Cli/Resources/AgentCommandStrings.resx index d20104fd57b..704169f984c 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.resx @@ -61,7 +61,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Manage AI agent integrations. + Manage AI agent specific setup. Start the MCP (Model Context Protocol) server. diff --git a/src/Aspire.Cli/Resources/ConfigCommandStrings.resx b/src/Aspire.Cli/Resources/ConfigCommandStrings.resx index ec4f96eb718..93f43755fca 100644 --- a/src/Aspire.Cli/Resources/ConfigCommandStrings.resx +++ b/src/Aspire.Cli/Resources/ConfigCommandStrings.resx @@ -107,7 +107,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Manage configuration settings. + Manage CLI configuration including feature flags. Get a configuration value. diff --git a/src/Aspire.Cli/Resources/DeployCommandStrings.resx b/src/Aspire.Cli/Resources/DeployCommandStrings.resx index cc971dad4cc..21d4efcc93c 100644 --- a/src/Aspire.Cli/Resources/DeployCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DeployCommandStrings.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Deploy the contents of an Aspire apphost to its defined deployment targets. (Preview) + Deploy an apphost to its deployment targets. (Preview) The optional output path for deployment artifacts. diff --git a/src/Aspire.Cli/Resources/ResourcesCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DescribeCommandStrings.Designer.cs similarity index 90% rename from src/Aspire.Cli/Resources/ResourcesCommandStrings.Designer.cs rename to src/Aspire.Cli/Resources/DescribeCommandStrings.Designer.cs index a46382e537f..39da716f1f3 100644 --- a/src/Aspire.Cli/Resources/ResourcesCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/DescribeCommandStrings.Designer.cs @@ -14,21 +14,21 @@ namespace Aspire.Cli.Resources { [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] [System.Diagnostics.DebuggerNonUserCodeAttribute()] [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - public class ResourcesCommandStrings { + public class DescribeCommandStrings { private static System.Resources.ResourceManager resourceMan; private static System.Globalization.CultureInfo resourceCulture; [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal ResourcesCommandStrings() { + internal DescribeCommandStrings() { } [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] public static System.Resources.ResourceManager ResourceManager { get { if (object.Equals(null, resourceMan)) { - System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Aspire.Cli.Resources.ResourcesCommandStrings", typeof(ResourcesCommandStrings).Assembly); + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Aspire.Cli.Resources.DescribeCommandStrings", typeof(DescribeCommandStrings).Assembly); resourceMan = temp; } return resourceMan; @@ -57,9 +57,9 @@ public static string ProjectOptionDescription { } } - public static string WatchOptionDescription { + public static string FollowOptionDescription { get { - return ResourceManager.GetString("WatchOptionDescription", resourceCulture); + return ResourceManager.GetString("FollowOptionDescription", resourceCulture); } } diff --git a/src/Aspire.Cli/Resources/ResourcesCommandStrings.resx b/src/Aspire.Cli/Resources/DescribeCommandStrings.resx similarity index 95% rename from src/Aspire.Cli/Resources/ResourcesCommandStrings.resx rename to src/Aspire.Cli/Resources/DescribeCommandStrings.resx index 31a6b19f367..b345d1b1845 100644 --- a/src/Aspire.Cli/Resources/ResourcesCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DescribeCommandStrings.resx @@ -118,16 +118,16 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Display resource snapshots from a running Aspire apphost. + Describe resources in a running apphost. The path to the Aspire AppHost project file. - - Stream resource snapshots as they change (NDJSON format when used with --json). + + Continuously stream resource state changes. - Output in JSON format for machine consumption. + Output format (Table or Json). No AppHost project found. diff --git a/src/Aspire.Cli/Resources/DoctorCommandStrings.resx b/src/Aspire.Cli/Resources/DoctorCommandStrings.resx index 07144cd95cd..79923adbf16 100644 --- a/src/Aspire.Cli/Resources/DoctorCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DoctorCommandStrings.resx @@ -59,10 +59,10 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Diagnose Aspire environment issues and verify setup + Diagnose Aspire environment issues and verify setup. - Output results as JSON for tooling integration + Output format (Table or Json). Aspire Environment Check diff --git a/src/Aspire.Cli/Resources/HelpGroupStrings.Designer.cs b/src/Aspire.Cli/Resources/HelpGroupStrings.Designer.cs new file mode 100644 index 00000000000..3170464bed9 --- /dev/null +++ b/src/Aspire.Cli/Resources/HelpGroupStrings.Designer.cs @@ -0,0 +1,149 @@ +//------------------------------------------------------------------------------ +// +// 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.Cli.Resources { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the MSBuild WriteCodeFragment class. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class HelpGroupStrings { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal HelpGroupStrings() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Aspire.Cli.Resources.HelpGroupStrings", typeof(HelpGroupStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to App Commands:. + /// + internal static string AppCommands { + get { + return ResourceManager.GetString("AppCommands", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Deployment:. + /// + internal static string Deployment { + get { + return ResourceManager.GetString("Deployment", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Monitoring:. + /// + internal static string Monitoring { + get { + return ResourceManager.GetString("Monitoring", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Resource Management:. + /// + internal static string ResourceManagement { + get { + return ResourceManager.GetString("ResourceManagement", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Tools & Configuration:. + /// + internal static string ToolsAndConfiguration { + get { + return ResourceManager.GetString("ToolsAndConfiguration", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Usage:. + /// + internal static string Usage { + get { + return ResourceManager.GetString("Usage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to aspire <command> [options]. + /// + internal static string UsageSyntax { + get { + return ResourceManager.GetString("UsageSyntax", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Other commands:. + /// + internal static string OtherCommands { + get { + return ResourceManager.GetString("OtherCommands", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Options:. + /// + internal static string Options { + get { + return ResourceManager.GetString("Options", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Use "aspire <command> --help" for more information about a command.. + /// + internal static string HelpHint { + get { + return ResourceManager.GetString("HelpHint", resourceCulture); + } + } + } +} diff --git a/src/Aspire.Cli/Resources/HelpGroupStrings.resx b/src/Aspire.Cli/Resources/HelpGroupStrings.resx new file mode 100644 index 00000000000..0ae3322384f --- /dev/null +++ b/src/Aspire.Cli/Resources/HelpGroupStrings.resx @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + App commands: + + + Resource management: + + + Monitoring: + + + Deployment: + + + Tools & configuration: + + + Usage: + + + aspire <command> [options] + + + Other commands: + + + Options: + + + Use "aspire <command> --help" for more information about a command. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/InitCommandStrings.resx b/src/Aspire.Cli/Resources/InitCommandStrings.resx index e1814ce2728..23977ff23b0 100644 --- a/src/Aspire.Cli/Resources/InitCommandStrings.resx +++ b/src/Aspire.Cli/Resources/InitCommandStrings.resx @@ -61,7 +61,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Initialize Aspire support in an existing solution or create a single-file AppHost. + Initialize Aspire in an existing codebase. Solution detected: {0} diff --git a/src/Aspire.Cli/Resources/LogsCommandStrings.resx b/src/Aspire.Cli/Resources/LogsCommandStrings.resx index 6cb3ba652d5..ffc9ec5eb81 100644 --- a/src/Aspire.Cli/Resources/LogsCommandStrings.resx +++ b/src/Aspire.Cli/Resources/LogsCommandStrings.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Display logs from resources in a running Aspire apphost. + Display logs from resources in a running apphost. The name of the resource to get logs for. If not specified, logs from all resources are shown. @@ -130,7 +130,7 @@ Stream logs in real-time as they are written. - Output logs in JSON format (NDJSON). + Output format (Table or Json). No running AppHost found. Use 'aspire run' to start one first. diff --git a/src/Aspire.Cli/Resources/NewCommandStrings.resx b/src/Aspire.Cli/Resources/NewCommandStrings.resx index 88ae14b614d..cb10375dc53 100644 --- a/src/Aspire.Cli/Resources/NewCommandStrings.resx +++ b/src/Aspire.Cli/Resources/NewCommandStrings.resx @@ -107,7 +107,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Create a new Aspire project. + Create a new app from an Aspire starter template. The name of the project to create. diff --git a/src/Aspire.Cli/Resources/PsCommandStrings.resx b/src/Aspire.Cli/Resources/PsCommandStrings.resx index 76bbf3e5731..9dfa01a78ce 100644 --- a/src/Aspire.Cli/Resources/PsCommandStrings.resx +++ b/src/Aspire.Cli/Resources/PsCommandStrings.resx @@ -118,10 +118,10 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - List running Aspire apphosts. + List running apphosts. - Output in JSON format. + Output format (Table or Json). Scanning for running AppHosts... diff --git a/src/Aspire.Cli/Resources/PublishCommandStrings.resx b/src/Aspire.Cli/Resources/PublishCommandStrings.resx index 56757121332..2c9fd7d80aa 100644 --- a/src/Aspire.Cli/Resources/PublishCommandStrings.resx +++ b/src/Aspire.Cli/Resources/PublishCommandStrings.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Generates deployment artifacts for an Aspire apphost. (Preview) + Generate deployment artifacts for an apphost. (Preview) Select a publisher: diff --git a/src/Aspire.Cli/Resources/RootCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/RootCommandStrings.Designer.cs index 79b40d5a3d1..e98fa07eb26 100644 --- a/src/Aspire.Cli/Resources/RootCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/RootCommandStrings.Designer.cs @@ -155,6 +155,15 @@ public static string NoLogoArgumentDescription { } } + /// + /// Looks up a localized string similar to Run the command in non-interactive mode, disabling all interactive prompts and spinners.. + /// + public static string NonInteractiveArgumentDescription { + get { + return ResourceManager.GetString("NonInteractiveArgumentDescription", resourceCulture); + } + } + /// /// Looks up a localized string similar to Wait for a debugger to attach before executing the command.. /// diff --git a/src/Aspire.Cli/Resources/RootCommandStrings.resx b/src/Aspire.Cli/Resources/RootCommandStrings.resx index 0e812a74fe1..606443c7bde 100644 --- a/src/Aspire.Cli/Resources/RootCommandStrings.resx +++ b/src/Aspire.Cli/Resources/RootCommandStrings.resx @@ -147,6 +147,9 @@ Waiting for debugger to attach to CLI process ID: {0} + + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + Welcome to Aspire! Learn more about Aspire at https://aspire.dev diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.resx b/src/Aspire.Cli/Resources/RunCommandStrings.resx index a5fabb4943d..70c58a28d54 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.resx +++ b/src/Aspire.Cli/Resources/RunCommandStrings.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Run an Aspire apphost in development mode. + Run an apphost in development mode. Stop any running instance of the AppHost without prompting. @@ -130,7 +130,7 @@ Run the AppHost in the background and exit after it starts. - Output result as JSON (only valid with --detach). + Output format (Table or Json). Only valid with --detach. Start project resources in watch mode. diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx index c79dbbf0118..03fef3f6d81 100644 --- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - View telemetry data (logs, spans, traces) from a running Aspire application. + View OpenTelemetry data (logs, spans, traces) from a running apphost. View structured logs from the Dashboard telemetry API. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf index 5036ddf9990..63d775c39d3 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.cs.xlf @@ -8,8 +8,8 @@ - Add a hosting integration to the Aspire AppHost. - Přidejte do hostitele aplikací Aspire integraci hostování. + Add a hosting integration to the apphost. + Přidejte do hostitele aplikací Aspire integraci hostování. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf index 59536adfd77..0da6d27502e 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.de.xlf @@ -8,8 +8,8 @@ - Add a hosting integration to the Aspire AppHost. - Fügen Sie dem Aspire AppHost eine Hosting-Integration hinzu. + Add a hosting integration to the apphost. + Fügen Sie dem Aspire AppHost eine Hosting-Integration hinzu. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf index 194e818c0a8..0fb533ad516 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.es.xlf @@ -8,8 +8,8 @@ - Add a hosting integration to the Aspire AppHost. - Agregue una integración de hospedaje a Aspire AppHost. + Add a hosting integration to the apphost. + Agregue una integración de hospedaje a Aspire AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf index 8ff5b50ac4b..5995efd7bb9 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.fr.xlf @@ -8,8 +8,8 @@ - Add a hosting integration to the Aspire AppHost. - Ajoutez une intégration d’hébergement à l’Aspire AppHost. + Add a hosting integration to the apphost. + Ajoutez une intégration d’hébergement à l’Aspire AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf index c8ca4a9f65f..a57d5667acd 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.it.xlf @@ -8,8 +8,8 @@ - Add a hosting integration to the Aspire AppHost. - Aggiungere un'integrazione di hosting all'AppHost Aspire. + Add a hosting integration to the apphost. + Aggiungere un'integrazione di hosting all'AppHost Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf index 553faaeaf53..6bc7ad98858 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ja.xlf @@ -8,8 +8,8 @@ - Add a hosting integration to the Aspire AppHost. - Aspire AppHost にホスティング統合を追加します。 + Add a hosting integration to the apphost. + Aspire AppHost にホスティング統合を追加します。 diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf index 4b5250ba7bf..c57a453a1f1 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ko.xlf @@ -8,8 +8,8 @@ - Add a hosting integration to the Aspire AppHost. - Aspire AppHost에 호스팅 통합을 추가하세요. + Add a hosting integration to the apphost. + Aspire AppHost에 호스팅 통합을 추가하세요. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf index a9e812592b8..9389dd50467 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pl.xlf @@ -8,8 +8,8 @@ - Add a hosting integration to the Aspire AppHost. - Dodaj integrację hostingu do hosta AppHost platformy Aspire. + Add a hosting integration to the apphost. + Dodaj integrację hostingu do hosta AppHost platformy Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf index e6b5a80f657..04c0bfc5f48 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.pt-BR.xlf @@ -8,8 +8,8 @@ - Add a hosting integration to the Aspire AppHost. - Adicione uma integração de hosting ao Aspire AppHost. + Add a hosting integration to the apphost. + Adicione uma integração de hosting ao Aspire AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf index 337ff2394b2..6f21d08a29f 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.ru.xlf @@ -8,8 +8,8 @@ - Add a hosting integration to the Aspire AppHost. - Добавьте интеграцию размещения в Aspire AppHost. + Add a hosting integration to the apphost. + Добавьте интеграцию размещения в Aspire AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf index 9f9933c4dbe..6a5bdbfb6ff 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.tr.xlf @@ -8,8 +8,8 @@ - Add a hosting integration to the Aspire AppHost. - Aspire AppHost'a bir barındırma tümleştirmesi ekleyin. + Add a hosting integration to the apphost. + Aspire AppHost'a bir barındırma tümleştirmesi ekleyin. diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf index 8586d7619ac..1bd8fda3314 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hans.xlf @@ -8,8 +8,8 @@ - Add a hosting integration to the Aspire AppHost. - 将托管集成添加到 Aspire AppHost。 + Add a hosting integration to the apphost. + 将托管集成添加到 Aspire AppHost。 diff --git a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf index 5324918af99..55e2f950941 100644 --- a/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AddCommandStrings.zh-Hant.xlf @@ -8,8 +8,8 @@ - Add a hosting integration to the Aspire AppHost. - 將主機整合新增到 Aspire AppHost。 + Add a hosting integration to the apphost. + 將主機整合新增到 Aspire AppHost。 diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf index 3e6e9255b44..7c2bdc107d5 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf @@ -23,8 +23,8 @@ - Manage AI agent integrations. - Spravujte integrace agentů AI. + Manage AI agent specific setup. + Spravujte integrace agentů AI. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf index 27f1bb51c46..136075c4f77 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf @@ -23,8 +23,8 @@ - Manage AI agent integrations. - Verwalten von KI-Agent-Integrationen. + Manage AI agent specific setup. + Verwalten von KI-Agent-Integrationen. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf index 5e8ca917049..ec2f3308ed1 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf @@ -23,8 +23,8 @@ - Manage AI agent integrations. - Administrar integraciones de agentes de IA. + Manage AI agent specific setup. + Administrar integraciones de agentes de IA. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf index 144ff39e840..07497d61499 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf @@ -23,8 +23,8 @@ - Manage AI agent integrations. - Gérer les intégrations des agents IA. + Manage AI agent specific setup. + Gérer les intégrations des agents IA. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf index 3efb7a98536..44154133e4e 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf @@ -23,8 +23,8 @@ - Manage AI agent integrations. - Gestire le integrazioni dell'agente AI. + Manage AI agent specific setup. + Gestire le integrazioni dell'agente AI. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf index 41c529b1cac..b6ae4c482c1 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf @@ -23,8 +23,8 @@ - Manage AI agent integrations. - AI エージェントの統合を管理します。 + Manage AI agent specific setup. + AI エージェントの統合を管理します。 diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf index 3cee8b9d29f..f678b846072 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf @@ -23,8 +23,8 @@ - Manage AI agent integrations. - AI 에이전트 통합을 관리합니다. + Manage AI agent specific setup. + AI 에이전트 통합을 관리합니다. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf index 8697a4b1cf6..1aa4bbd4897 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf @@ -23,8 +23,8 @@ - Manage AI agent integrations. - Zarządzaj integracjami agentów sztucznej inteligencji. + Manage AI agent specific setup. + Zarządzaj integracjami agentów sztucznej inteligencji. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf index 48a592b8376..cee50ee7a09 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf @@ -23,8 +23,8 @@ - Manage AI agent integrations. - Gerencie integrações de agente de IA. + Manage AI agent specific setup. + Gerencie integrações de agente de IA. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf index 07193b8ad3b..ad3ffe20ec6 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf @@ -23,8 +23,8 @@ - Manage AI agent integrations. - Управляйте интеграциями агентов ИИ. + Manage AI agent specific setup. + Управляйте интеграциями агентов ИИ. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf index b20ee85ae2f..16522538ba5 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf @@ -23,8 +23,8 @@ - Manage AI agent integrations. - AI detekli aracı entegrasyonlarınızı yönetin. + Manage AI agent specific setup. + AI detekli aracı entegrasyonlarınızı yönetin. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf index fbb65fdfae3..67760d202e2 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf @@ -23,8 +23,8 @@ - Manage AI agent integrations. - 管理 AI 智能体集成。 + Manage AI agent specific setup. + 管理 AI 智能体集成。 diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf index ae2b4105963..356b44af5d8 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf @@ -23,8 +23,8 @@ - Manage AI agent integrations. - 管理 AI Agent 整合。 + Manage AI agent specific setup. + 管理 AI Agent 整合。 diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.cs.xlf index 8cd9e1dff6a..0720a9f8793 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.cs.xlf @@ -53,8 +53,8 @@ - Manage configuration settings. - Umožňuje spravovat nastavení konfigurace. + Manage CLI configuration including feature flags. + Umožňuje spravovat nastavení konfigurace. diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.de.xlf index c2f42bf0470..1810746b81f 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.de.xlf @@ -53,8 +53,8 @@ - Manage configuration settings. - Verwalten Sie Konfigurationseinstellungen. + Manage CLI configuration including feature flags. + Verwalten Sie Konfigurationseinstellungen. diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.es.xlf index 595f4cd722c..890b10650de 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.es.xlf @@ -53,8 +53,8 @@ - Manage configuration settings. - Administrar las opciones de configuración. + Manage CLI configuration including feature flags. + Administrar las opciones de configuración. diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.fr.xlf index ebb09caf14f..53d4a40801d 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.fr.xlf @@ -53,8 +53,8 @@ - Manage configuration settings. - Gérez les paramètres de configuration. + Manage CLI configuration including feature flags. + Gérez les paramètres de configuration. diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.it.xlf index 65bcd81b85a..38fa936d4da 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.it.xlf @@ -53,8 +53,8 @@ - Manage configuration settings. - Gestisci le impostazioni di configurazione. + Manage CLI configuration including feature flags. + Gestisci le impostazioni di configurazione. diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ja.xlf index c3af2170747..be4175925d8 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ja.xlf @@ -53,8 +53,8 @@ - Manage configuration settings. - 構成設定を管理します。 + Manage CLI configuration including feature flags. + 構成設定を管理します。 diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ko.xlf index 4641da08f13..60ad7b23817 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ko.xlf @@ -53,8 +53,8 @@ - Manage configuration settings. - 구성 설정을 관리합니다. + Manage CLI configuration including feature flags. + 구성 설정을 관리합니다. diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pl.xlf index c65864490bf..0ee5b7b05db 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pl.xlf @@ -53,8 +53,8 @@ - Manage configuration settings. - Zarządzaj ustawieniami konfiguracji. + Manage CLI configuration including feature flags. + Zarządzaj ustawieniami konfiguracji. diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pt-BR.xlf index 5313d5e0ab3..c3f52487aa5 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.pt-BR.xlf @@ -53,8 +53,8 @@ - Manage configuration settings. - Gerencie as definições de configuração. + Manage CLI configuration including feature flags. + Gerencie as definições de configuração. diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ru.xlf index 7a364e99268..2fdd373187d 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.ru.xlf @@ -53,8 +53,8 @@ - Manage configuration settings. - Управление параметрами конфигурации. + Manage CLI configuration including feature flags. + Управление параметрами конфигурации. diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.tr.xlf index 9b61096e8e4..94be09448c1 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.tr.xlf @@ -53,8 +53,8 @@ - Manage configuration settings. - Yapılandırma ayarlarını yönetin. + Manage CLI configuration including feature flags. + Yapılandırma ayarlarını yönetin. diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hans.xlf index 6984e750d63..dcfffcf23f2 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hans.xlf @@ -53,8 +53,8 @@ - Manage configuration settings. - 管理配置设置。 + Manage CLI configuration including feature flags. + 管理配置设置。 diff --git a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hant.xlf index fc053a9a95f..a434ca4bf9c 100644 --- a/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/ConfigCommandStrings.zh-Hant.xlf @@ -53,8 +53,8 @@ - Manage configuration settings. - 管理組態設定。 + Manage CLI configuration including feature flags. + 管理組態設定。 diff --git a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.cs.xlf index f33c31988a0..50aa2d995ea 100644 --- a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.cs.xlf @@ -8,8 +8,8 @@ - Deploy the contents of an Aspire apphost to its defined deployment targets. (Preview) - Nasaďte obsah hostitele aplikací Aspire do svých definovaných cílů nasazení. (Preview) + Deploy an apphost to its deployment targets. (Preview) + Nasaďte obsah hostitele aplikací Aspire do svých definovaných cílů nasazení. (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.de.xlf index 6791a5b7528..3b97d463128 100644 --- a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.de.xlf @@ -8,8 +8,8 @@ - Deploy the contents of an Aspire apphost to its defined deployment targets. (Preview) - Stellen Sie die Inhalte eines Aspire-Apphosts auf den definierten Bereitstellungszielen bereit. (Vorschau) + Deploy an apphost to its deployment targets. (Preview) + Stellen Sie die Inhalte eines Aspire-Apphosts auf den definierten Bereitstellungszielen bereit. (Vorschau) diff --git a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.es.xlf index 5c409c48041..617d7f3b119 100644 --- a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.es.xlf @@ -8,8 +8,8 @@ - Deploy the contents of an Aspire apphost to its defined deployment targets. (Preview) - Implemente el contenido de un apphost de Aspire en sus destinos de implementación definidos. (Versión preliminar) + Deploy an apphost to its deployment targets. (Preview) + Implemente el contenido de un apphost de Aspire en sus destinos de implementación definidos. (Versión preliminar) diff --git a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.fr.xlf index 980967a3fe1..6b5957d4717 100644 --- a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.fr.xlf @@ -8,8 +8,8 @@ - Deploy the contents of an Aspire apphost to its defined deployment targets. (Preview) - Déployez le contenu d’un Aspire AppHost vers les cibles de déploiement définies. (Préversion) + Deploy an apphost to its deployment targets. (Preview) + Déployez le contenu d’un Aspire AppHost vers les cibles de déploiement définies. (Préversion) diff --git a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.it.xlf index 6879cde1f4f..1086496adbb 100644 --- a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.it.xlf @@ -8,8 +8,8 @@ - Deploy the contents of an Aspire apphost to its defined deployment targets. (Preview) - Distribuire il contenuto di un AppHost Aspire alle destinazioni di distribuzione definite. (Anteprima) + Deploy an apphost to its deployment targets. (Preview) + Distribuire il contenuto di un AppHost Aspire alle destinazioni di distribuzione definite. (Anteprima) diff --git a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.ja.xlf index 004afdc9d10..5e6e8925f12 100644 --- a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.ja.xlf @@ -8,8 +8,8 @@ - Deploy the contents of an Aspire apphost to its defined deployment targets. (Preview) - Aspire AppHost のコンテンツを定義された展開先にデプロイします。(プレビュー) + Deploy an apphost to its deployment targets. (Preview) + Aspire AppHost のコンテンツを定義された展開先にデプロイします。(プレビュー) diff --git a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.ko.xlf index 60002837391..54a7d768024 100644 --- a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.ko.xlf @@ -8,8 +8,8 @@ - Deploy the contents of an Aspire apphost to its defined deployment targets. (Preview) - Aspire 앱호스트의 콘텐츠를 정의된 배포 대상에 배포하세요. (미리 보기) + Deploy an apphost to its deployment targets. (Preview) + Aspire 앱호스트의 콘텐츠를 정의된 배포 대상에 배포하세요. (미리 보기) diff --git a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.pl.xlf index 14d6be95f05..606bf14aee0 100644 --- a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.pl.xlf @@ -8,8 +8,8 @@ - Deploy the contents of an Aspire apphost to its defined deployment targets. (Preview) - Wdróż zawartość hosta AppHost platformy Aspire w zdefiniowanych miejscach docelowych wdrożenia. (Wersja zapoznawcza) + Deploy an apphost to its deployment targets. (Preview) + Wdróż zawartość hosta AppHost platformy Aspire w zdefiniowanych miejscach docelowych wdrożenia. (Wersja zapoznawcza) diff --git a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.pt-BR.xlf index 08250ec76ce..3b26ab6f083 100644 --- a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.pt-BR.xlf @@ -8,8 +8,8 @@ - Deploy the contents of an Aspire apphost to its defined deployment targets. (Preview) - Implante o conteúdo de um Aspire apphost em seus destinos de implantação definidos. (Versão Prévia) + Deploy an apphost to its deployment targets. (Preview) + Implante o conteúdo de um Aspire apphost em seus destinos de implantação definidos. (Versão Prévia) diff --git a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.ru.xlf index 896094ecfd5..be0a68427fc 100644 --- a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.ru.xlf @@ -8,8 +8,8 @@ - Deploy the contents of an Aspire apphost to its defined deployment targets. (Preview) - Развернуть содержимое хоста приложений Aspire на заданных целевых объектах развертывания. (Предварительная версия) + Deploy an apphost to its deployment targets. (Preview) + Развернуть содержимое хоста приложений Aspire на заданных целевых объектах развертывания. (Предварительная версия) diff --git a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.tr.xlf index 81d4ff6ea15..7efb8bb9815 100644 --- a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.tr.xlf @@ -8,8 +8,8 @@ - Deploy the contents of an Aspire apphost to its defined deployment targets. (Preview) - Bir Aspire AppHost içeriğini tanımlanan dağıtım hedeflerine dağıtın. (Önizleme) + Deploy an apphost to its deployment targets. (Preview) + Bir Aspire AppHost içeriğini tanımlanan dağıtım hedeflerine dağıtın. (Önizleme) diff --git a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.zh-Hans.xlf index 42b175108c9..36d40834faa 100644 --- a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.zh-Hans.xlf @@ -8,8 +8,8 @@ - Deploy the contents of an Aspire apphost to its defined deployment targets. (Preview) - 将 Aspire 应用主机的内容部署到其定义的部署目标。(预览版) + Deploy an apphost to its deployment targets. (Preview) + 将 Aspire 应用主机的内容部署到其定义的部署目标。(预览版) diff --git a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.zh-Hant.xlf index 42bebeb7aac..4a97c931919 100644 --- a/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DeployCommandStrings.zh-Hant.xlf @@ -8,8 +8,8 @@ - Deploy the contents of an Aspire apphost to its defined deployment targets. (Preview) - 將 Aspire AppHost 的內容部署至其定義的部署目標。(預覽) + Deploy an apphost to its deployment targets. (Preview) + 將 Aspire AppHost 的內容部署至其定義的部署目標。(預覽) diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.cs.xlf similarity index 77% rename from src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.cs.xlf rename to src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.cs.xlf index c7b342fb973..3feab3a1a4f 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.cs.xlf @@ -1,6 +1,6 @@ - + - + No running AppHost found. Use 'aspire run' to start one first. @@ -8,13 +8,18 @@ - Display resource snapshots from a running Aspire apphost. - Umožňuje zobrazit snímky prostředků ze spuštěného hostitele aplikací Aspire. + Describe resources in a running apphost. + Umožňuje zobrazit snímky prostředků ze spuštěného hostitele aplikací Aspire. + + + + Continuously stream resource state changes. + Continuously stream resource state changes. - Output in JSON format for machine consumption. - Výstup ve formátu JSON pro spotřebu počítače. + Output format (Table or Json). + Výstup ve formátu JSON pro spotřebu počítače. @@ -52,11 +57,6 @@ Vyberte hostitele aplikací: - - Stream resource snapshots as they change (NDJSON format when used with --json). - Streamování snímků prostředků při změně (formát NDJSON při použití s parametrem --json). - - \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.de.xlf similarity index 76% rename from src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.de.xlf rename to src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.de.xlf index 3407fa794fb..75f974ade14 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.de.xlf @@ -1,6 +1,6 @@ - + - + No running AppHost found. Use 'aspire run' to start one first. @@ -8,13 +8,18 @@ - Display resource snapshots from a running Aspire apphost. - Zeigen Sie Ressourcenmomentaufnahmen von einem laufenden Aspire-AppHost an. + Describe resources in a running apphost. + Zeigen Sie Ressourcenmomentaufnahmen von einem laufenden Aspire-AppHost an. + + + + Continuously stream resource state changes. + Continuously stream resource state changes. - Output in JSON format for machine consumption. - Ausgabe im JSON-Format zur maschinellen Verarbeitung. + Output format (Table or Json). + Ausgabe im JSON-Format zur maschinellen Verarbeitung. @@ -52,11 +57,6 @@ AppHost auswählen: - - Stream resource snapshots as they change (NDJSON format when used with --json). - Streamen Sie Ressourcenmomentaufnahmen, während sie sich ändern (NDJSON-Format bei Verwendung mit --json). - - \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.es.xlf similarity index 77% rename from src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.es.xlf rename to src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.es.xlf index 6eb9dc07349..2aab00c2be8 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.es.xlf @@ -1,6 +1,6 @@ - + - + No running AppHost found. Use 'aspire run' to start one first. @@ -8,13 +8,18 @@ - Display resource snapshots from a running Aspire apphost. - Muestra instantáneas de recursos de un apphost de Aspire en ejecución. + Describe resources in a running apphost. + Muestra instantáneas de recursos de un apphost de Aspire en ejecución. + + + + Continuously stream resource state changes. + Continuously stream resource state changes. - Output in JSON format for machine consumption. - Salida en formato JSON para el consumo de la máquina. + Output format (Table or Json). + Salida en formato JSON para el consumo de la máquina. @@ -52,11 +57,6 @@ Seleccione un AppHost: - - Stream resource snapshots as they change (NDJSON format when used with --json). - Transmita instantáneas de recursos a medida que cambian (formato NDJSON cuando se usa con --json). - - \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.fr.xlf similarity index 76% rename from src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.fr.xlf rename to src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.fr.xlf index a1c90103ee8..3578d436ac2 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.fr.xlf @@ -1,6 +1,6 @@ - + - + No running AppHost found. Use 'aspire run' to start one first. @@ -8,13 +8,18 @@ - Display resource snapshots from a running Aspire apphost. - Afficher les instantanés de ressource d’un Apphost Aspire en cours d’exécution. + Describe resources in a running apphost. + Afficher les instantanés de ressource d’un Apphost Aspire en cours d’exécution. + + + + Continuously stream resource state changes. + Continuously stream resource state changes. - Output in JSON format for machine consumption. - Générer la sortie au format JSON pour traitement automatique. + Output format (Table or Json). + Générer la sortie au format JSON pour traitement automatique. @@ -52,11 +57,6 @@ Sélectionner un AppHost : - - Stream resource snapshots as they change (NDJSON format when used with --json). - Diffuser les instantanés de ressource au fur et à mesure de leur modification (format NDJSON lorsqu’il est utilisé avec --json). - - \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.it.xlf similarity index 76% rename from src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.it.xlf rename to src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.it.xlf index d1487094a85..febb49dcc37 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.it.xlf @@ -1,6 +1,6 @@ - + - + No running AppHost found. Use 'aspire run' to start one first. @@ -8,13 +8,18 @@ - Display resource snapshots from a running Aspire apphost. - Consente di visualizzare gli snapshot delle risorse da un apphost Aspire in esecuzione. + Describe resources in a running apphost. + Consente di visualizzare gli snapshot delle risorse da un apphost Aspire in esecuzione. + + + + Continuously stream resource state changes. + Continuously stream resource state changes. - Output in JSON format for machine consumption. - Output in formato JSON per l'utilizzo da parte del computer. + Output format (Table or Json). + Output in formato JSON per l'utilizzo da parte del computer. @@ -52,11 +57,6 @@ Selezionare un AppHost: - - Stream resource snapshots as they change (NDJSON format when used with --json). - Consente di trasmettere gli snapshot delle risorse man mano che cambiano (formato NDJSON se usato con --json). - - \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ja.xlf similarity index 76% rename from src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ja.xlf rename to src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ja.xlf index 396dec5e024..1767e395415 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ja.xlf @@ -1,6 +1,6 @@ - + - + No running AppHost found. Use 'aspire run' to start one first. @@ -8,13 +8,18 @@ - Display resource snapshots from a running Aspire apphost. - 実行中の Aspire apphost からのリソース スナップショットを表示します。 + Describe resources in a running apphost. + 実行中の Aspire apphost からのリソース スナップショットを表示します。 + + + + Continuously stream resource state changes. + Continuously stream resource state changes. - Output in JSON format for machine consumption. - 機械処理用の JSON 形式で出力します。 + Output format (Table or Json). + 機械処理用の JSON 形式で出力します。 @@ -52,11 +57,6 @@ AppHost を選択: - - Stream resource snapshots as they change (NDJSON format when used with --json). - 変化が発生したときにリソース スナップショットをストリームします (--json と組み合わせた場合は NDJSON 形式)。 - - \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ko.xlf similarity index 77% rename from src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ko.xlf rename to src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ko.xlf index 25a5ea15eed..e65dbcfe84d 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ko.xlf @@ -1,6 +1,6 @@ - + - + No running AppHost found. Use 'aspire run' to start one first. @@ -8,13 +8,18 @@ - Display resource snapshots from a running Aspire apphost. - 실행 중인 Aspire AppHost의 리소스 스냅샷을 표시합니다. + Describe resources in a running apphost. + 실행 중인 Aspire AppHost의 리소스 스냅샷을 표시합니다. + + + + Continuously stream resource state changes. + Continuously stream resource state changes. - Output in JSON format for machine consumption. - 컴퓨터 사용량에 대한 JSON형식의 출력입니다. + Output format (Table or Json). + 컴퓨터 사용량에 대한 JSON형식의 출력입니다. @@ -52,11 +57,6 @@ AppHost 선택: - - Stream resource snapshots as they change (NDJSON format when used with --json). - 리소스 스냅샷이 변경될 때 스트림으로 전송합니다(--json과 함께 사용 시 NDJSON 형식). - - \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pl.xlf similarity index 77% rename from src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.pl.xlf rename to src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pl.xlf index a2f3f172672..a76b5b568a6 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pl.xlf @@ -1,6 +1,6 @@ - + - + No running AppHost found. Use 'aspire run' to start one first. @@ -8,13 +8,18 @@ - Display resource snapshots from a running Aspire apphost. - Wyświetl migawki zasobów z działającego hosta aplikacji Aspire. + Describe resources in a running apphost. + Wyświetl migawki zasobów z działającego hosta aplikacji Aspire. + + + + Continuously stream resource state changes. + Continuously stream resource state changes. - Output in JSON format for machine consumption. - Wynik w formacie JSON do przetwarzania przez maszynę. + Output format (Table or Json). + Wynik w formacie JSON do przetwarzania przez maszynę. @@ -52,11 +57,6 @@ Wybierz hosta aplikacji: - - Stream resource snapshots as they change (NDJSON format when used with --json). - Strumieniuj migawki zasobów w miarę ich zmian (format NDJSON przy użyciu opcji --json). - - \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pt-BR.xlf similarity index 77% rename from src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.pt-BR.xlf rename to src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pt-BR.xlf index 492c5e8be4d..409dc0ce0ad 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pt-BR.xlf @@ -1,6 +1,6 @@ - + - + No running AppHost found. Use 'aspire run' to start one first. @@ -8,13 +8,18 @@ - Display resource snapshots from a running Aspire apphost. - Exiba instantâneos de recursos de um apphost do Aspire em execução. + Describe resources in a running apphost. + Exiba instantâneos de recursos de um apphost do Aspire em execução. + + + + Continuously stream resource state changes. + Continuously stream resource state changes. - Output in JSON format for machine consumption. - Saída no formato JSON para consumo do computador. + Output format (Table or Json). + Saída no formato JSON para consumo do computador. @@ -52,11 +57,6 @@ Selecione um AppHost: - - Stream resource snapshots as they change (NDJSON format when used with --json). - Transmita instantâneos de recursos conforme eles são alterados (formato NDJSON quando usado com --json). - - \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ru.xlf similarity index 75% rename from src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ru.xlf rename to src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ru.xlf index 0f34b6164d8..f43cfbbf742 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ru.xlf @@ -1,6 +1,6 @@ - + - + No running AppHost found. Use 'aspire run' to start one first. @@ -8,13 +8,18 @@ - Display resource snapshots from a running Aspire apphost. - Отображать моментальные снимки ресурсов из запущенного хоста приложений Aspire. + Describe resources in a running apphost. + Отображать моментальные снимки ресурсов из запущенного хоста приложений Aspire. + + + + Continuously stream resource state changes. + Continuously stream resource state changes. - Output in JSON format for machine consumption. - Вывод в формате JSON для потребления компьютером. + Output format (Table or Json). + Вывод в формате JSON для потребления компьютером. @@ -52,11 +57,6 @@ Выберите хост приложения: - - Stream resource snapshots as they change (NDJSON format when used with --json). - Потоковая передача моментальных снимков ресурсов по мере их изменения (формат NDJSON при использовании с --json). - - \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.tr.xlf similarity index 76% rename from src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.tr.xlf rename to src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.tr.xlf index 0bcfe9fd0d6..60e28a8c601 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.tr.xlf @@ -1,6 +1,6 @@ - + - + No running AppHost found. Use 'aspire run' to start one first. @@ -8,13 +8,18 @@ - Display resource snapshots from a running Aspire apphost. - Çalışan bir Aspire apphost'tan kaynak anlık görüntülerini göster. + Describe resources in a running apphost. + Çalışan bir Aspire apphost'tan kaynak anlık görüntülerini göster. + + + + Continuously stream resource state changes. + Continuously stream resource state changes. - Output in JSON format for machine consumption. - Makine tarafından kullanılmak üzere JSON biçiminde çıktı ver. + Output format (Table or Json). + Makine tarafından kullanılmak üzere JSON biçiminde çıktı ver. @@ -52,11 +57,6 @@ AppHost seçin: - - Stream resource snapshots as they change (NDJSON format when used with --json). - Kaynak anlık görüntülerini değiştikçe akışla aktarın (--json ile kullanıldığında NDJSON biçimi). - - \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hans.xlf similarity index 76% rename from src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.zh-Hans.xlf rename to src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hans.xlf index d5be650fc41..c31c1e4a7cb 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hans.xlf @@ -1,6 +1,6 @@ - + - + No running AppHost found. Use 'aspire run' to start one first. @@ -8,13 +8,18 @@ - Display resource snapshots from a running Aspire apphost. - 显示正在运行的 Aspire 应用主机中的资源快照。 + Describe resources in a running apphost. + 显示正在运行的 Aspire 应用主机中的资源快照。 + + + + Continuously stream resource state changes. + Continuously stream resource state changes. - Output in JSON format for machine consumption. - 以 JSON 格式输出,供计算机使用。 + Output format (Table or Json). + 以 JSON 格式输出,供计算机使用。 @@ -52,11 +57,6 @@ 选择 AppHost: - - Stream resource snapshots as they change (NDJSON format when used with --json). - 在资源快照改变时流式传输这些快照(与 --json 一起使用时为 NDJSON 格式)。 - - \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hant.xlf similarity index 77% rename from src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.zh-Hant.xlf rename to src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hant.xlf index a5f1fe7554d..f4fc0ce158d 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourcesCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hant.xlf @@ -1,6 +1,6 @@ - + - + No running AppHost found. Use 'aspire run' to start one first. @@ -8,13 +8,18 @@ - Display resource snapshots from a running Aspire apphost. - 顯示正在執行的 Aspire AppHost 的資源快照集。 + Describe resources in a running apphost. + 顯示正在執行的 Aspire AppHost 的資源快照集。 + + + + Continuously stream resource state changes. + Continuously stream resource state changes. - Output in JSON format for machine consumption. - 輸出為 JSON 格式供機器使用。 + Output format (Table or Json). + 輸出為 JSON 格式供機器使用。 @@ -52,11 +57,6 @@ 選取 AppHost: - - Stream resource snapshots as they change (NDJSON format when used with --json). - 變更時串流資源快照集 (與 --json 一起使用時為 NDJSON 格式)。 - - \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf index 080727326e5..a428a155a9c 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf @@ -13,8 +13,8 @@ - Diagnose Aspire environment issues and verify setup - Diagnostikovat problémy s prostředím Aspire a ověřit nastavení + Diagnose Aspire environment issues and verify setup. + Diagnostikovat problémy s prostředím Aspire a ověřit nastavení @@ -33,8 +33,8 @@ - Output results as JSON for tooling integration - Výstup výsledků ve formátu JSON pro integraci nástrojů + Output format (Table or Json). + Výstup výsledků ve formátu JSON pro integraci nástrojů diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf index 5b950ad8a0a..aef2d9fbec1 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf @@ -13,8 +13,8 @@ - Diagnose Aspire environment issues and verify setup - Diagnose von Aspire-Umgebungsproblemen und Überprüfung der Einrichtung + Diagnose Aspire environment issues and verify setup. + Diagnose von Aspire-Umgebungsproblemen und Überprüfung der Einrichtung @@ -33,8 +33,8 @@ - Output results as JSON for tooling integration - Ergebnisse als JSON für die Toolintegration ausgeben + Output format (Table or Json). + Ergebnisse als JSON für die Toolintegration ausgeben diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf index 58b9fc16c73..2a1f77e58a9 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf @@ -13,8 +13,8 @@ - Diagnose Aspire environment issues and verify setup - Diagnóstico de problemas del entorno de Aspire y comprobación de la configuración + Diagnose Aspire environment issues and verify setup. + Diagnóstico de problemas del entorno de Aspire y comprobación de la configuración @@ -33,8 +33,8 @@ - Output results as JSON for tooling integration - Resultados de salida como JSON para la integración de herramientas + Output format (Table or Json). + Resultados de salida como JSON para la integración de herramientas diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf index 00063b96aab..a0c7028a25e 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf @@ -13,8 +13,8 @@ - Diagnose Aspire environment issues and verify setup - Diagnostiquer les problèmes liés à l’environnement Aspire et vérifier la configuration + Diagnose Aspire environment issues and verify setup. + Diagnostiquer les problèmes liés à l’environnement Aspire et vérifier la configuration @@ -33,8 +33,8 @@ - Output results as JSON for tooling integration - Afficher les résultats au format JSON pour l’intégration des outils + Output format (Table or Json). + Afficher les résultats au format JSON pour l’intégration des outils diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf index 3f885d266e0..1ec125e4daf 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf @@ -13,8 +13,8 @@ - Diagnose Aspire environment issues and verify setup - Diagnostica i problemi dell'ambiente Aspire e verifica la configurazione + Diagnose Aspire environment issues and verify setup. + Diagnostica i problemi dell'ambiente Aspire e verifica la configurazione @@ -33,8 +33,8 @@ - Output results as JSON for tooling integration - Esporta i risultati in formato JSON per l'integrazione con gli strumenti + Output format (Table or Json). + Esporta i risultati in formato JSON per l'integrazione con gli strumenti diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf index 8566ab491e5..0dc2623ab7f 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf @@ -13,8 +13,8 @@ - Diagnose Aspire environment issues and verify setup - Aspire 環境の問題を診断し、セットアップを確認します + Diagnose Aspire environment issues and verify setup. + Aspire 環境の問題を診断し、セットアップを確認します @@ -33,8 +33,8 @@ - Output results as JSON for tooling integration - ツール統合の結果を JSON として出力します + Output format (Table or Json). + ツール統合の結果を JSON として出力します diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf index 5fa6fa7d46c..518dce75638 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf @@ -13,8 +13,8 @@ - Diagnose Aspire environment issues and verify setup - Aspire 환경 문제 진단 및 설정 확인 + Diagnose Aspire environment issues and verify setup. + Aspire 환경 문제 진단 및 설정 확인 @@ -33,8 +33,8 @@ - Output results as JSON for tooling integration - 도구 통합을 위해 결과를 JSON으로 출력 + Output format (Table or Json). + 도구 통합을 위해 결과를 JSON으로 출력 diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf index 20efe37ad0a..966a5f7504e 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf @@ -13,8 +13,8 @@ - Diagnose Aspire environment issues and verify setup - Zdiagnozuj problemy ze środowiskiem Aspire i sprawdź konfigurację + Diagnose Aspire environment issues and verify setup. + Zdiagnozuj problemy ze środowiskiem Aspire i sprawdź konfigurację @@ -33,8 +33,8 @@ - Output results as JSON for tooling integration - Wyświetl wyniki jako JSON na potrzeby integracji z narzędziami + Output format (Table or Json). + Wyświetl wyniki jako JSON na potrzeby integracji z narzędziami diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf index 2832d1d1b34..29dcc1b74dc 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf @@ -13,8 +13,8 @@ - Diagnose Aspire environment issues and verify setup - Diagnosticar problemas de ambiente do Aspire e verificar a configuração + Diagnose Aspire environment issues and verify setup. + Diagnosticar problemas de ambiente do Aspire e verificar a configuração @@ -33,8 +33,8 @@ - Output results as JSON for tooling integration - Resultados de saída como JSON para integração de ferramentas + Output format (Table or Json). + Resultados de saída como JSON para integração de ferramentas diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf index c5598cc1d19..a6cd11b63ca 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf @@ -13,8 +13,8 @@ - Diagnose Aspire environment issues and verify setup - Диагностика проблем среды Aspire и проверка настройки + Diagnose Aspire environment issues and verify setup. + Диагностика проблем среды Aspire и проверка настройки @@ -33,8 +33,8 @@ - Output results as JSON for tooling integration - Вывод результатов в формате JSON для интеграции с инструментами + Output format (Table or Json). + Вывод результатов в формате JSON для интеграции с инструментами diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf index a6595fa3791..c9d01752795 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf @@ -13,8 +13,8 @@ - Diagnose Aspire environment issues and verify setup - Aspire ortam sorunlarını teşhis edin ve kurulumu doğrulayın + Diagnose Aspire environment issues and verify setup. + Aspire ortam sorunlarını teşhis edin ve kurulumu doğrulayın @@ -33,8 +33,8 @@ - Output results as JSON for tooling integration - Araç tümleştirmesi için sonuçları JSON olarak çıktı + Output format (Table or Json). + Araç tümleştirmesi için sonuçları JSON olarak çıktı diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf index 587abbc379a..d96ccc237d8 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf @@ -13,8 +13,8 @@ - Diagnose Aspire environment issues and verify setup - 诊断 Aspire 环境问题并验证设置 + Diagnose Aspire environment issues and verify setup. + 诊断 Aspire 环境问题并验证设置 @@ -33,8 +33,8 @@ - Output results as JSON for tooling integration - 将结果输出为 JSON g格式以便工具集成 + Output format (Table or Json). + 将结果输出为 JSON g格式以便工具集成 diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf index 0b786c8482b..d08d88bc6d8 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf @@ -13,8 +13,8 @@ - Diagnose Aspire environment issues and verify setup - 診斷 Aspire 環境問題並驗證設定 + Diagnose Aspire environment issues and verify setup. + 診斷 Aspire 環境問題並驗證設定 @@ -33,8 +33,8 @@ - Output results as JSON for tooling integration - 輸出結果為 JSON 以用於工具整合 + Output format (Table or Json). + 輸出結果為 JSON 以用於工具整合 diff --git a/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.cs.xlf new file mode 100644 index 00000000000..5ac8b58ccd1 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.cs.xlf @@ -0,0 +1,57 @@ + + + + + + App commands: + App commands: + + + + Deployment: + Deployment: + + + + Use "aspire <command> --help" for more information about a command. + Use "aspire <command> --help" for more information about a command. + + + + Monitoring: + Monitoring: + + + + Options: + Options: + + + + Other commands: + Other commands: + + + + Resource management: + Resource management: + + + + Tools & configuration: + Tools & configuration: + + + + Usage: + Usage: + + + + aspire <command> [options] + aspire <command> [options] + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.de.xlf new file mode 100644 index 00000000000..c5c52a61d4e --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.de.xlf @@ -0,0 +1,57 @@ + + + + + + App commands: + App commands: + + + + Deployment: + Deployment: + + + + Use "aspire <command> --help" for more information about a command. + Use "aspire <command> --help" for more information about a command. + + + + Monitoring: + Monitoring: + + + + Options: + Options: + + + + Other commands: + Other commands: + + + + Resource management: + Resource management: + + + + Tools & configuration: + Tools & configuration: + + + + Usage: + Usage: + + + + aspire <command> [options] + aspire <command> [options] + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.es.xlf new file mode 100644 index 00000000000..b6ff8d218f9 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.es.xlf @@ -0,0 +1,57 @@ + + + + + + App commands: + App commands: + + + + Deployment: + Deployment: + + + + Use "aspire <command> --help" for more information about a command. + Use "aspire <command> --help" for more information about a command. + + + + Monitoring: + Monitoring: + + + + Options: + Options: + + + + Other commands: + Other commands: + + + + Resource management: + Resource management: + + + + Tools & configuration: + Tools & configuration: + + + + Usage: + Usage: + + + + aspire <command> [options] + aspire <command> [options] + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.fr.xlf new file mode 100644 index 00000000000..ac9ed76e289 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.fr.xlf @@ -0,0 +1,57 @@ + + + + + + App commands: + App commands: + + + + Deployment: + Deployment: + + + + Use "aspire <command> --help" for more information about a command. + Use "aspire <command> --help" for more information about a command. + + + + Monitoring: + Monitoring: + + + + Options: + Options: + + + + Other commands: + Other commands: + + + + Resource management: + Resource management: + + + + Tools & configuration: + Tools & configuration: + + + + Usage: + Usage: + + + + aspire <command> [options] + aspire <command> [options] + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.it.xlf new file mode 100644 index 00000000000..a9d9f104603 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.it.xlf @@ -0,0 +1,57 @@ + + + + + + App commands: + App commands: + + + + Deployment: + Deployment: + + + + Use "aspire <command> --help" for more information about a command. + Use "aspire <command> --help" for more information about a command. + + + + Monitoring: + Monitoring: + + + + Options: + Options: + + + + Other commands: + Other commands: + + + + Resource management: + Resource management: + + + + Tools & configuration: + Tools & configuration: + + + + Usage: + Usage: + + + + aspire <command> [options] + aspire <command> [options] + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.ja.xlf new file mode 100644 index 00000000000..0b8ecb4e00c --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.ja.xlf @@ -0,0 +1,57 @@ + + + + + + App commands: + App commands: + + + + Deployment: + Deployment: + + + + Use "aspire <command> --help" for more information about a command. + Use "aspire <command> --help" for more information about a command. + + + + Monitoring: + Monitoring: + + + + Options: + Options: + + + + Other commands: + Other commands: + + + + Resource management: + Resource management: + + + + Tools & configuration: + Tools & configuration: + + + + Usage: + Usage: + + + + aspire <command> [options] + aspire <command> [options] + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.ko.xlf new file mode 100644 index 00000000000..6bbce8478d9 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.ko.xlf @@ -0,0 +1,57 @@ + + + + + + App commands: + App commands: + + + + Deployment: + Deployment: + + + + Use "aspire <command> --help" for more information about a command. + Use "aspire <command> --help" for more information about a command. + + + + Monitoring: + Monitoring: + + + + Options: + Options: + + + + Other commands: + Other commands: + + + + Resource management: + Resource management: + + + + Tools & configuration: + Tools & configuration: + + + + Usage: + Usage: + + + + aspire <command> [options] + aspire <command> [options] + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.pl.xlf new file mode 100644 index 00000000000..c9d0be0ec6d --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.pl.xlf @@ -0,0 +1,57 @@ + + + + + + App commands: + App commands: + + + + Deployment: + Deployment: + + + + Use "aspire <command> --help" for more information about a command. + Use "aspire <command> --help" for more information about a command. + + + + Monitoring: + Monitoring: + + + + Options: + Options: + + + + Other commands: + Other commands: + + + + Resource management: + Resource management: + + + + Tools & configuration: + Tools & configuration: + + + + Usage: + Usage: + + + + aspire <command> [options] + aspire <command> [options] + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.pt-BR.xlf new file mode 100644 index 00000000000..47632dc32f3 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.pt-BR.xlf @@ -0,0 +1,57 @@ + + + + + + App commands: + App commands: + + + + Deployment: + Deployment: + + + + Use "aspire <command> --help" for more information about a command. + Use "aspire <command> --help" for more information about a command. + + + + Monitoring: + Monitoring: + + + + Options: + Options: + + + + Other commands: + Other commands: + + + + Resource management: + Resource management: + + + + Tools & configuration: + Tools & configuration: + + + + Usage: + Usage: + + + + aspire <command> [options] + aspire <command> [options] + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.ru.xlf new file mode 100644 index 00000000000..699f7224a33 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.ru.xlf @@ -0,0 +1,57 @@ + + + + + + App commands: + App commands: + + + + Deployment: + Deployment: + + + + Use "aspire <command> --help" for more information about a command. + Use "aspire <command> --help" for more information about a command. + + + + Monitoring: + Monitoring: + + + + Options: + Options: + + + + Other commands: + Other commands: + + + + Resource management: + Resource management: + + + + Tools & configuration: + Tools & configuration: + + + + Usage: + Usage: + + + + aspire <command> [options] + aspire <command> [options] + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.tr.xlf new file mode 100644 index 00000000000..3ceee28bc7e --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.tr.xlf @@ -0,0 +1,57 @@ + + + + + + App commands: + App commands: + + + + Deployment: + Deployment: + + + + Use "aspire <command> --help" for more information about a command. + Use "aspire <command> --help" for more information about a command. + + + + Monitoring: + Monitoring: + + + + Options: + Options: + + + + Other commands: + Other commands: + + + + Resource management: + Resource management: + + + + Tools & configuration: + Tools & configuration: + + + + Usage: + Usage: + + + + aspire <command> [options] + aspire <command> [options] + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.zh-Hans.xlf new file mode 100644 index 00000000000..0e7f393241a --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.zh-Hans.xlf @@ -0,0 +1,57 @@ + + + + + + App commands: + App commands: + + + + Deployment: + Deployment: + + + + Use "aspire <command> --help" for more information about a command. + Use "aspire <command> --help" for more information about a command. + + + + Monitoring: + Monitoring: + + + + Options: + Options: + + + + Other commands: + Other commands: + + + + Resource management: + Resource management: + + + + Tools & configuration: + Tools & configuration: + + + + Usage: + Usage: + + + + aspire <command> [options] + aspire <command> [options] + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.zh-Hant.xlf new file mode 100644 index 00000000000..84f6aa23303 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/HelpGroupStrings.zh-Hant.xlf @@ -0,0 +1,57 @@ + + + + + + App commands: + App commands: + + + + Deployment: + Deployment: + + + + Use "aspire <command> --help" for more information about a command. + Use "aspire <command> --help" for more information about a command. + + + + Monitoring: + Monitoring: + + + + Options: + Options: + + + + Other commands: + Other commands: + + + + Resource management: + Resource management: + + + + Tools & configuration: + Tools & configuration: + + + + Usage: + Usage: + + + + aspire <command> [options] + aspire <command> [options] + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.cs.xlf index 9c91b2e76ca..04e77e8310d 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.cs.xlf @@ -33,8 +33,8 @@ - Initialize Aspire support in an existing solution or create a single-file AppHost. - Inicializujte podporu Aspire v existujícím řešení nebo vytvořte AppHost s jedním souborem. + Initialize Aspire in an existing codebase. + Inicializujte podporu Aspire v existujícím řešení nebo vytvořte AppHost s jedním souborem. diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.de.xlf index de63849a09d..0e9c4e74db6 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.de.xlf @@ -33,8 +33,8 @@ - Initialize Aspire support in an existing solution or create a single-file AppHost. - Initialisieren der Unterstützung von Aspire in einer vorhandenen Lösung, oder Erstellen einen AppHosts mit nur einer Datei. + Initialize Aspire in an existing codebase. + Initialisieren der Unterstützung von Aspire in einer vorhandenen Lösung, oder Erstellen einen AppHosts mit nur einer Datei. diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.es.xlf index 17f49a2242b..c15d61fc51d 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.es.xlf @@ -33,8 +33,8 @@ - Initialize Aspire support in an existing solution or create a single-file AppHost. - Inicializa la compatibilidad con Initialize en una solución existente o crea un AppHost de un solo archivo. + Initialize Aspire in an existing codebase. + Inicializa la compatibilidad con Initialize en una solución existente o crea un AppHost de un solo archivo. diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.fr.xlf index 27d632910a8..ac5b6eb918a 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.fr.xlf @@ -33,8 +33,8 @@ - Initialize Aspire support in an existing solution or create a single-file AppHost. - Initialisez la prise en charge d’Aspire dans une solution existante ou créez un AppHost à fichier unique. + Initialize Aspire in an existing codebase. + Initialisez la prise en charge d’Aspire dans une solution existante ou créez un AppHost à fichier unique. diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.it.xlf index 6a440e1ee87..520e4717f2f 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.it.xlf @@ -33,8 +33,8 @@ - Initialize Aspire support in an existing solution or create a single-file AppHost. - Inizializzare il supporto Aspire in una soluzione esistente o creare un AppHost a file singolo. + Initialize Aspire in an existing codebase. + Inizializzare il supporto Aspire in una soluzione esistente o creare un AppHost a file singolo. diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ja.xlf index 19085822de6..416fc2ee7d8 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ja.xlf @@ -33,8 +33,8 @@ - Initialize Aspire support in an existing solution or create a single-file AppHost. - 既存のソリューションで Aspire サポートを初期化するか、単一ファイルの AppHost を作成します。 + Initialize Aspire in an existing codebase. + 既存のソリューションで Aspire サポートを初期化するか、単一ファイルの AppHost を作成します。 diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ko.xlf index 2d9826f5310..bc29fe515c2 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ko.xlf @@ -33,8 +33,8 @@ - Initialize Aspire support in an existing solution or create a single-file AppHost. - 기존 솔루션에서 Aspire 지원을 초기화하거나 단일 파일 AppHost를 만듭니다. + Initialize Aspire in an existing codebase. + 기존 솔루션에서 Aspire 지원을 초기화하거나 단일 파일 AppHost를 만듭니다. diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pl.xlf index 8eff71370d1..dbf04baf696 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pl.xlf @@ -33,8 +33,8 @@ - Initialize Aspire support in an existing solution or create a single-file AppHost. - Zainicjuj obsługę Aspire w istniejącym rozwiązaniu lub utwórz AppHost składający się z jednego pliku. + Initialize Aspire in an existing codebase. + Zainicjuj obsługę Aspire w istniejącym rozwiązaniu lub utwórz AppHost składający się z jednego pliku. diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pt-BR.xlf index ff117b091f6..d96c76b70b9 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.pt-BR.xlf @@ -33,8 +33,8 @@ - Initialize Aspire support in an existing solution or create a single-file AppHost. - Ative o suporte ao Aspire em uma solução existente ou crie um AppHost de arquivo único. + Initialize Aspire in an existing codebase. + Ative o suporte ao Aspire em uma solução existente ou crie um AppHost de arquivo único. diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ru.xlf index 3cd32957ee9..1d518575e08 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.ru.xlf @@ -33,8 +33,8 @@ - Initialize Aspire support in an existing solution or create a single-file AppHost. - Инициализируйте поддержку Aspire в существующем решении или создайте однофайловый AppHost. + Initialize Aspire in an existing codebase. + Инициализируйте поддержку Aspire в существующем решении или создайте однофайловый AppHost. diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.tr.xlf index bedf4055531..05571936352 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.tr.xlf @@ -33,8 +33,8 @@ - Initialize Aspire support in an existing solution or create a single-file AppHost. - Mevcut bir çözümde Aspire desteğini başlatın veya tek dosyalı bir AppHost oluşturun. + Initialize Aspire in an existing codebase. + Mevcut bir çözümde Aspire desteğini başlatın veya tek dosyalı bir AppHost oluşturun. diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hans.xlf index ee65626be07..b3721872253 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hans.xlf @@ -33,8 +33,8 @@ - Initialize Aspire support in an existing solution or create a single-file AppHost. - 在现有解决方案中初始化 Aspire 支持,或创建单文件 AppHost。 + Initialize Aspire in an existing codebase. + 在现有解决方案中初始化 Aspire 支持,或创建单文件 AppHost。 diff --git a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hant.xlf index d13e4cb5f33..2396e175973 100644 --- a/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/InitCommandStrings.zh-Hant.xlf @@ -33,8 +33,8 @@ - Initialize Aspire support in an existing solution or create a single-file AppHost. - 將現有解決方案中的 Aspire 支援初始化,或建立單一檔案 AppHost。 + Initialize Aspire in an existing codebase. + 將現有解決方案中的 Aspire 支援初始化,或建立單一檔案 AppHost。 diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf index 64f8d410dc1..a6bfe4146f6 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf @@ -8,8 +8,8 @@ - Display logs from resources in a running Aspire apphost. - Umožňuje zobrazit protokoly z prostředků v běžícím hostiteli aplikací Aspire. + Display logs from resources in a running apphost. + Umožňuje zobrazit protokoly z prostředků v běžícím hostiteli aplikací Aspire. @@ -18,8 +18,8 @@ - Output logs in JSON format (NDJSON). - Výstupní protokoly ve formátu JSON (NDJSON). + Output format (Table or Json). + Výstupní protokoly ve formátu JSON (NDJSON). diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf index ec47e495971..673b4da020e 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf @@ -8,8 +8,8 @@ - Display logs from resources in a running Aspire apphost. - Protokolle von Ressourcen in einem laufenden Aspire-AppHost anzeigen. + Display logs from resources in a running apphost. + Protokolle von Ressourcen in einem laufenden Aspire-AppHost anzeigen. @@ -18,8 +18,8 @@ - Output logs in JSON format (NDJSON). - Protokolle im JSON-Format (NDJSON) ausgeben. + Output format (Table or Json). + Protokolle im JSON-Format (NDJSON) ausgeben. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf index e21f94b8ca5..a5f9fa7e4aa 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf @@ -8,8 +8,8 @@ - Display logs from resources in a running Aspire apphost. - Muestra los registros de los recursos en un host de aplicaciones Aspire en ejecución. + Display logs from resources in a running apphost. + Muestra los registros de los recursos en un host de aplicaciones Aspire en ejecución. @@ -18,8 +18,8 @@ - Output logs in JSON format (NDJSON). - Registros de salida en formato JSON (NDJSON). + Output format (Table or Json). + Registros de salida en formato JSON (NDJSON). diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf index 6344e92d62c..3af538ead9a 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf @@ -8,8 +8,8 @@ - Display logs from resources in a running Aspire apphost. - Afficher les journaux des ressources dans un Apphost Aspire en cours d’exécution. + Display logs from resources in a running apphost. + Afficher les journaux des ressources dans un Apphost Aspire en cours d’exécution. @@ -18,8 +18,8 @@ - Output logs in JSON format (NDJSON). - Générer les journaux au format JSON (NDJSON). + Output format (Table or Json). + Générer les journaux au format JSON (NDJSON). diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf index e24d5114a96..4c38357db19 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf @@ -8,8 +8,8 @@ - Display logs from resources in a running Aspire apphost. - Consente di visualizzare i log delle risorse in un apphost Aspire in esecuzione. + Display logs from resources in a running apphost. + Consente di visualizzare i log delle risorse in un apphost Aspire in esecuzione. @@ -18,8 +18,8 @@ - Output logs in JSON format (NDJSON). - Log di output in formato JSON (NDJSON). + Output format (Table or Json). + Log di output in formato JSON (NDJSON). diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf index 7dc0960d043..1fe4afe31f3 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf @@ -8,8 +8,8 @@ - Display logs from resources in a running Aspire apphost. - 実行中の Aspire apphost のリソースからログを表示します。 + Display logs from resources in a running apphost. + 実行中の Aspire apphost のリソースからログを表示します。 @@ -18,8 +18,8 @@ - Output logs in JSON format (NDJSON). - ログを JSON 形式 (NDJSON) で出力します。 + Output format (Table or Json). + ログを JSON 形式 (NDJSON) で出力します。 diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf index ab73b881532..5f6d51ee28d 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf @@ -8,8 +8,8 @@ - Display logs from resources in a running Aspire apphost. - 실행 중인 Aspire AppHost에서 리소스의 로그를 표시합니다. + Display logs from resources in a running apphost. + 실행 중인 Aspire AppHost에서 리소스의 로그를 표시합니다. @@ -18,8 +18,8 @@ - Output logs in JSON format (NDJSON). - JSON 형식(NDJSON)으로 로그를 출력합니다. + Output format (Table or Json). + JSON 형식(NDJSON)으로 로그를 출력합니다. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf index 69afa3bdba0..79c7ebcdeec 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf @@ -8,8 +8,8 @@ - Display logs from resources in a running Aspire apphost. - Wyświetl dzienniki z zasobów w działającym hoście usługi Aspire. + Display logs from resources in a running apphost. + Wyświetl dzienniki z zasobów w działającym hoście usługi Aspire. @@ -18,8 +18,8 @@ - Output logs in JSON format (NDJSON). - Dzienniki wyjściowe w formacie JSON (NDJSON). + Output format (Table or Json). + Dzienniki wyjściowe w formacie JSON (NDJSON). diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf index 7428a6df57f..57cfe0a316b 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf @@ -8,8 +8,8 @@ - Display logs from resources in a running Aspire apphost. - Exiba logs de recursos em um apphost do Aspire em execução. + Display logs from resources in a running apphost. + Exiba logs de recursos em um apphost do Aspire em execução. @@ -18,8 +18,8 @@ - Output logs in JSON format (NDJSON). - Logs de saída no formato JSON (NDJSON). + Output format (Table or Json). + Logs de saída no formato JSON (NDJSON). diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf index 712fda63d81..ae1e063dbdc 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf @@ -8,8 +8,8 @@ - Display logs from resources in a running Aspire apphost. - Отображать журналы ресурсов в запущенном хосте приложений Aspire. + Display logs from resources in a running apphost. + Отображать журналы ресурсов в запущенном хосте приложений Aspire. @@ -18,8 +18,8 @@ - Output logs in JSON format (NDJSON). - Вывод журналов в формате JSON (NDJSON). + Output format (Table or Json). + Вывод журналов в формате JSON (NDJSON). diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf index 69c26c885f7..2c6ad882d5e 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf @@ -8,8 +8,8 @@ - Display logs from resources in a running Aspire apphost. - Çalışan bir Aspire apphost'taki kaynakların günlüklerini görüntüle. + Display logs from resources in a running apphost. + Çalışan bir Aspire apphost'taki kaynakların günlüklerini görüntüle. @@ -18,8 +18,8 @@ - Output logs in JSON format (NDJSON). - Günlükleri JSON biçiminde (NDJSON) çıkar. + Output format (Table or Json). + Günlükleri JSON biçiminde (NDJSON) çıkar. diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf index 5865e822bb0..814c5335594 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf @@ -8,8 +8,8 @@ - Display logs from resources in a running Aspire apphost. - 显示正在运行的 Aspire 应用主机中资源的日志。 + Display logs from resources in a running apphost. + 显示正在运行的 Aspire 应用主机中资源的日志。 @@ -18,8 +18,8 @@ - Output logs in JSON format (NDJSON). - 以 JSON 格式输出日志(NDJSON)。 + Output format (Table or Json). + 以 JSON 格式输出日志(NDJSON)。 diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf index d20fc20dca7..4fa74381ebc 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf @@ -8,8 +8,8 @@ - Display logs from resources in a running Aspire apphost. - 顯示正在執行 Aspire Apphost 中的資源記錄。 + Display logs from resources in a running apphost. + 顯示正在執行 Aspire Apphost 中的資源記錄。 @@ -18,8 +18,8 @@ - Output logs in JSON format (NDJSON). - 輸出記錄採用 JSON 格式 (NDJSON)。 + Output format (Table or Json). + 輸出記錄採用 JSON 格式 (NDJSON)。 diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.cs.xlf index 7949a87f95f..6a9bfd14929 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.cs.xlf @@ -13,8 +13,8 @@ - Create a new Aspire project. - Vytvořte nový projekt Aspire. + Create a new app from an Aspire starter template. + Vytvořte nový projekt Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.de.xlf index 1654ede4c0e..60c2183da3b 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.de.xlf @@ -13,8 +13,8 @@ - Create a new Aspire project. - Erstellen Sie ein neues Aspire-Projekt. + Create a new app from an Aspire starter template. + Erstellen Sie ein neues Aspire-Projekt. diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.es.xlf index 2e3572759fa..01f18339c85 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.es.xlf @@ -13,8 +13,8 @@ - Create a new Aspire project. - Crear un nuevo proyecto Aspire. + Create a new app from an Aspire starter template. + Crear un nuevo proyecto Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.fr.xlf index f6c688dd7b6..c6044ca3abf 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.fr.xlf @@ -13,8 +13,8 @@ - Create a new Aspire project. - Créez un nouveau projet Aspire. + Create a new app from an Aspire starter template. + Créez un nouveau projet Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.it.xlf index dc1c9671e86..d47460f0d99 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.it.xlf @@ -13,8 +13,8 @@ - Create a new Aspire project. - Creare un nuovo progetto Aspire. + Create a new app from an Aspire starter template. + Creare un nuovo progetto Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ja.xlf index cea1652c13a..ea66d9aaf8e 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ja.xlf @@ -13,8 +13,8 @@ - Create a new Aspire project. - 新しい Aspire プロジェクトを作成します。 + Create a new app from an Aspire starter template. + 新しい Aspire プロジェクトを作成します。 diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ko.xlf index 1e4a3b30503..d87d6dcd5c8 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ko.xlf @@ -13,8 +13,8 @@ - Create a new Aspire project. - 새 Aspire 프로젝트를 만듭니다. + Create a new app from an Aspire starter template. + 새 Aspire 프로젝트를 만듭니다. diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pl.xlf index 45721cf22ec..6f291aa465e 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pl.xlf @@ -13,8 +13,8 @@ - Create a new Aspire project. - Utwórz nowy projekt usługi Aspire. + Create a new app from an Aspire starter template. + Utwórz nowy projekt usługi Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pt-BR.xlf index 146fee2b8cb..86c3289af15 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.pt-BR.xlf @@ -13,8 +13,8 @@ - Create a new Aspire project. - Criar um projeto Aspire. + Create a new app from an Aspire starter template. + Criar um projeto Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ru.xlf index 4c36a4b4460..93d5dfad4d7 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.ru.xlf @@ -13,8 +13,8 @@ - Create a new Aspire project. - Создайте новый проект Aspire. + Create a new app from an Aspire starter template. + Создайте новый проект Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.tr.xlf index cfa312ed809..a78b69f1555 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.tr.xlf @@ -13,8 +13,8 @@ - Create a new Aspire project. - Yeni bir Aspire projesi oluşturun. + Create a new app from an Aspire starter template. + Yeni bir Aspire projesi oluşturun. diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hans.xlf index 8f8c36babc2..6d5d732efa4 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hans.xlf @@ -13,8 +13,8 @@ - Create a new Aspire project. - 创建新的 Aspire 项目。 + Create a new app from an Aspire starter template. + 创建新的 Aspire 项目。 diff --git a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hant.xlf index 70dd840db3b..6c7f7611cf8 100644 --- a/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/NewCommandStrings.zh-Hant.xlf @@ -13,8 +13,8 @@ - Create a new Aspire project. - 建立新的 Aspire 專案。 + Create a new app from an Aspire starter template. + 建立新的 Aspire 專案。 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf index 68566893ec9..2a355cde589 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf @@ -3,8 +3,8 @@ - List running Aspire apphosts. - Vypíše spuštěné hostitele aplikací Aspire. + List running apphosts. + Vypíše spuštěné hostitele aplikací Aspire. @@ -28,8 +28,8 @@ - Output in JSON format. - Výstup ve formátu JSON + Output format (Table or Json). + Výstup ve formátu JSON diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf index 2d6add3e826..fde09a8b071 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf @@ -3,8 +3,8 @@ - List running Aspire apphosts. - Listet die laufenden Aspire-Apphosts auf. + List running apphosts. + Listet die laufenden Aspire-Apphosts auf. @@ -28,8 +28,8 @@ - Output in JSON format. - Ausgabe im JSON-Format. + Output format (Table or Json). + Ausgabe im JSON-Format. diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf index 42b4bc7b0b0..3ad54273a7a 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf @@ -3,8 +3,8 @@ - List running Aspire apphosts. - Lista de host de aplicaciones Aspire en ejecución. + List running apphosts. + Lista de host de aplicaciones Aspire en ejecución. @@ -28,8 +28,8 @@ - Output in JSON format. - Salida en formato JSON. + Output format (Table or Json). + Salida en formato JSON. diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf index 2de424fbedc..fd68f6538be 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf @@ -3,8 +3,8 @@ - List running Aspire apphosts. - Liste des hôtes d’application Aspire en cours d’exécution. + List running apphosts. + Liste des hôtes d’application Aspire en cours d’exécution. @@ -28,8 +28,8 @@ - Output in JSON format. - Sortie au format JSON. + Output format (Table or Json). + Sortie au format JSON. diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf index 96f372f1888..24daf99b63a 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf @@ -3,8 +3,8 @@ - List running Aspire apphosts. - Elenca apphost Aspire in esecuzione. + List running apphosts. + Elenca apphost Aspire in esecuzione. @@ -28,8 +28,8 @@ - Output in JSON format. - Output in formato JSON. + Output format (Table or Json). + Output in formato JSON. diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf index eed10fb4eca..8657a3107f1 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf @@ -3,8 +3,8 @@ - List running Aspire apphosts. - 実行中の Aspire apphost を一覧表示します。 + List running apphosts. + 実行中の Aspire apphost を一覧表示します。 @@ -28,8 +28,8 @@ - Output in JSON format. - JSON 形式で出力します。 + Output format (Table or Json). + JSON 形式で出力します。 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf index 6796680e52e..44746ceaf82 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf @@ -3,8 +3,8 @@ - List running Aspire apphosts. - 실행 중인 Aspire AppHost를 나열합니다. + List running apphosts. + 실행 중인 Aspire AppHost를 나열합니다. @@ -28,8 +28,8 @@ - Output in JSON format. - JSON 형식의 출력입니다. + Output format (Table or Json). + JSON 형식의 출력입니다. diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf index 953234be043..8c664b20778 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf @@ -3,8 +3,8 @@ - List running Aspire apphosts. - Wyświetl listę uruchomionych hostów aplikacji Aspire. + List running apphosts. + Wyświetl listę uruchomionych hostów aplikacji Aspire. @@ -28,8 +28,8 @@ - Output in JSON format. - Wynik w formacie JSON. + Output format (Table or Json). + Wynik w formacie JSON. diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf index 1531d3583c6..c8a1b0fb28b 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf @@ -3,8 +3,8 @@ - List running Aspire apphosts. - Listar os apphosts Aspire em execução. + List running apphosts. + Listar os apphosts Aspire em execução. @@ -28,8 +28,8 @@ - Output in JSON format. - Saída no formato JSON. + Output format (Table or Json). + Saída no formato JSON. diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf index 303ec512d33..10bc8d9d98c 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf @@ -3,8 +3,8 @@ - List running Aspire apphosts. - Перечислить запущенные apphosts Aspire. + List running apphosts. + Перечислить запущенные apphosts Aspire. @@ -28,8 +28,8 @@ - Output in JSON format. - Вывод в формате JSON. + Output format (Table or Json). + Вывод в формате JSON. diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf index 047da277c43..4b82d4ad750 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf @@ -3,8 +3,8 @@ - List running Aspire apphosts. - Çalışan bir Aspire uygulama ana işlemlerini listeleyin. + List running apphosts. + Çalışan bir Aspire uygulama ana işlemlerini listeleyin. @@ -28,8 +28,8 @@ - Output in JSON format. - Çıkışı JSON biçiminde oluşturun. + Output format (Table or Json). + Çıkışı JSON biçiminde oluşturun. diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf index dab48d328e5..a3f8a2bb1d2 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf @@ -3,8 +3,8 @@ - List running Aspire apphosts. - 列出正在运行的 Aspire AppHost。 + List running apphosts. + 列出正在运行的 Aspire AppHost。 @@ -28,8 +28,8 @@ - Output in JSON format. - 以 JSON 格式输出。 + Output format (Table or Json). + 以 JSON 格式输出。 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf index 6ea34437dca..b8a2ec48b01 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf @@ -3,8 +3,8 @@ - List running Aspire apphosts. - 列出正在執行的 Aspire apphost。 + List running apphosts. + 列出正在執行的 Aspire apphost。 @@ -28,8 +28,8 @@ - Output in JSON format. - 以 JSON 格式輸出。 + Output format (Table or Json). + 以 JSON 格式輸出。 diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.cs.xlf index 00ca6129328..0f966540a6c 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.cs.xlf @@ -3,8 +3,8 @@ - Generates deployment artifacts for an Aspire apphost. (Preview) - Generuje artefakty nasazení pro hostitele aplikací Aspire. (Preview) + Generate deployment artifacts for an apphost. (Preview) + Generuje artefakty nasazení pro hostitele aplikací Aspire. (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.de.xlf index 6a20e6f7d7e..221e51230ba 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.de.xlf @@ -3,8 +3,8 @@ - Generates deployment artifacts for an Aspire apphost. (Preview) - Generiert Bereitstellungsartefakte für einen Aspire-App-Host. (Vorschau) + Generate deployment artifacts for an apphost. (Preview) + Generiert Bereitstellungsartefakte für einen Aspire-App-Host. (Vorschau) diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.es.xlf index 3f5f63fa488..d2fc4647cb1 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.es.xlf @@ -3,8 +3,8 @@ - Generates deployment artifacts for an Aspire apphost. (Preview) - Genera artefactos de implementación para un host de aplicaciones Aspire. (Versión preliminar) + Generate deployment artifacts for an apphost. (Preview) + Genera artefactos de implementación para un host de aplicaciones Aspire. (Versión preliminar) diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.fr.xlf index 4835622a816..739a6f60454 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.fr.xlf @@ -3,8 +3,8 @@ - Generates deployment artifacts for an Aspire apphost. (Preview) - Génère des artefacts de déploiement pour un Aspire AppHost. (Préversion) + Generate deployment artifacts for an apphost. (Preview) + Génère des artefacts de déploiement pour un Aspire AppHost. (Préversion) diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.it.xlf index b32fc0d550d..44412ca08c8 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.it.xlf @@ -3,8 +3,8 @@ - Generates deployment artifacts for an Aspire apphost. (Preview) - Genera artefatti della distribuzione per un AppHost Aspire. (Anteprima) + Generate deployment artifacts for an apphost. (Preview) + Genera artefatti della distribuzione per un AppHost Aspire. (Anteprima) diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ja.xlf index 696d80a2bc0..aa63fe4c227 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ja.xlf @@ -3,8 +3,8 @@ - Generates deployment artifacts for an Aspire apphost. (Preview) - Aspire アプリ ホスティング プロセスのデプロイ成果物を生成します。(プレビュー) + Generate deployment artifacts for an apphost. (Preview) + Aspire アプリ ホスティング プロセスのデプロイ成果物を生成します。(プレビュー) diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ko.xlf index 68bc80ace63..8b9c35ca2a2 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ko.xlf @@ -3,8 +3,8 @@ - Generates deployment artifacts for an Aspire apphost. (Preview) - Aspire 앱호스트에 대한 배포 아티팩트를 생성합니다. (미리 보기) + Generate deployment artifacts for an apphost. (Preview) + Aspire 앱호스트에 대한 배포 아티팩트를 생성합니다. (미리 보기) diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pl.xlf index 8aaff03bbe7..2d95f54ee45 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pl.xlf @@ -3,8 +3,8 @@ - Generates deployment artifacts for an Aspire apphost. (Preview) - Generuje artefakty wdrożenia dla hosta AppHost platformy Aspire. (Wersja zapoznawcza) + Generate deployment artifacts for an apphost. (Preview) + Generuje artefakty wdrożenia dla hosta AppHost platformy Aspire. (Wersja zapoznawcza) diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pt-BR.xlf index bece3c5fb38..51fc682939c 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.pt-BR.xlf @@ -3,8 +3,8 @@ - Generates deployment artifacts for an Aspire apphost. (Preview) - Gera artefatos de implantação para um Aspire apphost. (Versão Prévia) + Generate deployment artifacts for an apphost. (Preview) + Gera artefatos de implantação para um Aspire apphost. (Versão Prévia) diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ru.xlf index 2df80db04cb..935bfc9abd0 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.ru.xlf @@ -3,8 +3,8 @@ - Generates deployment artifacts for an Aspire apphost. (Preview) - Генерирует артефакты развертывания для хоста приложений Aspire. (Предварительная версия) + Generate deployment artifacts for an apphost. (Preview) + Генерирует артефакты развертывания для хоста приложений Aspire. (Предварительная версия) diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.tr.xlf index 266e4b02e1a..bd608b9872a 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.tr.xlf @@ -3,8 +3,8 @@ - Generates deployment artifacts for an Aspire apphost. (Preview) - Bir Aspire AppHost için dağıtım yapıtları oluşturur. (Önizleme) + Generate deployment artifacts for an apphost. (Preview) + Bir Aspire AppHost için dağıtım yapıtları oluşturur. (Önizleme) diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hans.xlf index 984381ed93e..abcfe03d3ae 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hans.xlf @@ -3,8 +3,8 @@ - Generates deployment artifacts for an Aspire apphost. (Preview) - 为 Aspire 应用主机生成部署项目。(预览版) + Generate deployment artifacts for an apphost. (Preview) + 为 Aspire 应用主机生成部署项目。(预览版) diff --git a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hant.xlf index b3bd4e87174..a54c029594a 100644 --- a/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/PublishCommandStrings.zh-Hant.xlf @@ -3,8 +3,8 @@ - Generates deployment artifacts for an Aspire apphost. (Preview) - 為 Aspire AppHost 產生部署成品。(預覽) + Generate deployment artifacts for an apphost. (Preview) + 為 Aspire AppHost 產生部署成品。(預覽) diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf index b6e8d1ffbcc..b7e6a477ec6 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.cs.xlf @@ -62,6 +62,11 @@ Další informace o telemetrii rozhraní příkazového řádku Aspire: https:// Umožňuje potlačit banner spuštění a oznámení o telemetrii. + + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + + Wait for a debugger to attach before executing the command. Před provedením příkazu počkejte na připojení ladicího programu. @@ -74,4 +79,4 @@ Další informace o telemetrii rozhraní příkazového řádku Aspire: https:// - \ No newline at end of file + diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf index 83ff154d2b5..92fe9053e6e 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.de.xlf @@ -62,6 +62,11 @@ Weitere Informationen zur Aspire-CLI-Telemetrie finden Sie unter: https://aka.ms Unterdrücken Sie das Startupbanner und den Telemetriehinweis. + + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + + Wait for a debugger to attach before executing the command. Warten Sie, bis ein Debugger angefügt wurde, bevor Sie den Befehl ausführen. @@ -74,4 +79,4 @@ Weitere Informationen zur Aspire-CLI-Telemetrie finden Sie unter: https://aka.ms - \ No newline at end of file + diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf index 4ee1b74a866..4e6d4ee75aa 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.es.xlf @@ -62,6 +62,11 @@ Más información acerca de la telemetría de la CLI de Aspire: https://aka.ms/a Suprima el banner de inicio y el aviso de telemetría. + + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + + Wait for a debugger to attach before executing the command. Espere a que se asocie un depurador antes de ejecutar el comando. @@ -74,4 +79,4 @@ Más información acerca de la telemetría de la CLI de Aspire: https://aka.ms/a - \ No newline at end of file + diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf index 5dbb5778707..a75a9a347e1 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.fr.xlf @@ -62,6 +62,11 @@ En savoir plus sur la télémétrie de l’interface CLI Aspire : https://aka.m Supprimer la bannière de démarrage et la notification de télémétrie. + + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + + Wait for a debugger to attach before executing the command. Attendez qu’un débogueur s’attache avant d’exécuter la commande. @@ -74,4 +79,4 @@ En savoir plus sur la télémétrie de l’interface CLI Aspire : https://aka.m - \ No newline at end of file + diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf index 20703b1fdab..a5f5a92a8c4 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.it.xlf @@ -62,6 +62,11 @@ Altre informazioni sui dati di telemetria dell'interfaccia della riga di comando Consente di disabilitare il banner di avvio e l'avviso di telemetria. + + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + + Wait for a debugger to attach before executing the command. Attendi che un debugger si connetta prima di eseguire il comando. @@ -74,4 +79,4 @@ Altre informazioni sui dati di telemetria dell'interfaccia della riga di comando - \ No newline at end of file + diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf index 9b3075e8c66..9b382e95f2c 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ja.xlf @@ -62,6 +62,11 @@ Aspire CLI テレメトリの詳細情報: https://aka.ms/aspire/cli-telemetryスタートアップ バナーとテレメトリ通知を非表示にします。 + + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + + Wait for a debugger to attach before executing the command. デバッガーがアタッチされるまで待ってから、コマンドを実行します。 @@ -74,4 +79,4 @@ Aspire CLI テレメトリの詳細情報: https://aka.ms/aspire/cli-telemetry - \ No newline at end of file + diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf index d79f1b67143..a4f3545f015 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ko.xlf @@ -62,6 +62,11 @@ Aspire CLI 원격 분석에 대해 자세히 알아보기: https://aka.ms/aspire 시작 배너 및 원격 분석 알림을 표시하지 않습니다. + + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + + Wait for a debugger to attach before executing the command. 명령을 실행하기 전에 디버거가 연결되기를 기다리세요. @@ -74,4 +79,4 @@ Aspire CLI 원격 분석에 대해 자세히 알아보기: https://aka.ms/aspire - \ No newline at end of file + diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf index 6be567691f1..0ee9545e085 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pl.xlf @@ -62,6 +62,11 @@ Dowiedz się więcej o telemetrii wiersza polecenia usługi Aspire: https://aka. Wstrzymaj baner startowy i powiadomienie o telemetrii. + + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + + Wait for a debugger to attach before executing the command. Przed wykonaniem polecenia poczekaj na dołączenie debugera. @@ -74,4 +79,4 @@ Dowiedz się więcej o telemetrii wiersza polecenia usługi Aspire: https://aka. - \ No newline at end of file + diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf index 87dac35c7ef..5e3e79439f5 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.pt-BR.xlf @@ -62,6 +62,11 @@ Leia mais sobre a telemetria da CLI do Aspire: https://aka.ms/aspire/cli-telemet Suprima a faixa de inicialização e o aviso de telemetria. + + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + + Wait for a debugger to attach before executing the command. Aguarde um depurador anexar antes de executar o comando. @@ -74,4 +79,4 @@ Leia mais sobre a telemetria da CLI do Aspire: https://aka.ms/aspire/cli-telemet - \ No newline at end of file + diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf index 4c1e6e179bb..097120bbc83 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.ru.xlf @@ -62,6 +62,11 @@ Aspire CLI собирает данные об использовании. Кор Отключить баннер запуска и уведомление о телеметрии. + + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + + Wait for a debugger to attach before executing the command. Дождитесь подключения отладчика, прежде чем выполнять команду. @@ -74,4 +79,4 @@ Aspire CLI собирает данные об использовании. Кор - \ No newline at end of file + diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf index b73a9067f61..d0af61ce448 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.tr.xlf @@ -62,6 +62,11 @@ Aspire CLI telemetrisi hakkında daha fazla bilgi edinin: https://aka.ms/aspire/ Başlangıç başlığını ve telemetri bildirimini gizle. + + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + + Wait for a debugger to attach before executing the command. Komutu yürütmeden önce hata ayıklayıcısının eklenmesini bekleyin. @@ -74,4 +79,4 @@ Aspire CLI telemetrisi hakkında daha fazla bilgi edinin: https://aka.ms/aspire/ - \ No newline at end of file + diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf index 8161a5ebcbc..91b6c456948 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hans.xlf @@ -62,6 +62,11 @@ Aspire CLI 收集使用情况数据。该数据由 Microsoft 收集,用于帮 抑制显示启动横幅和遥测通知。 + + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + + Wait for a debugger to attach before executing the command. 在执行命令之前,请等待附加调试程序。 @@ -74,4 +79,4 @@ Aspire CLI 收集使用情况数据。该数据由 Microsoft 收集,用于帮 - \ No newline at end of file + diff --git a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf index fb18139e269..ac401a2b1a8 100644 --- a/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RootCommandStrings.zh-Hant.xlf @@ -62,6 +62,11 @@ Aspire CLI 會收集使用方式資料。它由 Microsoft 收集,用於協助 抑制啟動橫幅與遙測通知。 + + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + Run the command in non-interactive mode, disabling all interactive prompts and spinners. + + Wait for a debugger to attach before executing the command. 請等候偵錯工具連結後再執行命令。 @@ -74,4 +79,4 @@ Aspire CLI 會收集使用方式資料。它由 Microsoft 收集,用於協助 - \ No newline at end of file + diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf index 5535b7f2e9b..f2278397832 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf @@ -78,8 +78,8 @@ - Run an Aspire apphost in development mode. - Spusťte hostitele aplikací Aspire ve vývojovém režimu. + Run an apphost in development mode. + Spusťte hostitele aplikací Aspire ve vývojovém režimu. @@ -118,8 +118,8 @@ - Output result as JSON (only valid with --detach). - Výsledek výstupu ve formátu JSON (platný jenom s parametrem --detach). + Output format (Table or Json). Only valid with --detach. + Výsledek výstupu ve formátu JSON (platný jenom s parametrem --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf index 1278b7bd643..19b08ef5426 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf @@ -78,8 +78,8 @@ - Run an Aspire apphost in development mode. - Führen Sie einen Aspire-App-Host im Entwicklungsmodus aus. + Run an apphost in development mode. + Führen Sie einen Aspire-App-Host im Entwicklungsmodus aus. @@ -118,8 +118,8 @@ - Output result as JSON (only valid with --detach). - Ausgabeergebnis als JSON (nur gültig mit --detach). + Output format (Table or Json). Only valid with --detach. + Ausgabeergebnis als JSON (nur gültig mit --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf index a39b710377e..1e5bdced501 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf @@ -78,8 +78,8 @@ - Run an Aspire apphost in development mode. - Ejecutar un apphost de Aspire en modo de desarrollo. + Run an apphost in development mode. + Ejecutar un apphost de Aspire en modo de desarrollo. @@ -118,8 +118,8 @@ - Output result as JSON (only valid with --detach). - Resultado de salida como JSON (solo válido con --detach). + Output format (Table or Json). Only valid with --detach. + Resultado de salida como JSON (solo válido con --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf index fb0a0a6e08a..e2808d90bc9 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf @@ -78,8 +78,8 @@ - Run an Aspire apphost in development mode. - Exécutez un hôte d’application Aspire en mode développement. + Run an apphost in development mode. + Exécutez un hôte d’application Aspire en mode développement. @@ -118,8 +118,8 @@ - Output result as JSON (only valid with --detach). - Afficher le résultat au format JSON (valide uniquement avec --detach). + Output format (Table or Json). Only valid with --detach. + Afficher le résultat au format JSON (valide uniquement avec --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf index 5296e1ed136..3762ab6b91d 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf @@ -78,8 +78,8 @@ - Run an Aspire apphost in development mode. - Esegui un AppHost Aspire in modalità di sviluppo. + Run an apphost in development mode. + Esegui un AppHost Aspire in modalità di sviluppo. @@ -118,8 +118,8 @@ - Output result as JSON (only valid with --detach). - Restituisce il risultato in formato JSON (valido solo con --detach). + Output format (Table or Json). Only valid with --detach. + Restituisce il risultato in formato JSON (valido solo con --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf index 8e73ec517c9..671308acd8d 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf @@ -78,8 +78,8 @@ - Run an Aspire apphost in development mode. - 開発モードで Aspire AppHost を実行します。 + Run an apphost in development mode. + 開発モードで Aspire AppHost を実行します。 @@ -118,8 +118,8 @@ - Output result as JSON (only valid with --detach). - 結果を JSON 形式で出力します (--detach オプション指定時のみ有効)。 + Output format (Table or Json). Only valid with --detach. + 結果を JSON 形式で出力します (--detach オプション指定時のみ有効)。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf index 6d10f499767..61f6cf7be0b 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf @@ -78,8 +78,8 @@ - Run an Aspire apphost in development mode. - 개발 모드에서 Aspire 앱호스트를 실행합니다. + Run an apphost in development mode. + 개발 모드에서 Aspire 앱호스트를 실행합니다. @@ -118,8 +118,8 @@ - Output result as JSON (only valid with --detach). - JSON으로 결과를 출력합니다(--detach와 함께 사용 시에만 유효). + Output format (Table or Json). Only valid with --detach. + JSON으로 결과를 출력합니다(--detach와 함께 사용 시에만 유효). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf index 9f2531431c7..1a6b00940f0 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf @@ -78,8 +78,8 @@ - Run an Aspire apphost in development mode. - Uruchamianie hosta AppHost platformy Aspire w trybie programowania. + Run an apphost in development mode. + Uruchamianie hosta AppHost platformy Aspire w trybie programowania. @@ -118,8 +118,8 @@ - Output result as JSON (only valid with --detach). - Wyświetl wynik jako JSON (działa tylko z parametrem --detach). + Output format (Table or Json). Only valid with --detach. + Wyświetl wynik jako JSON (działa tylko z parametrem --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf index 543eb04e807..b7f3f3ae738 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf @@ -78,8 +78,8 @@ - Run an Aspire apphost in development mode. - Execute um apphost do Aspire no modo de desenvolvimento. + Run an apphost in development mode. + Execute um apphost do Aspire no modo de desenvolvimento. @@ -118,8 +118,8 @@ - Output result as JSON (only valid with --detach). - Resultado de saída como JSON (válido somente com --detach). + Output format (Table or Json). Only valid with --detach. + Resultado de saída como JSON (válido somente com --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf index 9ffd508797b..a5b9942a118 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf @@ -78,8 +78,8 @@ - Run an Aspire apphost in development mode. - Запустите хост приложений Aspire в режиме разработки. + Run an apphost in development mode. + Запустите хост приложений Aspire в режиме разработки. @@ -118,8 +118,8 @@ - Output result as JSON (only valid with --detach). - Вывод результата в формате JSON (допустимо только с параметром --detach). + Output format (Table or Json). Only valid with --detach. + Вывод результата в формате JSON (допустимо только с параметром --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf index 6bd56480556..7e335b22c69 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf @@ -78,8 +78,8 @@ - Run an Aspire apphost in development mode. - Geliştirme modunda bir Aspire uygulama ana işlemini çalıştırın. + Run an apphost in development mode. + Geliştirme modunda bir Aspire uygulama ana işlemini çalıştırın. @@ -118,8 +118,8 @@ - Output result as JSON (only valid with --detach). - Sonucu JSON olarak çıkar (yalnızca --detach ile geçerlidir). + Output format (Table or Json). Only valid with --detach. + Sonucu JSON olarak çıkar (yalnızca --detach ile geçerlidir). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf index 703a2736efa..e4b859cd671 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf @@ -78,8 +78,8 @@ - Run an Aspire apphost in development mode. - 在开发模式下运行 Aspire 应用主机。 + Run an apphost in development mode. + 在开发模式下运行 Aspire 应用主机。 @@ -118,8 +118,8 @@ - Output result as JSON (only valid with --detach). - 以 JSON 格式输出结果(仅在使用 --detach 时有效)。 + Output format (Table or Json). Only valid with --detach. + 以 JSON 格式输出结果(仅在使用 --detach 时有效)。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf index 20f829b7698..fa9b873a2aa 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf @@ -78,8 +78,8 @@ - Run an Aspire apphost in development mode. - 在開發模式中執行 Aspire AppHost。 + Run an apphost in development mode. + 在開發模式中執行 Aspire AppHost。 @@ -118,8 +118,8 @@ - Output result as JSON (only valid with --detach). - 輸出結果為 JSON (僅於使用 --detach 時有效)。 + Output format (Table or Json). Only valid with --detach. + 輸出結果為 JSON (僅於使用 --detach 時有效)。 diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf index 968c090acd6..9444d733e94 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf @@ -13,8 +13,8 @@ - View telemetry data (logs, spans, traces) from a running Aspire application. - Umožňuje zobrazit telemetrická data (protokoly, rozsahy, trasování) ze spuštěné aplikace Aspire. + View OpenTelemetry data (logs, spans, traces) from a running apphost. + Umožňuje zobrazit telemetrická data (protokoly, rozsahy, trasování) ze spuštěné aplikace Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf index 3b1454e764f..d298a522dd4 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf @@ -13,8 +13,8 @@ - View telemetry data (logs, spans, traces) from a running Aspire application. - Zeigen Sie Telemetriedaten (Protokolle, Spans, Traces) einer laufenden Aspire-Anwendung an. + View OpenTelemetry data (logs, spans, traces) from a running apphost. + Zeigen Sie Telemetriedaten (Protokolle, Spans, Traces) einer laufenden Aspire-Anwendung an. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf index aa5860c44fa..bc7df265145 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf @@ -13,8 +13,8 @@ - View telemetry data (logs, spans, traces) from a running Aspire application. - Ver los datos de telemetría (registros, intervalos, seguimientos) de una aplicación de Aspire en ejecución. + View OpenTelemetry data (logs, spans, traces) from a running apphost. + Ver los datos de telemetría (registros, intervalos, seguimientos) de una aplicación de Aspire en ejecución. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf index 8eb389f527e..4c269154cc4 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf @@ -13,8 +13,8 @@ - View telemetry data (logs, spans, traces) from a running Aspire application. - Affichez les données de télémétrie (journaux, spans, traces) d’une application Aspire en cours d’exécution. + View OpenTelemetry data (logs, spans, traces) from a running apphost. + Affichez les données de télémétrie (journaux, spans, traces) d’une application Aspire en cours d’exécution. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf index 7184b111351..ec4e6ba8145 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf @@ -13,8 +13,8 @@ - View telemetry data (logs, spans, traces) from a running Aspire application. - Visualizza i dati di telemetria (log, span, tracce) da un'applicazione Aspire in esecuzione. + View OpenTelemetry data (logs, spans, traces) from a running apphost. + Visualizza i dati di telemetria (log, span, tracce) da un'applicazione Aspire in esecuzione. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf index 9449eef10ba..f6f1856db7c 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf @@ -13,8 +13,8 @@ - View telemetry data (logs, spans, traces) from a running Aspire application. - 実行中の Aspire アプリケーションのテレメトリ データ (ログ、スパン、トレース) を表示します。 + View OpenTelemetry data (logs, spans, traces) from a running apphost. + 実行中の Aspire アプリケーションのテレメトリ データ (ログ、スパン、トレース) を表示します。 diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf index 68a721287d1..c29af0f83f0 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf @@ -13,8 +13,8 @@ - View telemetry data (logs, spans, traces) from a running Aspire application. - 실행 중인 Aspire 애플리케이션의 원격 분석 데이터(로그, 범위, 추적)를 확인합니다. + View OpenTelemetry data (logs, spans, traces) from a running apphost. + 실행 중인 Aspire 애플리케이션의 원격 분석 데이터(로그, 범위, 추적)를 확인합니다. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf index e6d4780d993..7a296f4b0ff 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf @@ -13,8 +13,8 @@ - View telemetry data (logs, spans, traces) from a running Aspire application. - Wyświetl dane telemetryczne (logi, zakresy, śledzenia) z uruchomionej aplikacji Aspire. + View OpenTelemetry data (logs, spans, traces) from a running apphost. + Wyświetl dane telemetryczne (logi, zakresy, śledzenia) z uruchomionej aplikacji Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf index 46aa2a2640d..46213469067 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf @@ -13,8 +13,8 @@ - View telemetry data (logs, spans, traces) from a running Aspire application. - Veja os dados de telemetria (logs, spans, rastreamentos) de um aplicativo Aspire em execução. + View OpenTelemetry data (logs, spans, traces) from a running apphost. + Veja os dados de telemetria (logs, spans, rastreamentos) de um aplicativo Aspire em execução. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf index e5c9e2e1315..839264940bd 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf @@ -13,8 +13,8 @@ - View telemetry data (logs, spans, traces) from a running Aspire application. - Просмотр данных телеметрии (журналы, диапазоны, трассировки) из запущенного приложения Aspire. + View OpenTelemetry data (logs, spans, traces) from a running apphost. + Просмотр данных телеметрии (журналы, диапазоны, трассировки) из запущенного приложения Aspire. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf index 6c448597e04..14ca0675a86 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf @@ -13,8 +13,8 @@ - View telemetry data (logs, spans, traces) from a running Aspire application. - Çalışan bir Aspire uygulamasından telemetri verilerini (günlükler, yayılmalar, izlemeler) görüntüleyin. + View OpenTelemetry data (logs, spans, traces) from a running apphost. + Çalışan bir Aspire uygulamasından telemetri verilerini (günlükler, yayılmalar, izlemeler) görüntüleyin. diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf index d33a07e9fd4..ae605f40628 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf @@ -13,8 +13,8 @@ - View telemetry data (logs, spans, traces) from a running Aspire application. - 查看正在运行的 Aspire 应用程序中的遥测数据(日志、跨度、跟踪)。 + View OpenTelemetry data (logs, spans, traces) from a running apphost. + 查看正在运行的 Aspire 应用程序中的遥测数据(日志、跨度、跟踪)。 diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf index 6e33905d72a..5bdb57d0b3a 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf @@ -13,8 +13,8 @@ - View telemetry data (logs, spans, traces) from a running Aspire application. - 從執行中的 Aspire 應用程式檢視遙測資料 (記錄、範圍、追蹤)。 + View OpenTelemetry data (logs, spans, traces) from a running apphost. + 從執行中的 Aspire 應用程式檢視遙測資料 (記錄、範圍、追蹤)。 diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ResourcesCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs similarity index 91% rename from tests/Aspire.Cli.EndToEnd.Tests/ResourcesCommandTests.cs rename to tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs index 41edab5154e..d946b463853 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ResourcesCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs @@ -10,20 +10,20 @@ namespace Aspire.Cli.EndToEnd.Tests; /// -/// End-to-end tests for the aspire resources command. +/// End-to-end tests for the aspire describe command. /// Each test class runs as a separate CI job for parallelization. /// -public sealed class ResourcesCommandTests(ITestOutputHelper output) +public sealed class DescribeCommandTests(ITestOutputHelper output) { [Fact] - public async Task ResourcesCommandShowsRunningResources() + public async Task DescribeCommandShowsRunningResources() { var workspace = TemporaryWorkspace.Create(output); var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(ResourcesCommandShowsRunningResources)); + var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(DescribeCommandShowsRunningResources)); var builder = Hex1bTerminal.CreateBuilder() .WithHeadless() @@ -122,16 +122,16 @@ public async Task ResourcesCommandShowsRunningResources() .Enter() .WaitForSuccessPrompt(counter); - // Now verify aspire resources shows the running resources (human-readable table) - sequenceBuilder.Type("aspire resources") + // Now verify aspire describe shows the running resources (human-readable table) + sequenceBuilder.Type("aspire describe") .Enter() .WaitUntil(s => waitForResourcesTableHeader.Search(s).Count > 0, TimeSpan.FromSeconds(30)) .WaitUntil(s => waitForWebfrontendResource.Search(s).Count > 0, TimeSpan.FromSeconds(5)) .WaitUntil(s => waitForApiserviceResource.Search(s).Count > 0, TimeSpan.FromSeconds(5)) .WaitForSuccessPrompt(counter); - // Test aspire resources --format json output - pipe to file to avoid terminal buffer issues - sequenceBuilder.Type("aspire resources --format json > resources.json") + // Test aspire describe --format json output - pipe to file to avoid terminal buffer issues + sequenceBuilder.Type("aspire describe --format json > resources.json") .Enter() .WaitForSuccessPrompt(counter); diff --git a/tests/Aspire.Cli.Tests/Commands/ResourcesCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs similarity index 75% rename from tests/Aspire.Cli.Tests/Commands/ResourcesCommandTests.cs rename to tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs index 2586cfee7dd..cbdb1be9e49 100644 --- a/tests/Aspire.Cli.Tests/Commands/ResourcesCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DescribeCommandTests.cs @@ -9,32 +9,31 @@ namespace Aspire.Cli.Tests.Commands; -public class ResourcesCommandTests(ITestOutputHelper outputHelper) +public class DescribeCommandTests(ITestOutputHelper outputHelper) { [Fact] - public async Task ResourcesCommand_Help_Works() + public async Task DescribeCommand_Help_Works() { using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("resources --help"); - + var result = command.Parse("describe --help"); var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } [Fact] - public async Task ResourcesCommand_WhenNoAppHostRunning_ReturnsSuccess() + public async Task DescribeCommand_WhenNoAppHostRunning_ReturnsSuccess() { using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("resources"); + var result = command.Parse("describe"); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -46,14 +45,14 @@ public async Task ResourcesCommand_WhenNoAppHostRunning_ReturnsSuccess() [InlineData("json")] [InlineData("Json")] [InlineData("JSON")] - public async Task ResourcesCommand_FormatOption_IsCaseInsensitive(string format) + public async Task DescribeCommand_FormatOption_IsCaseInsensitive(string format) { using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse($"resources --format {format} --help"); + var result = command.Parse($"describe --format {format} --help"); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -64,14 +63,14 @@ public async Task ResourcesCommand_FormatOption_IsCaseInsensitive(string format) [InlineData("table")] [InlineData("Table")] [InlineData("TABLE")] - public async Task ResourcesCommand_FormatOption_AcceptsTable(string format) + public async Task DescribeCommand_FormatOption_AcceptsTable(string format) { using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse($"resources --format {format} --help"); + var result = command.Parse($"describe --format {format} --help"); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -79,14 +78,14 @@ public async Task ResourcesCommand_FormatOption_AcceptsTable(string format) } [Fact] - public async Task ResourcesCommand_FormatOption_RejectsInvalidValue() + public async Task DescribeCommand_FormatOption_RejectsInvalidValue() { using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("resources --format invalid"); + var result = command.Parse("describe --format invalid"); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -94,14 +93,44 @@ public async Task ResourcesCommand_FormatOption_RejectsInvalidValue() } [Fact] - public async Task ResourcesCommand_WatchOption_CanBeParsed() + public async Task DescribeCommand_FollowOption_CanBeParsed() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("describe --follow --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task DescribeCommand_LegacyWatchOption_StillWorks() { using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("resources --watch --help"); + var result = command.Parse("describe --watch --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task DescribeCommand_LegacyResourcesAlias_StillWorks() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("resources --help"); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -109,14 +138,14 @@ public async Task ResourcesCommand_WatchOption_CanBeParsed() } [Fact] - public async Task ResourcesCommand_WatchAndFormat_CanBeCombined() + public async Task DescribeCommand_FollowAndFormat_CanBeCombined() { using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("resources --watch --format json --help"); + var result = command.Parse("describe --follow --format json --help"); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -124,14 +153,14 @@ public async Task ResourcesCommand_WatchAndFormat_CanBeCombined() } [Fact] - public async Task ResourcesCommand_ResourceNameArgument_CanBeParsed() + public async Task DescribeCommand_ResourceNameArgument_CanBeParsed() { using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("resources myresource --help"); + var result = command.Parse("describe myresource --help"); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -139,14 +168,14 @@ public async Task ResourcesCommand_ResourceNameArgument_CanBeParsed() } [Fact] - public async Task ResourcesCommand_AllOptions_CanBeCombined() + public async Task DescribeCommand_AllOptions_CanBeCombined() { using var workspace = TemporaryWorkspace.Create(outputHelper); var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("resources myresource --watch --format json --help"); + var result = command.Parse("describe myresource --follow --format json --help"); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -154,7 +183,7 @@ public async Task ResourcesCommand_AllOptions_CanBeCombined() } [Fact] - public void ResourcesCommand_NdjsonFormat_OutputsOneObjectPerLine() + public void DescribeCommand_NdjsonFormat_OutputsOneObjectPerLine() { // Arrange - create resource JSON objects var resources = new[] @@ -164,7 +193,7 @@ public void ResourcesCommand_NdjsonFormat_OutputsOneObjectPerLine() new ResourceJson { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Starting" } }; - // Act - serialize each resource separately (simulating NDJSON streaming output for --watch) + // Act - serialize each resource separately (simulating NDJSON streaming output for --follow) var ndjsonLines = resources .Select(r => System.Text.Json.JsonSerializer.Serialize(r, ResourcesCommandJsonContext.Ndjson.ResourceJson)) .ToList(); @@ -194,7 +223,7 @@ public void ResourcesCommand_NdjsonFormat_OutputsOneObjectPerLine() } [Fact] - public void ResourcesCommand_SnapshotFormat_OutputsWrappedJsonArray() + public void DescribeCommand_SnapshotFormat_OutputsWrappedJsonArray() { // Arrange - resources output for snapshot var resourcesOutput = new ResourcesOutput diff --git a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs index 5a11b6da189..6ecd8cff7a1 100644 --- a/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RootCommandTests.cs @@ -373,4 +373,50 @@ public void SetupCommand_Available_WhenBundleIsAvailable() Assert.True(hasSetupCommand); } + [Fact] + public void AllVisibleCommands_HaveHelpGroup() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.BundleServiceFactory = _ => new TestBundleService(isBundle: true); + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + + // Either the subcommand isn't a BaseCommand, or it hasn't specified a HelpGroup. + var missingGroup = command.Subcommands + .Where(sub => !sub.Hidden) + .Where(cmd => cmd is not BaseCommand baseCmd || baseCmd.HelpGroup is HelpGroup.None) + .Select(cmd => cmd.Name) + .ToList(); + + Assert.True(missingGroup.Count == 0, + $"The following visible commands are missing a HelpGroup: {string.Join(", ", missingGroup)}. " + + "Add 'internal override HelpGroup HelpGroup => HelpGroup.XXX;' to each command class."); + } + + [Fact] + public void GroupedHelp_ContainsAllVisibleCommands() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.BundleServiceFactory = _ => new TestBundleService(isBundle: true); + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var helpWriter = new StringWriter(); + GroupedHelpWriter.WriteHelp(command, helpWriter, 120); + + var helpOutput = helpWriter.ToString(); + var visibleCommands = command.Subcommands.Where(sub => !sub.Hidden).ToList(); + + foreach (var sub in visibleCommands) + { + Assert.Contains(sub.Name, helpOutput); + } + } } diff --git a/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs index 00c4ad77013..0469d945489 100644 --- a/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/TelemetryCommandTests.cs @@ -20,7 +20,7 @@ public async Task TelemetryCommand_WithoutSubcommand_ReturnsInvalidCommand() var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("telemetry"); + var result = command.Parse("otel"); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -35,7 +35,7 @@ public async Task TelemetryLogsCommand_WhenNoAppHostRunning_ReturnsSuccess() var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("telemetry logs"); + var result = command.Parse("otel logs"); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -50,7 +50,7 @@ public async Task TelemetrySpansCommand_WhenNoAppHostRunning_ReturnsSuccess() var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("telemetry spans"); + var result = command.Parse("otel spans"); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -65,7 +65,7 @@ public async Task TelemetryTracesCommand_WhenNoAppHostRunning_ReturnsSuccess() var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("telemetry traces"); + var result = command.Parse("otel traces"); var exitCode = await result.InvokeAsync().DefaultTimeout(); diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 7b746e42e66..92fb03db4be 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -168,7 +168,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); From b4fa644d7ce151c0c8e92ebf70054b53c953d726 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 24 Feb 2026 15:13:21 +0800 Subject: [PATCH 157/256] Add skill for dashboard testing (#14611) --- .github/skills/dashboard-testing/SKILL.md | 458 ++++++++++++++++++++++ AGENTS.md | 1 + 2 files changed, 459 insertions(+) create mode 100644 .github/skills/dashboard-testing/SKILL.md diff --git a/.github/skills/dashboard-testing/SKILL.md b/.github/skills/dashboard-testing/SKILL.md new file mode 100644 index 00000000000..8603f283ba1 --- /dev/null +++ b/.github/skills/dashboard-testing/SKILL.md @@ -0,0 +1,458 @@ +--- +name: dashboard-testing +description: Guide for writing tests for the Aspire Dashboard. Use this when asked to create, modify, or debug dashboard unit tests or Blazor component tests. +--- + +# Aspire Dashboard Testing + +This skill provides patterns and practices for writing tests for the Aspire Dashboard. There are two test projects depending on whether the code under test uses Blazor types. + +## Test Project Selection + +| Project | Location | Use When | +|---------|----------|----------| +| **Aspire.Dashboard.Tests** | `tests/Aspire.Dashboard.Tests/` | Testing code that does **not** use Blazor types (models, helpers, utils, OTLP services, middleware) | +| **Aspire.Dashboard.Components.Tests** | `tests/Aspire.Dashboard.Components.Tests/` | Testing code that **does** use Blazor types (pages, components, controls). Uses bUnit for in-memory rendering | + +### Dashboard Source Code + +The dashboard source code is in `src/Aspire.Dashboard/`. Key subdirectories: + +- `Components/` — Blazor components (pages, controls, layout) → test in **Components.Tests** +- `Model/` — View models, data models, helpers → test in **Dashboard.Tests** +- `Otlp/` — OpenTelemetry protocol handling → test in **Dashboard.Tests** +- `Utils/` — Utility and helper classes → test in **Dashboard.Tests** + +## Aspire.Dashboard.Tests (Non-Blazor) + +Standard xUnit tests for models, helpers, utilities, middleware, and services that don't depend on Blazor rendering. + +### Project Structure + +``` +tests/Aspire.Dashboard.Tests/ +├── Model/ # ViewModel and model tests +├── Telemetry/ # Telemetry repository tests +├── ConsoleLogsTests/ # Console log parsing tests +├── Integration/ # Integration tests (auth, OTLP, startup) +├── Markdown/ # Markdown rendering tests +├── Mcp/ # MCP service tests +├── Middleware/ # HTTP middleware tests +├── FormatHelpersTests.cs # Utility function tests +├── DashboardOptionsTests.cs # Configuration tests +└── ... +``` + +### Test Pattern + +```csharp +using Xunit; + +namespace Aspire.Dashboard.Tests; + +public class FormatHelpersTests +{ + [Theory] + [InlineData("9", 9d)] + [InlineData("9.9", 9.9d)] + [InlineData("0.9", 0.9d)] + public void FormatNumberWithOptionalDecimalPlaces_InvariantCulture(string expected, double value) + { + Assert.Equal(expected, FormatHelpers.FormatNumberWithOptionalDecimalPlaces(value, maxDecimalPlaces: 6, CultureInfo.InvariantCulture)); + } +} +``` + +Key points: +- No bUnit, no DI container — direct construction and assertions +- Use `[Fact]` for single test cases, `[Theory]` with `[InlineData]` for parameterized tests +- Use `ModelTestHelpers.CreateResource(...)` from shared test utilities to build `ResourceViewModel` instances +- Use hand-rolled fakes (e.g., `MockKnownPropertyLookup`) instead of mocking frameworks + +## Aspire.Dashboard.Components.Tests (Blazor/bUnit) + +Uses [bUnit](https://bunit.dev) to render and test Blazor components in-memory without a browser. + +### Project Structure + +``` +tests/Aspire.Dashboard.Components.Tests/ +├── Pages/ # Full page component tests +│ ├── ResourcesTests.cs +│ ├── ConsoleLogsTests.cs +│ ├── MetricsTests.cs +│ ├── StructuredLogsTests.cs +│ ├── TraceDetailsTests.cs +│ └── LoginTests.cs +├── Controls/ # Individual control tests +│ ├── ResourceDetailsTests.cs +│ ├── PlotlyChartTests.cs +│ ├── ChartFiltersTests.cs +│ └── ... +├── Interactions/ # Interaction provider tests +├── Layout/ # Layout component tests +├── Model/ # Component model tests +├── Shared/ # Setup helpers and test utilities +│ ├── DashboardPageTestContext.cs +│ ├── FluentUISetupHelpers.cs +│ ├── ResourceSetupHelpers.cs +│ ├── MetricsSetupHelpers.cs +│ ├── StructuredLogsSetupHelpers.cs +│ ├── IntegrationTestHelpers.cs +│ ├── TestLocalStorage.cs +│ ├── TestTimeProvider.cs +│ └── ... +└── GridColumnManagerTests.cs +``` + +### Base Test Class + +All bUnit component tests must extend `DashboardTestContext`: + +```csharp +using Bunit; + +namespace Aspire.Dashboard.Components.Tests.Shared; + +public abstract class DashboardTestContext : TestContext +{ + public DashboardTestContext() + { + // Increase from default 1 second as Helix/GitHub Actions can be slow. + DefaultWaitTimeout = TimeSpan.FromSeconds(10); + } +} +``` + +### Basic Component Test Pattern + +```csharp +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Tests.Shared; +using Bunit; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Controls; + +[UseCulture("en-US")] +public class ResourceDetailsTests : DashboardTestContext +{ + [Fact] + public void Render_BasicResource_DisplaysProperties() + { + // Arrange — register services using shared setup helpers + ResourceSetupHelpers.SetupResourceDetails(this); + + var resource = ModelTestHelpers.CreateResource( + resourceName: "myapp", + state: KnownResourceState.Running); + + // Act — render the component + var cut = RenderComponent(builder => + { + builder.Add(p => p.Resource, resource); + builder.Add(p => p.ShowSpecificProperties, true); + }); + + // Assert — query the rendered DOM + var rows = cut.FindAll(".resource-detail-row"); + Assert.NotEmpty(rows); + } +} +``` + +### Page-Level Test Pattern + +```csharp +using System.Threading.Channels; +using Aspire.Dashboard.Components.Resize; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Tests.Shared; +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Pages; + +[UseCulture("en-US")] +public partial class ResourcesTests : DashboardTestContext +{ + [Fact] + public void UpdateResources_FiltersUpdated() + { + // Arrange + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + var initialResources = new List + { + ModelTestHelpers.CreateResource("Resource1", "Type1", "Running"), + }; + var channel = Channel.CreateUnbounded>(); + var dashboardClient = new TestDashboardClient( + isEnabled: true, + initialResources: initialResources, + resourceChannelProvider: () => channel); + + ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); + + // Act + var cut = RenderComponent(builder => + { + builder.AddCascadingValue(viewport); + }); + + // Assert + Assert.Collection(cut.Instance.PageViewModel.ResourceTypesToVisibility.OrderBy(kvp => kvp.Key), + kvp => Assert.Equal("Type1", kvp.Key)); + } +} +``` + +## Shared Setup Helpers + +Dashboard services require extensive DI setup (telemetry, storage, localization, FluentUI JS interop mocks, etc.). Reuse existing shared setup methods to avoid duplicate registration logic. **When adding tests for a new area, add a new setup helper rather than duplicating setup across test classes.** + +### Setup Helper Index + +| Helper | Location | Purpose | +|--------|----------|---------| +| `FluentUISetupHelpers.AddCommonDashboardServices()` | `Shared/FluentUISetupHelpers.cs` | Registers core DI services shared by all dashboard pages (localization, storage, telemetry, theme, dialog, shortcuts, etc.) | +| `FluentUISetupHelpers.SetupFluentUIComponents()` | `Shared/FluentUISetupHelpers.cs` | Calls `AddFluentUIComponents()` and configures the menu provider for tests | +| `FluentUISetupHelpers.SetupDialogInfrastructure()` | `Shared/FluentUISetupHelpers.cs` | Combines common services + FluentUI components + dialog provider JS mocks | +| `FluentUISetupHelpers.SetupFluentDataGrid()` | `Shared/FluentUISetupHelpers.cs` | Mocks FluentDataGrid JS interop | +| `FluentUISetupHelpers.SetupFluentSearch()` | `Shared/FluentUISetupHelpers.cs` | Mocks FluentSearch JS interop | +| `FluentUISetupHelpers.SetupFluentMenu()` | `Shared/FluentUISetupHelpers.cs` | Mocks FluentMenu JS interop | +| `ResourceSetupHelpers.SetupResourcesPage()` | `Shared/ResourceSetupHelpers.cs` | Full setup for the Resources page | +| `ResourceSetupHelpers.SetupResourceDetails()` | `Shared/ResourceSetupHelpers.cs` | Setup for ResourceDetails control | +| `MetricsSetupHelpers.SetupMetricsPage()` | `Shared/MetricsSetupHelpers.cs` | Full setup for the Metrics page | +| `MetricsSetupHelpers.SetupChartContainer()` | `Shared/MetricsSetupHelpers.cs` | Setup for chart container and Plotly | +| `StructuredLogsSetupHelpers.SetupStructuredLogsDetails()` | `Shared/StructuredLogsSetupHelpers.cs` | Setup for structured log details | +| `IntegrationTestHelpers.CreateLoggerFactory()` | `Shared/IntegrationTestHelpers.cs` | Creates `ILoggerFactory` wired to xUnit test output | + +### FluentUI JS Interop Mocks + +FluentUI Blazor components require JavaScript interop. bUnit runs without a browser, so all JS calls must be mocked. Use the helpers from `FluentUISetupHelpers`: + +```csharp +// Each FluentUI component has a corresponding setup method +FluentUISetupHelpers.SetupFluentDataGrid(context); +FluentUISetupHelpers.SetupFluentSearch(context); +FluentUISetupHelpers.SetupFluentMenu(context); +FluentUISetupHelpers.SetupFluentDivider(context); +FluentUISetupHelpers.SetupFluentAnchor(context); +FluentUISetupHelpers.SetupFluentKeyCode(context); +FluentUISetupHelpers.SetupFluentToolbar(context); +FluentUISetupHelpers.SetupFluentOverflow(context); +FluentUISetupHelpers.SetupFluentTab(context); +FluentUISetupHelpers.SetupFluentList(context); +FluentUISetupHelpers.SetupFluentCheckbox(context); +FluentUISetupHelpers.SetupFluentTextField(context); +FluentUISetupHelpers.SetupFluentInputLabel(context); +FluentUISetupHelpers.SetupFluentAnchoredRegion(context); +FluentUISetupHelpers.SetupFluentDialogProvider(context); +``` + +### Adding a New Setup Helper + +When testing a new component area, create a dedicated setup helper in `Shared/`: + +```csharp +// Shared/MyFeatureSetupHelpers.cs +using Bunit; +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Dashboard.Components.Tests.Shared; + +internal static class MyFeatureSetupHelpers +{ + public static void SetupMyFeaturePage(TestContext context, IDashboardClient? dashboardClient = null) + { + // 1. Register common dashboard services + FluentUISetupHelpers.AddCommonDashboardServices(context); + + // 2. Setup FluentUI JS mocks for components used by the page + FluentUISetupHelpers.SetupFluentDataGrid(context); + FluentUISetupHelpers.SetupFluentSearch(context); + FluentUISetupHelpers.SetupFluentMenu(context); + + // 3. Register page-specific services + context.Services.AddSingleton(dashboardClient ?? new TestDashboardClient()); + context.Services.AddSingleton(); + } +} +``` + +## Shared Test Fakes + +Both test projects share hand-rolled fakes from `tests/Shared/`. No mocking framework is used. + +| Fake | Purpose | +|------|---------| +| `TestDashboardClient` | Configurable `IDashboardClient` with channel providers for resources, console logs, interactions, and commands | +| `TestDialogService` | Fake dialog service | +| `TestSessionStorage` | In-memory session storage | +| `TestStringLocalizer` | Pass-through string localizer | +| `TestDashboardTelemetrySender` | No-op telemetry sender | +| `TestAIContextProvider` | No-op AI context provider | +| `ModelTestHelpers.CreateResource()` | Factory for building `ResourceViewModel` instances with sensible defaults | + +### Using TestDashboardClient + +`TestDashboardClient` is constructor-configurable with channel providers: + +```csharp +var resourceChannel = Channel.CreateUnbounded>(); +var consoleLogsChannel = Channel.CreateUnbounded>(); + +var dashboardClient = new TestDashboardClient( + isEnabled: true, + initialResources: [testResource], + resourceChannelProvider: () => resourceChannel, + consoleLogsChannelProvider: name => consoleLogsChannel); +``` + +### Using ModelTestHelpers + +Create test resource view models with keyword arguments: + +```csharp +using Aspire.Tests.Shared.DashboardModel; + +var resource = ModelTestHelpers.CreateResource( + resourceName: "myapp", + resourceType: "Project", + state: KnownResourceState.Running); +``` + +## Test Conventions + +### DO: Use `[UseCulture("en-US")]` on Component Tests + +All bUnit test classes should be decorated with `[UseCulture("en-US")]` for deterministic formatting: + +```csharp +[UseCulture("en-US")] +public partial class ResourcesTests : DashboardTestContext +``` + +### DO: Reuse Shared Setup Methods + +Call existing helpers instead of duplicating DI registrations: + +```csharp +// DO: Use the shared helper +ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); + +// DON'T: Duplicate service registration in every test class +Services.AddSingleton(); +Services.AddSingleton(); +Services.AddSingleton(); +// ... 20 more lines +``` + +### DO: Create New Setup Helpers for New Areas + +If testing a new page or component area, add a setup helper in `Shared/` to consolidate the setup: + +```csharp +// DO: Create a helper when multiple tests need the same setup +internal static class NewFeatureSetupHelpers +{ + public static void SetupNewFeaturePage(TestContext context) { ... } +} + +// DON'T: Copy-paste setup across test methods +``` + +### DO: Use `WaitForAssertion` for Async State Changes + +When component state updates happen asynchronously, use bUnit's `WaitForAssertion`: + +```csharp +cut.WaitForAssertion(() => +{ + var items = cut.FindAll(".resource-row"); + Assert.Equal(3, items.Count); +}); +``` + +### DO: Use Channels to Simulate Real-Time Updates + +Push changes through channels to simulate dashboard data updates: + +```csharp +var channel = Channel.CreateUnbounded>(); +var dashboardClient = new TestDashboardClient( + isEnabled: true, + initialResources: [], + resourceChannelProvider: () => channel); + +// Render the component... + +// Simulate an update +channel.Writer.TryWrite([ + new ResourceViewModelChange( + ResourceViewModelChangeType.Upsert, + ModelTestHelpers.CreateResource("newResource")) +]); + +// Wait for the UI to update +cut.WaitForAssertion(() => +{ + Assert.Equal(1, cut.FindAll(".resource-row").Count); +}); +``` + +### DO: Provide ViewportInformation for Responsive Components + +Many dashboard pages require viewport information: + +```csharp +var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + +// Set on DimensionManager +var dimensionManager = Services.GetRequiredService(); +dimensionManager.InvokeOnViewportInformationChanged(viewport); + +// Pass as cascading parameter +var cut = RenderComponent(builder => +{ + builder.AddCascadingValue(viewport); +}); +``` + +### DON'T: Use Mocking Frameworks + +The project uses hand-rolled fakes: + +```csharp +// DON'T: No mocking frameworks +var mock = new Mock(); + +// DO: Use the provided test fakes +var client = new TestDashboardClient(isEnabled: true, initialResources: resources); +``` + +### DON'T: Register Services Manually When a Helper Exists + +```csharp +// DON'T: Manual FluentUI setup +var module = JSInterop.SetupModule("./_content/Microsoft.FluentUI.../FluentDataGrid.razor.js"); +module.SetupVoid("enableColumnResizing", _ => true); + +// DO: Use the helper +FluentUISetupHelpers.SetupFluentDataGrid(this); +``` + +## Running Dashboard Tests + +```bash +# Run non-Blazor dashboard tests +dotnet test tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" + +# Run Blazor component tests +dotnet test tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj -- --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" + +# Run a specific test +dotnet test tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj -- --filter-method "*.UpdateResources_FiltersUpdated" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" +``` diff --git a/AGENTS.md b/AGENTS.md index cb6596c3d31..123faa4bf01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -352,6 +352,7 @@ For most development tasks, following these instructions should be sufficient to The following specialized skills are available in `.github/skills/`: - **cli-e2e-testing**: Guide for writing Aspire CLI end-to-end tests using Hex1b terminal automation +- **dashboard-testing**: Guide for writing tests for the Aspire Dashboard using xUnit and bUnit - **test-management**: Quarantines or disables flaky/problematic tests using the QuarantineTools utility - **connection-properties**: Expert for creating and improving Connection Properties in Aspire resources - **dependency-update**: Guides dependency version updates by checking nuget.org, triggering the dotnet-migrate-package Azure DevOps pipeline, and monitoring runs From 61d07a801bddb4d07a21bf8c326f3fe5528f3fda Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 24 Feb 2026 15:59:48 +0800 Subject: [PATCH 158/256] LogsCommand improvements (#14632) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Aspire.Cli.csproj | 1 + .../Backchannel/ExtensionBackchannel.cs | 6 +- src/Aspire.Cli/Commands/ExecCommand.cs | 2 +- src/Aspire.Cli/Commands/LogsCommand.cs | 120 ++++++-- src/Aspire.Cli/Commands/SetupCommand.cs | 6 +- .../Interaction/ConsoleInteractionService.cs | 4 +- .../ExtensionInteractionService.cs | 6 +- .../Interaction/IInteractionService.cs | 2 +- .../Resources/LogsCommandStrings.Designer.cs | 12 + .../Resources/LogsCommandStrings.resx | 6 + .../Resources/xlf/LogsCommandStrings.cs.xlf | 10 + .../Resources/xlf/LogsCommandStrings.de.xlf | 10 + .../Resources/xlf/LogsCommandStrings.es.xlf | 10 + .../Resources/xlf/LogsCommandStrings.fr.xlf | 10 + .../Resources/xlf/LogsCommandStrings.it.xlf | 10 + .../Resources/xlf/LogsCommandStrings.ja.xlf | 10 + .../Resources/xlf/LogsCommandStrings.ko.xlf | 10 + .../Resources/xlf/LogsCommandStrings.pl.xlf | 10 + .../xlf/LogsCommandStrings.pt-BR.xlf | 10 + .../Resources/xlf/LogsCommandStrings.ru.xlf | 10 + .../Resources/xlf/LogsCommandStrings.tr.xlf | 10 + .../xlf/LogsCommandStrings.zh-Hans.xlf | 10 + .../xlf/LogsCommandStrings.zh-Hant.xlf | 10 + .../Components/Pages/ConsoleLogs.razor.cs | 2 +- .../Mcp/AspireResourceMcpTools.cs | 2 +- src/Shared/ConsoleLogs/LogParser.cs | 49 +-- .../Commands/LogsCommandTests.cs | 291 ++++++++++++++++++ .../Commands/NewCommandTests.cs | 2 +- ...PublishCommandPromptingIntegrationTests.cs | 2 +- .../Commands/UpdateCommandTests.cs | 2 +- .../Templating/DotNetTemplateFactoryTests.cs | 2 +- .../TestConsoleInteractionService.cs | 2 +- .../TestServices/TestExtensionBackchannel.cs | 4 +- .../TestExtensionInteractionService.cs | 2 +- .../ConsoleLogsTests/LogEntriesTests.cs | 8 +- 35 files changed, 585 insertions(+), 78 deletions(-) diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 8eedc788dfd..090a34ea79e 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -67,6 +67,7 @@ + diff --git a/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs b/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs index 1406b387b86..eefff9a8b0f 100644 --- a/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs +++ b/src/Aspire.Cli/Backchannel/ExtensionBackchannel.cs @@ -21,7 +21,7 @@ namespace Aspire.Cli.Backchannel; internal interface IExtensionBackchannel { Task ConnectAsync(CancellationToken cancellationToken); - Task DisplayMessageAsync(string emoji, string message, CancellationToken cancellationToken); + Task DisplayMessageAsync(string emojiName, string message, CancellationToken cancellationToken); Task DisplaySuccessAsync(string message, CancellationToken cancellationToken); Task DisplaySubtleMessageAsync(string message, CancellationToken cancellationToken); Task DisplayErrorAsync(string error, CancellationToken cancellationToken); @@ -246,7 +246,7 @@ static void AddLocalRpcTarget(JsonRpc rpc, IExtensionRpcTarget target) } } - public async Task DisplayMessageAsync(string emoji, string message, CancellationToken cancellationToken) + public async Task DisplayMessageAsync(string emojiName, string message, CancellationToken cancellationToken) { await ConnectAsync(cancellationToken); @@ -258,7 +258,7 @@ public async Task DisplayMessageAsync(string emoji, string message, Cancellation await rpc.InvokeWithCancellationAsync( "displayMessage", - [_token, emoji, message], + [_token, emojiName, message], cancellationToken); } diff --git a/src/Aspire.Cli/Commands/ExecCommand.cs b/src/Aspire.Cli/Commands/ExecCommand.cs index 6dfab4368be..c73cbe74ea4 100644 --- a/src/Aspire.Cli/Commands/ExecCommand.cs +++ b/src/Aspire.Cli/Commands/ExecCommand.cs @@ -199,7 +199,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // of the apphost so that the user can attach to it. if (waitForDebugger) { - InteractionService.DisplayMessage(emoji: "bug", InteractionServiceStrings.WaitingForDebuggerToAttachToAppHost); + InteractionService.DisplayMessage("bug", InteractionServiceStrings.WaitingForDebuggerToAttachToAppHost); } // The wait for the debugger in the apphost is done inside the CreateBuilder(...) method diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs index 4530648261d..b6412a0aced 100644 --- a/src/Aspire.Cli/Commands/LogsCommand.cs +++ b/src/Aspire.Cli/Commands/LogsCommand.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.Globalization; using System.Runtime.CompilerServices; using System.Text.Encodings.Web; using System.Text.Json; @@ -12,6 +13,7 @@ using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; +using Aspire.Shared.ConsoleLogs; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -23,6 +25,7 @@ namespace Aspire.Cli.Commands; internal sealed class LogLineJson { public required string ResourceName { get; init; } + public string? Timestamp { get; init; } public required string Content { get; init; } public required bool IsError { get; init; } } @@ -98,6 +101,10 @@ internal sealed class LogsCommand : BaseCommand { Description = LogsCommandStrings.TailOptionDescription }; + private static readonly Option s_timestampsOption = new("--timestamps", "-t") + { + Description = LogsCommandStrings.TimestampsOptionDescription + }; // Colors to cycle through for different resources (similar to docker-compose) private static readonly Color[] s_resourceColors = @@ -137,6 +144,7 @@ public LogsCommand( Options.Add(s_followOption); Options.Add(s_formatOption); Options.Add(s_tailOption); + Options.Add(s_timestampsOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) @@ -148,6 +156,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var follow = parseResult.GetValue(s_followOption); var format = parseResult.GetValue(s_formatOption); var tail = parseResult.GetValue(s_tailOption); + var timestamps = parseResult.GetValue(s_timestampsOption); // Validate --tail value if (tail.HasValue && tail.Value < 1) @@ -173,13 +182,37 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell return ExitCodeConstants.Success; } + var connection = result.Connection!; + + // Fetch snapshots for resource name resolution + var snapshots = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); + + // Validate resource name exists (match by Name or DisplayName since users may pass either) + if (resourceName is not null) + { + if (!snapshots.Any(s => string.Equals(s.Name, resourceName, StringComparisons.ResourceName) + || string.Equals(s.DisplayName, resourceName, StringComparisons.ResourceName))) + { + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, LogsCommandStrings.ResourceNotFound, resourceName)); + return ExitCodeConstants.InvalidCommand; + } + } + else + { + if (snapshots.Count == 0) + { + _interactionService.DisplayMessage("information", LogsCommandStrings.NoResourcesFound); + return ExitCodeConstants.Success; + } + } + if (follow) { - return await ExecuteWatchAsync(result.Connection!, resourceName, format, tail, cancellationToken); + return await ExecuteWatchAsync(connection, resourceName, format, tail, timestamps, snapshots, cancellationToken); } else { - return await ExecuteGetAsync(result.Connection!, resourceName, format, tail, cancellationToken); + return await ExecuteGetAsync(connection, resourceName, format, tail, timestamps, snapshots, cancellationToken); } } @@ -188,18 +221,20 @@ private async Task ExecuteGetAsync( string? resourceName, OutputFormat format, int? tail, + bool timestamps, + IReadOnlyList snapshots, CancellationToken cancellationToken) { // Collect all logs List logLines; if (!tail.HasValue) { - logLines = await CollectLogsAsync(connection, resourceName, cancellationToken).ConfigureAwait(false); + logLines = await CollectLogsAsync(connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false); } else { // With tail specified, collect all logs first then take last N - logLines = await CollectLogsAsync(connection, resourceName, cancellationToken).ConfigureAwait(false); + logLines = await CollectLogsAsync(connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false); // Apply tail filter (tail.Value is guaranteed >= 1 by earlier validation) if (logLines.Count > tail.Value) @@ -209,16 +244,23 @@ private async Task ExecuteGetAsync( } // Output the logs + var logParser = new LogParser(ConsoleColor.Black); + if (format == OutputFormat.Json) { // Wrapped JSON for snapshot - single JSON object compatible with jq var logsOutput = new LogsOutput { - Logs = logLines.Select(l => new LogLineJson + Logs = logLines.Select(l => { - ResourceName = l.ResourceName, - Content = l.Content, - IsError = l.IsError + var entry = logParser.CreateLogEntry(l.Content, l.IsError, l.ResourceName); + return new LogLineJson + { + ResourceName = ResolveResourceName(l.ResourceName, snapshots), + Timestamp = timestamps && entry.Timestamp.HasValue ? FormatTimestamp(entry.Timestamp.Value) : null, + Content = entry.Content ?? l.Content, + IsError = l.IsError + }; }).ToArray() }; var json = JsonSerializer.Serialize(logsOutput, LogsCommandJsonContext.Snapshot.LogsOutput); @@ -229,7 +271,7 @@ private async Task ExecuteGetAsync( { foreach (var logLine in logLines) { - OutputLogLine(logLine, format); + OutputLogLine(logLine, format, timestamps, logParser, snapshots); } } @@ -241,12 +283,16 @@ private async Task ExecuteWatchAsync( string? resourceName, OutputFormat format, int? tail, + bool timestamps, + IReadOnlyList snapshots, CancellationToken cancellationToken) { + var logParser = new LogParser(ConsoleColor.Black); + // If tail is specified, show last N lines first before streaming if (tail.HasValue) { - var historicalLogs = await CollectLogsAsync(connection, resourceName, cancellationToken).ConfigureAwait(false); + var historicalLogs = await CollectLogsAsync(connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false); // Output last N lines var tailedLogs = historicalLogs.Count > tail.Value @@ -255,14 +301,14 @@ private async Task ExecuteWatchAsync( foreach (var logLine in tailedLogs) { - OutputLogLine(logLine, format); + OutputLogLine(logLine, format, timestamps, logParser, snapshots); } } // Now stream new logs await foreach (var logLine in connection.GetResourceLogsAsync(resourceName, follow: true, cancellationToken).ConfigureAwait(false)) { - OutputLogLine(logLine, format); + OutputLogLine(logLine, format, timestamps, logParser, snapshots); } return ExitCodeConstants.Success; @@ -271,13 +317,14 @@ private async Task ExecuteWatchAsync( /// /// Collects all logs for a resource (or all resources if resourceName is null) into a list. /// - private async Task> CollectLogsAsync( + private static async Task> CollectLogsAsync( IAppHostAuxiliaryBackchannel connection, string? resourceName, + IReadOnlyList snapshots, CancellationToken cancellationToken) { var logLines = new List(); - await foreach (var logLine in GetLogsAsync(connection, resourceName, cancellationToken).ConfigureAwait(false)) + await foreach (var logLine in GetLogsAsync(connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false)) { logLines.Add(logLine); } @@ -287,9 +334,10 @@ private async Task> CollectLogsAsync( /// /// Gets logs for a resource (or all resources if resourceName is null) as an async enumerable. /// - private async IAsyncEnumerable GetLogsAsync( + private static async IAsyncEnumerable GetLogsAsync( IAppHostAuxiliaryBackchannel connection, string? resourceName, + IReadOnlyList snapshots, [EnumeratorCancellation] CancellationToken cancellationToken) { if (resourceName is not null) @@ -302,13 +350,6 @@ private async IAsyncEnumerable GetLogsAsync( } // Get all resources and stream logs for each (like docker compose logs) - var snapshots = await connection.GetResourceSnapshotsAsync(cancellationToken).ConfigureAwait(false); - if (snapshots.Count == 0) - { - _interactionService.DisplayMessage("ℹ️", LogsCommandStrings.NoResourcesFound); - yield break; - } - foreach (var snapshot in snapshots.OrderBy(s => s.Name)) { await foreach (var logLine in connection.GetResourceLogsAsync(snapshot.Name, follow: false, cancellationToken).ConfigureAwait(false)) @@ -318,15 +359,21 @@ private async IAsyncEnumerable GetLogsAsync( } } - private void OutputLogLine(ResourceLogLine logLine, OutputFormat format) + private void OutputLogLine(ResourceLogLine logLine, OutputFormat format, bool timestamps, LogParser logParser, IReadOnlyList snapshots) { + var displayName = ResolveResourceName(logLine.ResourceName, snapshots); + var entry = logParser.CreateLogEntry(logLine.Content, logLine.IsError, logLine.ResourceName); + var content = entry.Content ?? logLine.Content; + var timestampPrefix = timestamps && entry.Timestamp.HasValue ? FormatTimestamp(entry.Timestamp.Value) + " " : string.Empty; + if (format == OutputFormat.Json) { // NDJSON for streaming - compact, one object per line var logLineJson = new LogLineJson { - ResourceName = logLine.ResourceName, - Content = logLine.Content, + ResourceName = displayName, + Timestamp = timestamps && entry.Timestamp.HasValue ? FormatTimestamp(entry.Timestamp.Value) : null, + Content = content, IsError = logLine.IsError }; var output = JsonSerializer.Serialize(logLineJson, LogsCommandJsonContext.Ndjson.LogLineJson); @@ -336,14 +383,14 @@ private void OutputLogLine(ResourceLogLine logLine, OutputFormat format) else if (_hostEnvironment.SupportsAnsi) { // Colorized output: assign a consistent color to each resource - var color = GetResourceColor(logLine.ResourceName); - var escapedContent = logLine.Content.EscapeMarkup(); - AnsiConsole.MarkupLine($"[{color}][[{logLine.ResourceName.EscapeMarkup()}]][/] {escapedContent}"); + var color = GetResourceColor(displayName); + var escapedContent = content.EscapeMarkup(); + AnsiConsole.MarkupLine($"{timestampPrefix.EscapeMarkup()}[{color}][[{displayName.EscapeMarkup()}]][/] {escapedContent}"); } else { // Plain text fallback when colors not supported - _interactionService.DisplayPlainText($"[{logLine.ResourceName}] {logLine.Content}"); + _interactionService.DisplayPlainText($"{timestampPrefix}[{displayName}] {content}"); } } @@ -357,4 +404,19 @@ private Color GetResourceColor(string resourceName) } return color; } + + private static string FormatTimestamp(DateTime timestamp) + { + return timestamp.ToString("yyyy-MM-ddTHH:mm:ss.fffK", CultureInfo.InvariantCulture); + } + + private static string ResolveResourceName(string resourceName, IReadOnlyList snapshots) + { + var snapshot = snapshots.FirstOrDefault(s => string.Equals(s.Name, resourceName, StringComparisons.ResourceName)); + if (snapshot is not null) + { + return ResourceSnapshotMapper.GetResourceName(snapshot, snapshots); + } + return resourceName; + } } diff --git a/src/Aspire.Cli/Commands/SetupCommand.cs b/src/Aspire.Cli/Commands/SetupCommand.cs index 1576b333ea7..f000cf6545a 100644 --- a/src/Aspire.Cli/Commands/SetupCommand.cs +++ b/src/Aspire.Cli/Commands/SetupCommand.cs @@ -81,15 +81,15 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell switch (result) { case BundleExtractResult.NoPayload: - InteractionService.DisplayMessage(":information:", "This CLI binary does not contain an embedded bundle. No extraction needed."); + InteractionService.DisplayMessage("information", "This CLI binary does not contain an embedded bundle. No extraction needed."); break; case BundleExtractResult.AlreadyUpToDate: - InteractionService.DisplayMessage(":white_check_mark:", "Bundle is already extracted and up to date. Use --force to re-extract."); + InteractionService.DisplayMessage("white_check_mark", "Bundle is already extracted and up to date. Use --force to re-extract."); break; case BundleExtractResult.Extracted: - InteractionService.DisplayMessage(":white_check_mark:", $"Bundle extracted to {installPath}"); + InteractionService.DisplayMessage("white_check_mark", $"Bundle extracted to {installPath}"); break; case BundleExtractResult.ExtractionFailed: diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index 8f4f22c464a..361be14bd7d 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -208,13 +208,13 @@ public void DisplayError(string errorMessage) DisplayMessage("cross_mark", $"[red bold]{errorMessage.EscapeMarkup()}[/]"); } - public void DisplayMessage(string emoji, string message) + public void DisplayMessage(string emojiName, string message) { // This is a hack to deal with emoji of different size. We write the emoji then move the cursor to aboslute column 4 // on the same line before writing the message. This ensures that the message starts at the same position regardless // of the emoji used. I'm not OCD .. you are! var console = MessageConsole; - console.Markup($":{emoji}:"); + console.Markup($":{emojiName}:"); console.Write("\u001b[4G"); console.MarkupLine(message); } diff --git a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs index 80113c16829..fc534407db2 100644 --- a/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ExtensionInteractionService.cs @@ -233,11 +233,11 @@ public void DisplayError(string errorMessage) _consoleInteractionService.DisplayError(errorMessage); } - public void DisplayMessage(string emoji, string message) + public void DisplayMessage(string emojiName, string message) { - var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayMessageAsync(emoji, message.RemoveSpectreFormatting(), _cancellationToken)); + var result = _extensionTaskChannel.Writer.TryWrite(() => Backchannel.DisplayMessageAsync(emojiName, message.RemoveSpectreFormatting(), _cancellationToken)); Debug.Assert(result); - _consoleInteractionService.DisplayMessage(emoji, message); + _consoleInteractionService.DisplayMessage(emojiName, message); } public void DisplaySuccess(string message) diff --git a/src/Aspire.Cli/Interaction/IInteractionService.cs b/src/Aspire.Cli/Interaction/IInteractionService.cs index 01625eb0b93..d49db12e2b5 100644 --- a/src/Aspire.Cli/Interaction/IInteractionService.cs +++ b/src/Aspire.Cli/Interaction/IInteractionService.cs @@ -16,7 +16,7 @@ internal interface IInteractionService Task> PromptForSelectionsAsync(string promptText, IEnumerable choices, Func choiceFormatter, CancellationToken cancellationToken = default) where T : notnull; int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion); void DisplayError(string errorMessage); - void DisplayMessage(string emoji, string message); + void DisplayMessage(string emojiName, string message); void DisplayPlainText(string text); void DisplayRawText(string text, ConsoleOutput? consoleOverride = null); void DisplayMarkdown(string markdown); diff --git a/src/Aspire.Cli/Resources/LogsCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/LogsCommandStrings.Designer.cs index 19522fbda1b..74af5822262 100644 --- a/src/Aspire.Cli/Resources/LogsCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/LogsCommandStrings.Designer.cs @@ -122,5 +122,17 @@ public static string TailMustBePositive { return ResourceManager.GetString("TailMustBePositive", resourceCulture); } } + + public static string ResourceNotFound { + get { + return ResourceManager.GetString("ResourceNotFound", resourceCulture); + } + } + + public static string TimestampsOptionDescription { + get { + return ResourceManager.GetString("TimestampsOptionDescription", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/LogsCommandStrings.resx b/src/Aspire.Cli/Resources/LogsCommandStrings.resx index ffc9ec5eb81..6bd0d1f5831 100644 --- a/src/Aspire.Cli/Resources/LogsCommandStrings.resx +++ b/src/Aspire.Cli/Resources/LogsCommandStrings.resx @@ -159,4 +159,10 @@ The --tail value must be a positive number. + + Resource '{0}' was not found. + + + Show timestamps for each log line. + diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf index a6bfe4146f6..3570d122da1 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf @@ -42,6 +42,11 @@ Název prostředku, pro který se mají načíst protokoly. Pokud se nezadá, zobrazí se protokoly ze všech prostředků. + + Resource '{0}' was not found. + Resource '{0}' was not found. + + A resource name is required when not using --follow. Use --follow to stream logs from all resources. Pokud nepoužíváte --follow, vyžaduje se název prostředku. Pokud chcete streamovat protokoly ze všech prostředků, použijte --follow. @@ -72,6 +77,11 @@ Možnost --tail vyžaduje zadání názvu prostředku. + + Show timestamps for each log line. + Show timestamps for each log line. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf index 673b4da020e..0506b7af164 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf @@ -42,6 +42,11 @@ Name der Ressource, für die Protokolle abgerufen werden sollen. Wenn keine Angabe erfolgt, werden Protokolle von allen Ressourcen angezeigt. + + Resource '{0}' was not found. + Resource '{0}' was not found. + + A resource name is required when not using --follow. Use --follow to stream logs from all resources. Ein Ressourcenname ist erforderlich, wenn --follow nicht verwendet wird. Verwenden Sie --follow, um Protokolle aller Ressourcen zu streamen. @@ -72,6 +77,11 @@ Für die Option --tail muss ein Ressourcenname angegeben werden. + + Show timestamps for each log line. + Show timestamps for each log line. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf index a5f9fa7e4aa..69b69d68e25 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf @@ -42,6 +42,11 @@ Nombre del recurso para el que se van a obtener registros. Si no se especifica, se muestran los registros de todos los recursos. + + Resource '{0}' was not found. + Resource '{0}' was not found. + + A resource name is required when not using --follow. Use --follow to stream logs from all resources. Se requiere un nombre de recurso cuando no se usa --follow. Use --follow para transmitir registros de todos los recursos. @@ -72,6 +77,11 @@ La opción --tail requiere que se especifique un nombre de recurso. + + Show timestamps for each log line. + Show timestamps for each log line. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf index 3af538ead9a..ab190ddba81 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf @@ -42,6 +42,11 @@ Le nom de la ressource pour laquelle obtenir les journaux. Si aucun nom n’est spécifié, les journaux d’activité de toutes les ressources sont affichées. + + Resource '{0}' was not found. + Resource '{0}' was not found. + + A resource name is required when not using --follow. Use --follow to stream logs from all resources. Un nom de ressource est requis si vous n’utilisez pas --follow. Utilisez --follow pour diffuser en continu les journaux de toutes les ressources. @@ -72,6 +77,11 @@ L’option --tail requiert la spécification d’un nom de ressource. + + Show timestamps for each log line. + Show timestamps for each log line. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf index 4c38357db19..27999b6fd2f 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf @@ -42,6 +42,11 @@ Nome della risorsa per cui ottenere i log. Se non specificato, vengono visualizzati i log di tutte le risorse. + + Resource '{0}' was not found. + Resource '{0}' was not found. + + A resource name is required when not using --follow. Use --follow to stream logs from all resources. Quando non si usa --follow, è necessario specificare un nome di risorsa. Usare --follow per trasmettere i log da tutte le risorse. @@ -72,6 +77,11 @@ L'opzione --tail richiede che venga specificato un nome di risorsa. + + Show timestamps for each log line. + Show timestamps for each log line. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf index 1fe4afe31f3..875087f00bb 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf @@ -42,6 +42,11 @@ ログ取得の対象とするリソースの名前。指定しない場合は、すべてのリソースのログが表示されます。 + + Resource '{0}' was not found. + Resource '{0}' was not found. + + A resource name is required when not using --follow. Use --follow to stream logs from all resources. --follow を使わない場合はリソース名の指定が必須です。--follow を使う場合、すべてのリソースのログがストリーミングされます。 @@ -72,6 +77,11 @@ --tail オプションにはリソース名の指定が必須です。 + + Show timestamps for each log line. + Show timestamps for each log line. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf index 5f6d51ee28d..1f99f1d1a82 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf @@ -42,6 +42,11 @@ 로그를 가져올 리소스의 이름입니다. 지정하지 않으면 모든 리소스의 로그가 표시됩니다. + + Resource '{0}' was not found. + Resource '{0}' was not found. + + A resource name is required when not using --follow. Use --follow to stream logs from all resources. --follow를 사용하지 않을 경우 리소스 이름은 필수 항목입니다. --follow를 사용하여 모든 리소스의 로그를 스트리밍합니다. @@ -72,6 +77,11 @@ --tail 옵션을 사용하려면 리소스 이름을 지정해야 합니다. + + Show timestamps for each log line. + Show timestamps for each log line. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf index 79c7ebcdeec..c3ff4fd50eb 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf @@ -42,6 +42,11 @@ Nazwa zasobu, którego dzienniki mają zostać pobrane. Jeśli nie podasz nazwy, pokażą się dzienniki ze wszystkich zasobów. + + Resource '{0}' was not found. + Resource '{0}' was not found. + + A resource name is required when not using --follow. Use --follow to stream logs from all resources. Nazwa zasobu jest wymagana, jeśli nie używasz opcji --follow. Użyj opcji --follow, aby przesyłać strumieniowo dzienniki ze wszystkich zasobów. @@ -72,6 +77,11 @@ Opcja --tail wymaga podania nazwy zasobu. + + Show timestamps for each log line. + Show timestamps for each log line. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf index 57cfe0a316b..cda7ef34669 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf @@ -42,6 +42,11 @@ O nome do recurso para o qual obter logs. Se não for especificado, os logs de todos os recursos serão mostrados. + + Resource '{0}' was not found. + Resource '{0}' was not found. + + A resource name is required when not using --follow. Use --follow to stream logs from all resources. Um nome de recurso é necessário ao não usar --follow. Use --follow para transmitir logs de todos os recursos. @@ -72,6 +77,11 @@ A opção --tail requer que um nome de recurso seja especificado. + + Show timestamps for each log line. + Show timestamps for each log line. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf index ae1e063dbdc..445bdd48294 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf @@ -42,6 +42,11 @@ Имя ресурса, для которого нужно получить журналы. Если не указано, отображаются журналы из всех ресурсов. + + Resource '{0}' was not found. + Resource '{0}' was not found. + + A resource name is required when not using --follow. Use --follow to stream logs from all resources. При отсутствии параметра --follow необходимо указать имя ресурса. Используйте --follow для потоковой передачи журналов из всех ресурсов. @@ -72,6 +77,11 @@ Параметр --tail требует указания имени ресурса. + + Show timestamps for each log line. + Show timestamps for each log line. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf index 2c6ad882d5e..d3a450aa5e8 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf @@ -42,6 +42,11 @@ Günlükleri alınacak kaynağın adı. Belirtilmezse tüm kaynakların günlükleri gösterilir. + + Resource '{0}' was not found. + Resource '{0}' was not found. + + A resource name is required when not using --follow. Use --follow to stream logs from all resources. --follow kullanılmadığında kaynak adı gereklidir. Tüm kaynaklardan günlük akışı yapmak için --follow kullanın. @@ -72,6 +77,11 @@ --tail seçeneği için bir kaynak adı belirtilmelidir. + + Show timestamps for each log line. + Show timestamps for each log line. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf index 814c5335594..f2e6a1eea79 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf @@ -42,6 +42,11 @@ 要获取其日志的资源的名称。如果未指定,则显示所有资源的日志。 + + Resource '{0}' was not found. + Resource '{0}' was not found. + + A resource name is required when not using --follow. Use --follow to stream logs from all resources. 不使用 --follow 时必须指定资源名称。使用 --follow 流式传输所有资源的日志。 @@ -72,6 +77,11 @@ --tail 选项需要指定资源名称。 + + Show timestamps for each log line. + Show timestamps for each log line. + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf index 4fa74381ebc..afaf1ff2bd7 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf @@ -42,6 +42,11 @@ 要取得其記錄的資源名稱。如果未指定,則顯示所有資源的記錄。 + + Resource '{0}' was not found. + Resource '{0}' was not found. + + A resource name is required when not using --follow. Use --follow to stream logs from all resources. 當未使用 --follow 時,必須指定資源名稱。使用 --follow 從所有資源串流記錄。 @@ -72,6 +77,11 @@ --tail 選項需要指定資源名稱。 + + Show timestamps for each log line. + Show timestamps for each log line. + + \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index 1090db849d9..7405c4ae8e5 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -780,7 +780,7 @@ private void LoadLogsForResource(ConsoleLogsSubscription subscription) var resourcePrefix = ResourceViewModel.GetResourceName(subscription.Resource, _resourceByName); - var logParser = new LogParser(ConsoleColor.Black); + var logParser = new LogParser(ConsoleColor.Black, encodeForHtml: true); await foreach (var batch in logSubscription.ConfigureAwait(false)) { subscription.CancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs b/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs index b38155710be..6b949875440 100644 --- a/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs +++ b/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs @@ -91,7 +91,7 @@ public async Task ListConsoleLogsAsync( return $"Unable to find a resource named '{resourceName}'."; } - var logParser = new LogParser(ConsoleColor.Black); + var logParser = new LogParser(ConsoleColor.Black, encodeForHtml: true); var logEntries = new LogEntries(maximumEntryCount: AIHelpers.ConsoleLogsLimit) { BaseLineNumber = 1 }; // Add a timeout for getting all console logs. diff --git a/src/Shared/ConsoleLogs/LogParser.cs b/src/Shared/ConsoleLogs/LogParser.cs index 75521c6d06d..93e1a4aa3be 100644 --- a/src/Shared/ConsoleLogs/LogParser.cs +++ b/src/Shared/ConsoleLogs/LogParser.cs @@ -8,11 +8,13 @@ namespace Aspire.Shared.ConsoleLogs; internal sealed class LogParser { private readonly ConsoleColor _defaultBackgroundColor; + private readonly bool _encodeForHtml; private AnsiParser.ParserState? _residualState; - public LogParser(ConsoleColor defaultBackgroundColor) + public LogParser(ConsoleColor defaultBackgroundColor, bool encodeForHtml = false) { _defaultBackgroundColor = defaultBackgroundColor; + _encodeForHtml = encodeForHtml; } public LogEntry CreateLogEntry(string rawText, bool isErrorOutput, string? resourcePrefix) @@ -20,9 +22,9 @@ public LogEntry CreateLogEntry(string rawText, bool isErrorOutput, string? resou // Several steps to do here: // // 1. Parse the content to look for the timestamp - // 2. HTML Encode the raw text for security purposes - // 3. Parse the content to look for ANSI Control Sequences and color them if possible - // 4. Parse the content to look for URLs and make them links if possible + // 2. HTML Encode the raw text for security purposes (when encodeForHtml is true) + // 3. Parse the content to look for ANSI Control Sequences and color them if possible (when encodeForHtml is true) + // 4. Parse the content to look for URLs and make them links if possible (when encodeForHtml is true) // 5. Create the LogEntry var content = rawText; @@ -36,29 +38,32 @@ public LogEntry CreateLogEntry(string rawText, bool isErrorOutput, string? resou timestamp = timestampParseResult.Value.Timestamp.UtcDateTime; } - Func callback = (s) => + if (_encodeForHtml) { - // This callback is run on text that isn't transformed into a clickable URL. + Func callback = (s) => + { + // This callback is run on text that isn't transformed into a clickable URL. - // 3a. HTML Encode the raw text for security purposes - var updatedText = WebUtility.HtmlEncode(s); + // 3a. HTML Encode the raw text for security purposes + var updatedText = WebUtility.HtmlEncode(s); - // 3b. Parse the content to look for ANSI Control Sequences and color them if possible - var conversionResult = AnsiParser.ConvertToHtml(updatedText, _residualState, _defaultBackgroundColor); - updatedText = conversionResult.ConvertedText; - _residualState = conversionResult.ResidualState; + // 3b. Parse the content to look for ANSI Control Sequences and color them if possible + var conversionResult = AnsiParser.ConvertToHtml(updatedText, _residualState, _defaultBackgroundColor); + updatedText = conversionResult.ConvertedText; + _residualState = conversionResult.ResidualState; - return updatedText ?? string.Empty; - }; + return updatedText ?? string.Empty; + }; - // 3. Parse the content to look for URLs and make them links if possible - if (UrlParser.TryParse(content, callback, out var modifiedText)) - { - content = modifiedText; - } - else - { - content = callback(content); + // 3. Parse the content to look for URLs and make them links if possible + if (UrlParser.TryParse(content, callback, out var modifiedText)) + { + content = modifiedText; + } + else + { + content = callback(content); + } } // 5. Create the LogEntry diff --git a/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs index 2dddef0ad14..0e97fbf362f 100644 --- a/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; +using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.InternalTesting; @@ -364,4 +366,293 @@ public void LogsCommand_NdjsonFormat_HandlesSpecialCharactersInContent() Assert.NotNull(deserialized); Assert.Equal(logLine.Content, deserialized.Content); } + + [Fact] + public async Task LogsCommand_JsonOutput_ResolvesResourceNames() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = CreateLogsTestServices(workspace, outputWriter); + + var command = provider.GetRequiredService(); + var result = command.Parse("logs --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonOutput = outputWriter.Logs.FirstOrDefault(l => l.Contains("\"logs\"")); + Assert.NotNull(jsonOutput); + + var logsOutput = JsonSerializer.Deserialize(jsonOutput, LogsCommandJsonContext.Snapshot.LogsOutput); + Assert.NotNull(logsOutput); + Assert.Equal(3, logsOutput.Logs.Length); + + // Resources are ordered alphabetically by Name in the output. + // Replicas share the same DisplayName, so the unique Name should be used instead + Assert.Equal("apiservice-abc123", logsOutput.Logs[0].ResourceName); + Assert.Equal("apiservice-def456", logsOutput.Logs[1].ResourceName); + + // Unique display name should be used for the redis resource + Assert.Equal("redis", logsOutput.Logs[2].ResourceName); + } + + [Fact] + public async Task LogsCommand_TextOutput_ResolvesResourceNames() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = CreateLogsTestServices(workspace, outputWriter, configureOptions: config => + { + config["NO_COLOR"] = "1"; + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("logs"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // Plain text output uses "[resourceName] content" format + // Replicas share the same DisplayName, so the unique Name should be used instead + Assert.Contains(outputWriter.Logs, l => l.Contains("[apiservice-abc123]")); + Assert.Contains(outputWriter.Logs, l => l.Contains("[apiservice-def456]")); + + // Unique display name should be used for the redis resource + Assert.Contains(outputWriter.Logs, l => l.Contains("[redis]")); + } + + [Theory] + [InlineData("nonexistent", true)] + [InlineData("redis", false)] + [InlineData("apiservice-abc123", false)] + [InlineData("apiservice", false)] + public async Task LogsCommand_WithResourceName_ValidatesAgainstNameAndDisplayName(string resourceName, bool expectError) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = CreateLogsTestServices(workspace, outputWriter); + + var command = provider.GetRequiredService(); + var result = command.Parse($"logs {resourceName} --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + if (expectError) + { + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + else + { + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + } + + [Fact] + public async Task LogsCommand_JsonOutput_WithTimestamps_IncludesTimestampField() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = CreateLogsTestServices(workspace, outputWriter); + + var command = provider.GetRequiredService(); + var result = command.Parse("logs --format json --timestamps"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonOutput = outputWriter.Logs.FirstOrDefault(l => l.Contains("\"logs\"")); + Assert.NotNull(jsonOutput); + + var logsOutput = JsonSerializer.Deserialize(jsonOutput, LogsCommandJsonContext.Snapshot.LogsOutput); + Assert.NotNull(logsOutput); + Assert.Equal(3, logsOutput.Logs.Length); + + // Resources are ordered alphabetically by Name + Assert.Equal("apiservice-abc123", logsOutput.Logs[0].ResourceName); + Assert.Equal("2025-01-15T10:30:01.000Z", logsOutput.Logs[0].Timestamp); + Assert.Equal("Hello from replica 1", logsOutput.Logs[0].Content); + Assert.False(logsOutput.Logs[0].IsError); + + Assert.Equal("apiservice-def456", logsOutput.Logs[1].ResourceName); + Assert.Equal("2025-01-15T10:30:02.000Z", logsOutput.Logs[1].Timestamp); + Assert.Equal("Hello from replica 2", logsOutput.Logs[1].Content); + Assert.False(logsOutput.Logs[1].IsError); + + Assert.Equal("redis", logsOutput.Logs[2].ResourceName); + Assert.Equal("2025-01-15T10:30:00.000Z", logsOutput.Logs[2].Timestamp); + Assert.Equal("Ready to accept connections", logsOutput.Logs[2].Content); + Assert.False(logsOutput.Logs[2].IsError); + } + + [Fact] + public async Task LogsCommand_JsonOutput_WithoutTimestamps_OmitsTimestampField() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = CreateLogsTestServices(workspace, outputWriter); + + var command = provider.GetRequiredService(); + var result = command.Parse("logs --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonOutput = outputWriter.Logs.FirstOrDefault(l => l.Contains("\"logs\"")); + Assert.NotNull(jsonOutput); + + var logsOutput = JsonSerializer.Deserialize(jsonOutput, LogsCommandJsonContext.Snapshot.LogsOutput); + Assert.NotNull(logsOutput); + Assert.Equal(3, logsOutput.Logs.Length); + + // Timestamp should be null when --timestamps is not specified + Assert.Equal("apiservice-abc123", logsOutput.Logs[0].ResourceName); + Assert.Null(logsOutput.Logs[0].Timestamp); + Assert.Equal("Hello from replica 1", logsOutput.Logs[0].Content); + Assert.False(logsOutput.Logs[0].IsError); + + Assert.Equal("apiservice-def456", logsOutput.Logs[1].ResourceName); + Assert.Null(logsOutput.Logs[1].Timestamp); + Assert.Equal("Hello from replica 2", logsOutput.Logs[1].Content); + Assert.False(logsOutput.Logs[1].IsError); + + Assert.Equal("redis", logsOutput.Logs[2].ResourceName); + Assert.Null(logsOutput.Logs[2].Timestamp); + Assert.Equal("Ready to accept connections", logsOutput.Logs[2].Content); + Assert.False(logsOutput.Logs[2].IsError); + } + + [Fact] + public async Task LogsCommand_TextOutput_WithTimestamps_IncludesTimestampPrefix() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = CreateLogsTestServices(workspace, outputWriter, configureOptions: config => + { + config["NO_COLOR"] = "1"; + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("logs --timestamps"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // Resources are ordered alphabetically by Name, timestamp prefix is ISO 8601 round-trip format + var logLines = outputWriter.Logs.Where(l => l.StartsWith("2025-", StringComparison.Ordinal)).ToList(); + Assert.Equal(3, logLines.Count); + Assert.Equal("2025-01-15T10:30:01.000Z [apiservice-abc123] Hello from replica 1", logLines[0]); + Assert.Equal("2025-01-15T10:30:02.000Z [apiservice-def456] Hello from replica 2", logLines[1]); + Assert.Equal("2025-01-15T10:30:00.000Z [redis] Ready to accept connections", logLines[2]); + } + + [Fact] + public async Task LogsCommand_TextOutput_WithoutTimestamps_NoTimestampPrefix() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var provider = CreateLogsTestServices(workspace, outputWriter, configureOptions: config => + { + config["NO_COLOR"] = "1"; + }); + + var command = provider.GetRequiredService(); + var result = command.Parse("logs"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + // Without --timestamps, log lines start with "[resourceName]" with no timestamp prefix + var logLines = outputWriter.Logs.Where(l => l.StartsWith("[", StringComparison.Ordinal)).ToList(); + Assert.Equal(3, logLines.Count); + Assert.Equal("[apiservice-abc123] Hello from replica 1", logLines[0]); + Assert.Equal("[apiservice-def456] Hello from replica 2", logLines[1]); + Assert.Equal("[redis] Ready to accept connections", logLines[2]); + } + + private ServiceProvider CreateLogsTestServices( + TemporaryWorkspace workspace, + TestOutputTextWriter outputWriter, + Action>? configureOptions = null) + { + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection = new TestAppHostAuxiliaryBackchannel + { + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"), + ProcessId = 1234 + }, + ResourceSnapshots = + [ + // Unique resource - DisplayName is unique across all resources + new ResourceSnapshot + { + Name = "redis", + DisplayName = "redis", + ResourceType = "Container", + State = "Running" + }, + // Replicas - two resources share the same DisplayName + new ResourceSnapshot + { + Name = "apiservice-abc123", + DisplayName = "apiservice", + ResourceType = "Project", + State = "Running" + }, + new ResourceSnapshot + { + Name = "apiservice-def456", + DisplayName = "apiservice", + ResourceType = "Project", + State = "Running" + } + ], + LogLines = + [ + new ResourceLogLine + { + ResourceName = "redis", + LineNumber = 1, + Content = "2025-01-15T10:30:00Z Ready to accept connections", + IsError = false + }, + new ResourceLogLine + { + ResourceName = "apiservice-abc123", + LineNumber = 1, + Content = "2025-01-15T10:30:01Z Hello from replica 1", + IsError = false + }, + new ResourceLogLine + { + ResourceName = "apiservice-def456", + LineNumber = 1, + Content = "2025-01-15T10:30:02Z Hello from replica 2", + IsError = false + } + ] + }; + monitor.AddConnection("hash1", "socket.hash1", connection); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + options.OutputTextWriter = outputWriter; + + if (configureOptions is not null) + { + options.ConfigurationCallback += configureOptions; + } + }); + + return services.BuildServiceProvider(); + } } diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 787c6363e4f..515e9b770e1 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -944,7 +944,7 @@ public Task> PromptForSelectionsAsync(string promptText, IEn public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => 0; public void DisplayError(string errorMessage) { } - public void DisplayMessage(string emoji, string message) { } + public void DisplayMessage(string emojiName, string message) { } public void DisplaySuccess(string message) { } public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { } public void DisplayCancellationMessage() { } diff --git a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs index 3fbcd729d64..0c2a636b9fe 100644 --- a/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PublishCommandPromptingIntegrationTests.cs @@ -940,7 +940,7 @@ public Task ConfirmAsync(string promptText, bool defaultValue = true, Canc public void ShowStatus(string statusText, Action action) => action(); public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => 0; public void DisplayError(string errorMessage) => DisplayedErrors.Add(errorMessage); - public void DisplayMessage(string emoji, string message) { } + public void DisplayMessage(string emojiName, string message) { } public void DisplaySuccess(string message) { } public void DisplaySubtleMessage(string message, bool escapeMarkup = true) { } public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { } diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index bc0b0d052f6..b0a1d1e1d41 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -966,7 +966,7 @@ public Task> PromptForSelectionsAsync(string promptText, IEn public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => _innerService.DisplayIncompatibleVersionError(ex, appHostHostingVersion); public void DisplayError(string errorMessage) => _innerService.DisplayError(errorMessage); - public void DisplayMessage(string emoji, string message) => _innerService.DisplayMessage(emoji, message); + public void DisplayMessage(string emojiName, string message) => _innerService.DisplayMessage(emojiName, message); public void DisplayPlainText(string text) => _innerService.DisplayPlainText(text); public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) => _innerService.DisplayRawText(text, consoleOverride); public void DisplayMarkdown(string markdown) => _innerService.DisplayMarkdown(markdown); diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index f82a23a06ce..5acafd00fe0 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -434,7 +434,7 @@ public void ShowStatus(string message, Action work) public void DisplaySuccess(string message) { } public void DisplayError(string message) { } - public void DisplayMessage(string emoji, string message) { } + public void DisplayMessage(string emojiName, string message) { } public void DisplayLines(IEnumerable<(string Stream, string Line)> lines) { } public void DisplayCancellationMessage() { } public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) => 0; diff --git a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs index fe77711c78f..82534aaa3bf 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestConsoleInteractionService.cs @@ -80,7 +80,7 @@ public void DisplayError(string errorMessage) DisplayErrorCallback?.Invoke(errorMessage); } - public void DisplayMessage(string emoji, string message) + public void DisplayMessage(string emojiName, string message) { } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs index 59c3bb860c6..9decc14937a 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionBackchannel.cs @@ -89,10 +89,10 @@ public Task ConnectAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - public Task DisplayMessageAsync(string emoji, string message, CancellationToken cancellationToken) + public Task DisplayMessageAsync(string emojiName, string message, CancellationToken cancellationToken) { DisplayMessageAsyncCalled?.SetResult(); - return DisplayMessageAsyncCallback?.Invoke(emoji, message) ?? Task.CompletedTask; + return DisplayMessageAsyncCallback?.Invoke(emojiName, message) ?? Task.CompletedTask; } public Task DisplaySuccessAsync(string message, CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs index 10ba19a3215..ca26796ea23 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestExtensionInteractionService.cs @@ -68,7 +68,7 @@ public void DisplayError(string errorMessage) DisplayErrorCallback?.Invoke(errorMessage); } - public void DisplayMessage(string emoji, string message) + public void DisplayMessage(string emojiName, string message) { } diff --git a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs index 63e608d6c6c..e87aae816e5 100644 --- a/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs +++ b/tests/Aspire.Dashboard.Tests/ConsoleLogsTests/LogEntriesTests.cs @@ -17,7 +17,7 @@ private static LogEntries CreateLogEntries(int? maximumEntryCount = null, int? b private static void AddLogLine(LogEntries logEntries, string content, bool isError) { - var logParser = new LogParser(ConsoleColor.Black); + var logParser = new LogParser(ConsoleColor.Black, encodeForHtml: true); var logEntry = logParser.CreateLogEntry(content, isError, resourcePrefix: null); logEntries.InsertSorted(logEntry); } @@ -29,7 +29,7 @@ public void Clear_AfterEarliestTimestampIndex_Success() var logEntries = CreateLogEntries(); // Act - var logParser = new LogParser(ConsoleColor.Black); + var logParser = new LogParser(ConsoleColor.Black, encodeForHtml: true); // Insert log with no timestamp. var logEntry1 = logParser.CreateLogEntry("Test", isErrorOutput: false, resourcePrefix: null); @@ -296,7 +296,7 @@ public void InsertSorted_TrimsToMaximumEntryCount_OutOfOrder() public void CreateLogEntry_AnsiAndUrl_HasUrlAnchor() { // Arrange - var parser = new LogParser(ConsoleColor.Black); + var parser = new LogParser(ConsoleColor.Black, encodeForHtml: true); // Act var entry = parser.CreateLogEntry("\x1b[36mhttps://www.example.com\u001b[0m", isErrorOutput: false, resourcePrefix: null); @@ -311,7 +311,7 @@ public void CreateLogEntry_AnsiAndUrl_HasUrlAnchor() public void CreateLogEntry_DefaultBackgroundColor_SkipMatchingColor(ConsoleColor defaultBackgroundColor, string output) { // Arrange - var parser = new LogParser(defaultBackgroundColor); + var parser = new LogParser(defaultBackgroundColor, encodeForHtml: true); // Act var entry = parser.CreateLogEntry("\u001b[40m\u001b[32minfo\u001b[39m\u001b[22m\u001b[49m: LoggerName", isErrorOutput: false, resourcePrefix: null); From 3adee5fa12d33079ac6c0781d46ed7cdb9f335f9 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 24 Feb 2026 16:27:24 +0800 Subject: [PATCH 159/256] Review feedback on dashboard skill (#14642) * Review feedback on dashboard skill * Apply suggestion from @JamesNK --- .github/skills/dashboard-testing/SKILL.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/skills/dashboard-testing/SKILL.md b/.github/skills/dashboard-testing/SKILL.md index 8603f283ba1..72e62e20658 100644 --- a/.github/skills/dashboard-testing/SKILL.md +++ b/.github/skills/dashboard-testing/SKILL.md @@ -128,7 +128,8 @@ public abstract class DashboardTestContext : TestContext ```csharp using Aspire.Dashboard.Components.Tests.Shared; -using Aspire.Dashboard.Tests.Shared; +using Aspire.Tests.Shared.DashboardModel; +using Aspire.Dashboard.Model; using Bunit; using Xunit; @@ -151,7 +152,7 @@ public class ResourceDetailsTests : DashboardTestContext var cut = RenderComponent(builder => { builder.Add(p => p.Resource, resource); - builder.Add(p => p.ShowSpecificProperties, true); + builder.Add(p => p.ShowSpecOnlyToggle, true); }); // Assert — query the rendered DOM @@ -185,7 +186,7 @@ public partial class ResourcesTests : DashboardTestContext var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); var initialResources = new List { - ModelTestHelpers.CreateResource("Resource1", "Type1", "Running"), + ModelTestHelpers.CreateResource(resourceName: "Resource1", resourceType: "Type1", state: KnownResourceState.Running), }; var channel = Channel.CreateUnbounded>(); var dashboardClient = new TestDashboardClient( @@ -284,7 +285,7 @@ internal static class MyFeatureSetupHelpers ## Shared Test Fakes -Both test projects share hand-rolled fakes from `tests/Shared/`. No mocking framework is used. +Both test projects use hand-rolled fakes — no mocking framework is used. Cross-project fakes live in `tests/Shared/` (e.g., `TestDashboardClient`, `ModelTestHelpers`), while bUnit-specific fakes live in `tests/Aspire.Dashboard.Components.Tests/Shared/` (e.g., `TestLocalStorage`, `TestTimeProvider`). | Fake | Purpose | |------|---------| @@ -326,9 +327,9 @@ var resource = ModelTestHelpers.CreateResource( ## Test Conventions -### DO: Use `[UseCulture("en-US")]` on Component Tests +### DO: Use `[UseCulture("en-US")]` for Culture-Sensitive Component Tests -All bUnit test classes should be decorated with `[UseCulture("en-US")]` for deterministic formatting: +Apply `[UseCulture("en-US")]` to bUnit test classes that assert culture-sensitive formatting (for example, numbers or dates) so those tests run deterministically across environments: ```csharp [UseCulture("en-US")] From cb90b6f381fab73fdab25e13241110f07721a22a Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 24 Feb 2026 20:04:45 +1100 Subject: [PATCH 160/256] Improve Azure deployment error output (#14576) * Improve Azure deployment error output Fixes #12303 - Add ProvisioningFailedException to throw clean error messages from AzureBicepResource instead of re-throwing raw RequestFailedException whose Message property includes verbose HTTP status, content, and headers. - Skip redundant error wrapping for DistributedApplicationException in ExecuteStepAsync, since these exceptions already have user-friendly messages that don't need 'Step ... failed: ' prefix prepended. - Update Verify snapshot for the now-cleaner error format. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add E2E deployment test for clean error output Add AcaDeploymentErrorOutputTests that deploys with an invalid Azure location ('invalidlocation') to deliberately induce a provisioning failure, then verifies the error output does not contain verbose HTTP headers, status codes, or raw Content blocks from RequestFailedException. Also add WaitForAnyPrompt helper to DeploymentE2ETestHelpers for tests that expect commands to fail (non-zero exit code). Relates to #12303 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix E2E test: unset Azure__Location from workflow env The deployment-tests.yml workflow sets Azure__Location=westus3 at the job level. On Linux, environment variables are case-sensitive, so AZURE__LOCATION=invalidlocation doesn't override Azure__Location. Unset the workflow variable and set both casings to ensure the invalid location is used. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix verbose error output in create-provisioning-context step E2E test revealed that RequestFailedException with verbose HTTP headers also leaks through BaseProvisioningContextProvider.CreateProvisioningContextAsync when resource group creation fails (e.g., invalid location). Wrap the CreateOrUpdateAsync call in a try/catch for RequestFailedException and throw ProvisioningFailedException with a clean extracted error message. Make ExtractDetailedErrorMessage internal so it can be reused from BaseProvisioningContextProvider. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR feedback: improve error format, use exact assertions, trim constructors - Change ExtractDetailedErrorMessage format from 'code: message' to 'Error code = code, Message = message' to avoid confusing double-colon when combined with context like 'Failed to create resource group: ...' - Remove unused constructors from ProvisioningFailedException (only message+inner is used) - Replace fuzzy DoesNotContain/Contains assertions with Verify snapshot testing for exact error message validation 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> --- .../AzureBicepResource.cs | 10 +- src/Aspire.Hosting.Azure/Exceptions.cs | 5 + .../BaseProvisioningContextProvider.cs | 13 +- .../DistributedApplicationPipeline.cs | 7 + .../AcaDeploymentErrorOutputTests.cs | 243 ++++++++++++++++++ .../Helpers/DeploymentE2ETestHelpers.cs | 29 +++ .../AzureDeployerTests.cs | 86 +++++++ ...sourcesAndNoEnvironment_Fails.verified.txt | 2 +- ...sNotIncludeVerboseHttpDetails.verified.txt | 7 + 9 files changed, 394 insertions(+), 8 deletions(-) create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AcaDeploymentErrorOutputTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRequestFailedException_DoesNotIncludeVerboseHttpDetails.verified.txt diff --git a/src/Aspire.Hosting.Azure/AzureBicepResource.cs b/src/Aspire.Hosting.Azure/AzureBicepResource.cs index 29a1404f75f..4f835cb6c42 100644 --- a/src/Aspire.Hosting.Azure/AzureBicepResource.cs +++ b/src/Aspire.Hosting.Azure/AzureBicepResource.cs @@ -359,7 +359,7 @@ await resourceTask.CompleteAsync( $"Failed to provision **{resource.Name}**: {errorMessage}", CompletionState.CompletedWithError, context.CancellationToken).ConfigureAwait(false); - throw; + throw new ProvisioningFailedException(errorMessage, ex); } } } @@ -373,7 +373,7 @@ await resourceTask.CompleteAsync( /// /// The Azure RequestFailedException containing the error response /// The most specific error message found, or the original exception message if parsing fails - private static string ExtractDetailedErrorMessage(RequestFailedException requestEx) + internal static string ExtractDetailedErrorMessage(RequestFailedException requestEx) { try { @@ -401,7 +401,7 @@ private static string ExtractDetailedErrorMessage(RequestFailedException request } } - return $"{code}: {message}"; + return $"Error code = {code}, Message = {message}"; } } @@ -412,7 +412,7 @@ private static string ExtractDetailedErrorMessage(RequestFailedException request if (!string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(message)) { - return $"{code}: {message}"; + return $"Error code = {code}, Message = {message}"; } } } @@ -444,7 +444,7 @@ private static string ExtractDeepestErrorMessage(JsonArray detailsArray) if (!string.IsNullOrEmpty(detailCode) && !string.IsNullOrEmpty(detailMessage)) { - return $"{detailCode}: {detailMessage}"; + return $"Error code = {detailCode}, Message = {detailMessage}"; } } } diff --git a/src/Aspire.Hosting.Azure/Exceptions.cs b/src/Aspire.Hosting.Azure/Exceptions.cs index d95a92a63f7..65103f682fa 100644 --- a/src/Aspire.Hosting.Azure/Exceptions.cs +++ b/src/Aspire.Hosting.Azure/Exceptions.cs @@ -24,3 +24,8 @@ public FailedToApplyEnvironmentException(string message) : base(message) { } public FailedToApplyEnvironmentException(string message, Exception inner) : base(message, inner) { } } +internal sealed class ProvisioningFailedException : DistributedApplicationException +{ + public ProvisioningFailedException(string message, Exception inner) : base(message, inner) { } +} + diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs index c616106055e..d7be67becf2 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs @@ -152,8 +152,17 @@ public virtual async Task CreateProvisioningContextAsync(Ca var rgData = new ResourceGroupData(location); rgData.Tags.Add("aspire", "true"); - var operation = await resourceGroups.CreateOrUpdateAsync(WaitUntil.Completed, resourceGroupName, rgData, cancellationToken).ConfigureAwait(false); - resourceGroup = operation.Value; + + try + { + var operation = await resourceGroups.CreateOrUpdateAsync(WaitUntil.Completed, resourceGroupName, rgData, cancellationToken).ConfigureAwait(false); + resourceGroup = operation.Value; + } + catch (RequestFailedException createEx) + { + var errorMessage = AzureBicepResource.ExtractDetailedErrorMessage(createEx); + throw new ProvisioningFailedException($"Failed to create resource group '{resourceGroupName}': {errorMessage}", createEx); + } _logger.LogInformation("Resource group {rgName} created.", resourceGroup.Name); } diff --git a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs index f990b0bf825..a7d951b508a 100644 --- a/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs +++ b/src/Aspire.Hosting/Pipelines/DistributedApplicationPipeline.cs @@ -841,6 +841,13 @@ private static async Task ExecuteStepAsync(PipelineStep step, PipelineStepContex { await step.Action(stepContext).ConfigureAwait(false); } + catch (DistributedApplicationException) + { + // DistributedApplicationException subtypes already have clean, user-friendly messages. + // Re-throw without wrapping to avoid verbose error output (e.g. raw HTTP headers + // from Azure SDK RequestFailedException leaking into step failure messages). + throw; + } catch (Exception ex) { var exceptionInfo = ExceptionDispatchInfo.Capture(ex); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaDeploymentErrorOutputTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaDeploymentErrorOutputTests.cs new file mode 100644 index 00000000000..a275c95badf --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaDeploymentErrorOutputTests.cs @@ -0,0 +1,243 @@ +// 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 Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests that verify Azure deployment error output is clean and user-friendly. +/// Uses an invalid Azure location to deliberately induce a deployment failure, then asserts +/// that the error output does not contain verbose HTTP details from RequestFailedException. +/// +/// +/// See https://github.com/dotnet/aspire/issues/12303 +/// +public sealed class AcaDeploymentErrorOutputTests(ITestOutputHelper output) +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(40); + + /// + /// Deploys with an invalid Azure location ('invalidlocation') to induce a provisioning failure, + /// then verifies the error output is clean without verbose HTTP headers or status details. + /// + [Fact] + public async Task DeployWithInvalidLocation_ErrorOutputIsClean() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + + await DeployWithInvalidLocation_ErrorOutputIsCleanCore(linkedCts.Token); + } + + private async Task DeployWithInvalidLocation_ErrorOutputIsCleanCore(CancellationToken cancellationToken) + { + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + var workspace = TemporaryWorkspace.Create(output); + var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployWithInvalidLocation_ErrorOutputIsClean)); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("errout"); + var deployOutputFile = Path.Combine(workspace.WorkspaceRoot.FullName, "deploy-output.txt"); + + output.WriteLine($"Test: {nameof(DeployWithInvalidLocation_ErrorOutputIsClean)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + try + { + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(160, 48) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + using var terminal = builder.Build(); + var pendingRun = terminal.RunAsync(cancellationToken); + + var waitingForInitComplete = new CellPatternSearcher() + .Find("Aspire initialization complete"); + + var waitingForVersionSelectionPrompt = new CellPatternSearcher() + .Find("(based on NuGet.config)"); + + var waitingForPipelineFailed = new CellPatternSearcher() + .Find("PIPELINE FAILED"); + + var counter = new SequenceCounter(); + var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); + + // Step 1: Prepare environment + output.WriteLine("Step 1: Preparing environment..."); + sequenceBuilder.PrepareEnvironment(workspace, counter); + + // Step 2: Set up CLI + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + output.WriteLine("Step 2: Using pre-installed Aspire CLI..."); + sequenceBuilder.SourceAspireCliEnvironment(counter); + } + + // Step 3: Create single-file AppHost + output.WriteLine("Step 3: Creating single-file AppHost..."); + sequenceBuilder.Type("aspire init") + .Enter() + .Wait(TimeSpan.FromSeconds(5)) + .Enter() + .WaitUntil(s => waitingForInitComplete.Search(s).Count > 0, TimeSpan.FromMinutes(2)) + .WaitForSuccessPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 4: Add Azure Container Apps package + output.WriteLine("Step 4: Adding Azure Container Apps package..."); + sequenceBuilder.Type("aspire add Aspire.Hosting.Azure.AppContainers") + .Enter(); + + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + sequenceBuilder + .WaitUntil(s => waitingForVersionSelectionPrompt.Search(s).Count > 0, TimeSpan.FromSeconds(60)) + .Enter(); + } + + sequenceBuilder.WaitForSuccessPrompt(counter, TimeSpan.FromSeconds(180)); + + // Step 5: Modify apphost.cs to add Azure Container App Environment + sequenceBuilder.ExecuteCallback(() => + { + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + var content = File.ReadAllText(appHostFilePath); + + var buildRunPattern = "builder.Build().Run();"; + var replacement = """ +builder.AddAzureContainerAppEnvironment("infra"); + +builder.Build().Run(); +"""; + + content = content.Replace(buildRunPattern, replacement); + File.WriteAllText(appHostFilePath, content); + + output.WriteLine($"Modified apphost.cs with Azure Container App Environment"); + }); + + // Step 6: Set environment variables with an INVALID Azure location to induce failure. + // 'invalidlocation' is not a real Azure region, so provisioning will fail with + // LocationNotAvailableForResourceType or similar error. + // Note: Unset both Azure__Location and AZURE__LOCATION because the CI workflow + // sets Azure__Location=westus3 at the job level, and on Linux env vars are + // case-sensitive. Then set the invalid location with both casings to be safe. + output.WriteLine("Step 6: Setting invalid Azure location to induce failure..."); + sequenceBuilder.Type($"unset ASPIRE_PLAYGROUND && unset Azure__Location && export AZURE__LOCATION=invalidlocation && export Azure__Location=invalidlocation && export AZURE__RESOURCEGROUP={resourceGroupName}") + .Enter() + .WaitForSuccessPrompt(counter); + + // Step 7: Deploy (expecting failure) and capture output to a file + output.WriteLine("Step 7: Starting deployment with invalid location (expecting failure)..."); + sequenceBuilder + .Type($"aspire deploy --clear-cache 2>&1 | tee {deployOutputFile}") + .Enter() + .WaitUntil(s => waitingForPipelineFailed.Search(s).Count > 0, TimeSpan.FromMinutes(30)) + .WaitForAnyPrompt(counter, TimeSpan.FromMinutes(2)); + + // Step 8: Exit terminal + sequenceBuilder.Type("exit").Enter(); + + var sequence = sequenceBuilder.Build(); + await sequence.ApplyAsync(terminal, cancellationToken); + await pendingRun; + + // Step 9: Read captured output and verify error messages are clean + output.WriteLine("Step 9: Verifying error output is clean..."); + Assert.True(File.Exists(deployOutputFile), $"Deploy output file not found at {deployOutputFile}"); + + var deployOutput = File.ReadAllText(deployOutputFile); + output.WriteLine($"Captured {deployOutput.Length} characters of deploy output"); + output.WriteLine("--- Deploy output (last 2000 chars) ---"); + output.WriteLine(deployOutput.Length > 2000 ? deployOutput[^2000..] : deployOutput); + output.WriteLine("--- End deploy output ---"); + + // Verify the output does NOT contain verbose HTTP details from RequestFailedException + Assert.DoesNotContain("Headers:", deployOutput); + Assert.DoesNotContain("Cache-Control:", deployOutput); + Assert.DoesNotContain("x-ms-failure-cause:", deployOutput); + Assert.DoesNotContain("x-ms-request-id:", deployOutput); + Assert.DoesNotContain("Content-Type: application/json", deployOutput); + Assert.DoesNotContain("Status: 400", deployOutput); + Assert.DoesNotContain("Status: 404", deployOutput); + + // Verify the pipeline DID fail (sanity check) + Assert.Contains("PIPELINE FAILED", deployOutput); + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"✅ Test completed in {duration} - error output is clean"); + } + catch (Exception ex) + { + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"❌ Test failed after {duration}: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(DeployWithInvalidLocation_ErrorOutputIsClean), + resourceGroupName, + ex.Message, + ex.StackTrace); + + throw; + } + finally + { + // Cleanup: resource group may not have been created if provisioning failed early, + // but attempt cleanup just in case. + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + TriggerCleanupResourceGroup(resourceGroupName); + } + } + + private void TriggerCleanupResourceGroup(string resourceGroupName) + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + output.WriteLine($"Cleanup triggered for resource group: {resourceGroupName}"); + } + catch (Exception ex) + { + output.WriteLine($"Failed to trigger cleanup: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs index 905b001719b..6b2f00c359b 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs @@ -194,6 +194,35 @@ internal static Hex1bTerminalInputSequenceBuilder WaitForSuccessPrompt( .IncrementSequence(counter); } + /// + /// Waits for any command prompt (success or error) with the expected sequence number. + /// Use this after commands that are expected to fail (non-zero exit code). + /// + internal static Hex1bTerminalInputSequenceBuilder WaitForAnyPrompt( + this Hex1bTerminalInputSequenceBuilder builder, + SequenceCounter counter, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); + + return builder.WaitUntil(snapshot => + { + var expectedCount = counter.Value.ToString(); + + var successSearcher = new CellPatternSearcher() + .FindPattern(expectedCount) + .RightText(" OK] $ "); + + var errorSearcher = new CellPatternSearcher() + .FindPattern(expectedCount) + .RightText(" ERR:"); + + return successSearcher.Search(snapshot).Count > 0 + || errorSearcher.Search(snapshot).Count > 0; + }, effectiveTimeout) + .IncrementSequence(counter); + } + /// /// Increments the sequence counter. /// diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index 09460f25c8d..ca1d9dd4c8c 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -1199,6 +1199,44 @@ public async Task DeployAsync_WithAzureResourcesAndNoEnvironment_Fails() await Verify(logs); } + [Fact] + public async Task DeployAsync_WithRequestFailedException_DoesNotIncludeVerboseHttpDetails() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, step: WellKnownPipelineSteps.Deploy); + var mockActivityReporter = new TestPipelineActivityReporter(testOutputHelper); + + ConfigureTestServices(builder, bicepProvisioner: new FailingBicepProvisioner(), activityReporter: mockActivityReporter); + + builder.AddAzureEnvironment(); + builder.AddAzureSqlServer("sql").AddDatabase("db"); + + using var app = builder.Build(); + await app.RunAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + // Collect all error-level messages from the pipeline activity reporter + var errorLogs = mockActivityReporter.LoggedMessages + .Where(m => m.LogLevel >= LogLevel.Error) + .Select(s => s.Message) + .ToList(); + + // Collect all completed step/task messages + var completedStepMessages = mockActivityReporter.CompletedSteps + .Where(s => s.CompletionState == CompletionState.CompletedWithError) + .Select(s => s.CompletionText) + .ToList(); + + var completedTaskMessages = mockActivityReporter.CompletedTasks + .Where(t => t.CompletionState == CompletionState.CompletedWithError) + .Select(t => t.CompletionMessage ?? t.TaskStatusText) + .ToList(); + + var allMessages = errorLogs.Concat(completedStepMessages).Concat(completedTaskMessages).ToList(); + + Assert.NotEmpty(allMessages); + + await Verify(allMessages); + } + private void ConfigureTestServices(IDistributedApplicationTestingBuilder builder, IInteractionService? interactionService = null, IBicepProvisioner? bicepProvisioner = null, @@ -1266,6 +1304,54 @@ public Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningCo } } + private sealed class FailingBicepProvisioner : IBicepProvisioner + { + public Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken) + { + return Task.FromResult(false); + } + + public Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken) + { + // Build a mock Azure Response with JSON error body, simulating what the ARM + // deployment API returns when a deployment fails. This ensures the + // ExtractDetailedErrorMessage method can parse the JSON and produce a clean + // error message rather than falling back to the verbose Message property. + var jsonContent = """{"error":{"code":"LocationNotAvailableForResourceType","message":"The provided location 'asia' is not available for resource type 'Microsoft.ManagedIdentity/userAssignedIdentities'."}}"""; + var response = new TestAzureResponse(400, "Bad Request", jsonContent); + + throw new global::Azure.RequestFailedException(response); + } + } + + /// + /// Minimal Azure Response subclass for testing. Provides JSON content that + /// ExtractDetailedErrorMessage can parse, simulating a real Azure SDK error. + /// + private sealed class TestAzureResponse : global::Azure.Response + { + private readonly int _status; + private readonly string _reasonPhrase; + + public TestAzureResponse(int status, string reasonPhrase, string content) + { + _status = status; + _reasonPhrase = reasonPhrase; + ContentStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content)); + } + + public override int Status => _status; + public override string ReasonPhrase => _reasonPhrase; + public override Stream? ContentStream { get; set; } + public override string ClientRequestId { get; set; } = string.Empty; + + public override void Dispose() { } + protected override bool TryGetHeader(string name, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value) { value = null; return false; } + protected override bool TryGetHeaderValues(string name, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out IEnumerable? values) { values = null; return false; } + protected override bool ContainsHeader(string name) => false; + protected override IEnumerable EnumerateHeaders() => []; + } + private sealed class Project : IProjectMetadata { public string ProjectPath => "project"; diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourcesAndNoEnvironment_Fails.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourcesAndNoEnvironment_Fails.verified.txt index 3faf05c1adc..0c692d28316 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourcesAndNoEnvironment_Fails.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourcesAndNoEnvironment_Fails.verified.txt @@ -1,4 +1,4 @@ [ Step 'provision-sql-roles' failed., - Step 'provision-sql-roles' failed: An Azure principal parameter was not supplied a value. Ensure you are using an environment that supports role assignments, for example AddAzureContainerAppEnvironment. + Deployment failed: An Azure principal parameter was not supplied a value. Ensure you are using an environment that supports role assignments, for example AddAzureContainerAppEnvironment. ] \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRequestFailedException_DoesNotIncludeVerboseHttpDetails.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRequestFailedException_DoesNotIncludeVerboseHttpDetails.verified.txt new file mode 100644 index 00000000000..03a203f2101 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRequestFailedException_DoesNotIncludeVerboseHttpDetails.verified.txt @@ -0,0 +1,7 @@ +[ + Step 'provision-sql' failed., + Deployment failed: Error code = LocationNotAvailableForResourceType, Message = The provided location 'asia' is not available for resource type 'Microsoft.ManagedIdentity/userAssignedIdentities'., + Deployment failed: Error code = LocationNotAvailableForResourceType, Message = The provided location 'asia' is not available for resource type 'Microsoft.ManagedIdentity/userAssignedIdentities'., + Failed, + Failed to provision **sql**: Deployment failed: Error code = LocationNotAvailableForResourceType, Message = The provided location 'asia' is not available for resource type 'Microsoft.ManagedIdentity/userAssignedIdentities'. +] \ No newline at end of file From 3d9f4e7b4dda4e0bd01317a2017801a5edf12b58 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 24 Feb 2026 02:52:03 -0800 Subject: [PATCH 161/256] aspire-managed unified binary + native certificate management (#14441) * Squash branch changes for release/13.2 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove CLI size check from clipack Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-bundle.yml | 70 - .../workflows/build-cli-native-archives.yml | 41 + .github/workflows/polyglot-validation.yml | 117 +- .../polyglot-validation/setup-local-cli.sh | 7 - .github/workflows/tests.yml | 13 +- Aspire.slnx | 1 - docs/specs/bundle.md | 321 ++-- eng/Bundle.proj | 56 +- eng/Versions.props | 2 - eng/build.ps1 | 5 - eng/build.sh | 5 - eng/clipack/Common.projitems | 27 +- eng/pipelines/templates/build_sign_native.yml | 16 + eng/scripts/README.md | 119 +- eng/scripts/get-aspire-cli-bundle-pr.ps1 | 625 ------- eng/scripts/get-aspire-cli-bundle-pr.sh | 785 -------- eng/scripts/install-aspire-bundle.ps1 | 414 ----- eng/scripts/install-aspire-bundle.sh | 609 ------- localhive.ps1 | 106 +- localhive.sh | 116 +- .../Aspire.Cli.NuGetHelper.csproj | 24 - src/Aspire.Cli.NuGetHelper/Program.cs | 31 - src/Aspire.Cli/Bundles/BundleService.cs | 7 +- .../BundleCertificateToolRunner.cs | 195 -- .../CertificateExportFormat.cs | 10 + .../CertificateManager.cs | 1596 +++++++++++++++++ .../CertificatePurpose.cs | 10 + .../EnsureCertificateResult.cs | 21 + .../ImportCertificateResult.cs | 15 + .../MacOSCertificateManager.cs | 497 +++++ .../CertificateGeneration/README.md | 20 + .../UnixCertificateManager.cs | 1088 +++++++++++ .../WindowsCertificateManager.cs | 166 ++ .../NativeCertificateToolRunner.cs | 103 ++ .../Certificates/SdkCertificateToolRunner.cs | 155 -- src/Aspire.Cli/Layout/LayoutConfiguration.cs | 108 +- src/Aspire.Cli/Layout/LayoutDiscovery.cs | 74 +- src/Aspire.Cli/Layout/LayoutProcessRunner.cs | 45 +- .../NuGet/BundleNuGetPackageCache.cs | 18 +- src/Aspire.Cli/NuGet/BundleNuGetService.cs | 22 +- src/Aspire.Cli/Program.cs | 20 +- .../Projects/AppHostServerProject.cs | 2 +- .../Projects/PrebuiltAppHostServer.cs | 42 +- .../Dashboard/DashboardEventHandlers.cs | 81 +- src/Aspire.Managed/Aspire.Managed.csproj | 49 + .../NuGet}/Commands/LayoutCommand.cs | 2 +- .../NuGet}/Commands/RestoreCommand.cs | 2 +- .../NuGet}/Commands/SearchCommand.cs | 2 +- .../NuGet}/NuGetLogger.cs | 2 +- src/Aspire.Managed/Program.cs | 47 + src/Shared/BundleDiscovery.cs | 214 +-- .../Helpers/CliE2ETestHelpers.cs | 10 +- .../Dashboard/DashboardLifecycleHookTests.cs | 20 +- .../Dashboard/DashboardResourceTests.cs | 3 +- tools/CreateLayout/Program.cs | 508 +----- tools/CreateLayout/README.md | 4 +- 56 files changed, 4265 insertions(+), 4403 deletions(-) delete mode 100644 .github/workflows/build-bundle.yml delete mode 100644 eng/scripts/get-aspire-cli-bundle-pr.ps1 delete mode 100644 eng/scripts/get-aspire-cli-bundle-pr.sh delete mode 100644 eng/scripts/install-aspire-bundle.ps1 delete mode 100644 eng/scripts/install-aspire-bundle.sh delete mode 100644 src/Aspire.Cli.NuGetHelper/Aspire.Cli.NuGetHelper.csproj delete mode 100644 src/Aspire.Cli.NuGetHelper/Program.cs delete mode 100644 src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs create mode 100644 src/Aspire.Cli/Certificates/CertificateGeneration/CertificateExportFormat.cs create mode 100644 src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs create mode 100644 src/Aspire.Cli/Certificates/CertificateGeneration/CertificatePurpose.cs create mode 100644 src/Aspire.Cli/Certificates/CertificateGeneration/EnsureCertificateResult.cs create mode 100644 src/Aspire.Cli/Certificates/CertificateGeneration/ImportCertificateResult.cs create mode 100644 src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs create mode 100644 src/Aspire.Cli/Certificates/CertificateGeneration/README.md create mode 100644 src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs create mode 100644 src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs create mode 100644 src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs delete mode 100644 src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs create mode 100644 src/Aspire.Managed/Aspire.Managed.csproj rename src/{Aspire.Cli.NuGetHelper => Aspire.Managed/NuGet}/Commands/LayoutCommand.cs (99%) rename src/{Aspire.Cli.NuGetHelper => Aspire.Managed/NuGet}/Commands/RestoreCommand.cs (99%) rename src/{Aspire.Cli.NuGetHelper => Aspire.Managed/NuGet}/Commands/SearchCommand.cs (99%) rename src/{Aspire.Cli.NuGetHelper => Aspire.Managed/NuGet}/NuGetLogger.cs (98%) create mode 100644 src/Aspire.Managed/Program.cs diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml deleted file mode 100644 index eb1e98a5c4f..00000000000 --- a/.github/workflows/build-bundle.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Build Bundle (Reusable) - -# This workflow creates the Aspire CLI bundle by: -# 1. Building/publishing managed components (Dashboard, NuGetHelper, AppHostServer) -# 2. Creating the tar.gz archive from the layout -# 3. AOT-compiling the CLI with the archive as an embedded resource - -on: - workflow_call: - inputs: - versionOverrideArg: - required: false - type: string - -jobs: - build_bundle: - name: Build bundle (${{ matrix.targets.rid }}) - runs-on: ${{ matrix.targets.runner }} - strategy: - matrix: - targets: - - rid: linux-x64 - runner: 8-core-ubuntu-latest # Larger runner for bundle disk space - cli_exe: aspire - - rid: win-x64 - runner: windows-latest - cli_exe: aspire.exe - - rid: osx-arm64 - runner: macos-latest - cli_exe: aspire - - steps: - - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - # Download RID-specific NuGet packages (for DCP) - - name: Download RID-specific NuGets - uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 - with: - name: built-nugets-for-${{ matrix.targets.rid }} - path: artifacts/packages/Release/Shipping - - # Build bundle: managed projects → tar.gz → AOT compile CLI with embedded payload - - name: Build bundle - shell: pwsh - run: | - $dotnetCmd = if ($IsWindows) { "./dotnet.cmd" } else { "./dotnet.sh" } - & $dotnetCmd msbuild eng/Bundle.proj ` - /restore ` - /p:Configuration=Release ` - /p:TargetRid=${{ matrix.targets.rid }} ` - /p:ContinuousIntegrationBuild=true ` - /bl:${{ github.workspace }}/artifacts/log/Release/Bundle.binlog ` - ${{ inputs.versionOverrideArg }} - - - name: Upload bundle - if: success() - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: aspire-bundle-${{ matrix.targets.rid }} - path: artifacts/bin/Aspire.Cli/Release/net10.0/${{ matrix.targets.rid }}/native/${{ matrix.targets.cli_exe }} - retention-days: 15 - if-no-files-found: error - - - name: Upload logs - if: always() - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 - with: - name: bundle-logs-${{ matrix.targets.rid }} - path: artifacts/log/** diff --git a/.github/workflows/build-cli-native-archives.yml b/.github/workflows/build-cli-native-archives.yml index 06b7b8c09e7..746f7138f86 100644 --- a/.github/workflows/build-cli-native-archives.yml +++ b/.github/workflows/build-cli-native-archives.yml @@ -33,6 +33,45 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + # Download RID-specific NuGet packages (for DCP) used by Bundle.proj + - name: Download RID-specific NuGets + uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 + with: + name: built-nugets-for-${{ matrix.targets.rids }} + path: artifacts/packages/Release/Shipping + + - name: Build bundle payload archive (Windows) + if: ${{ matrix.targets.os == 'windows-latest' }} + shell: pwsh + run: > + .\dotnet.cmd + msbuild + eng/Bundle.proj + /restore + /p:Configuration=${{ inputs.configuration }} + /p:TargetRid=${{ matrix.targets.rids }} + /p:BundleVersion=ci-bundlepayload + /p:SkipNativeBuild=true + /p:ContinuousIntegrationBuild=true + /bl:${{ github.workspace }}/artifacts/log/${{ inputs.configuration }}/BundlePayload.binlog + ${{ inputs.versionOverrideArg }} + + - name: Build bundle payload archive (Unix) + if: ${{ matrix.targets.os != 'windows-latest' }} + shell: bash + run: > + ./dotnet.sh + msbuild + eng/Bundle.proj + /restore + /p:Configuration=${{ inputs.configuration }} + /p:TargetRid=${{ matrix.targets.rids }} + /p:BundleVersion=ci-bundlepayload + /p:SkipNativeBuild=true + /p:ContinuousIntegrationBuild=true + /bl:${{ github.workspace }}/artifacts/log/${{ inputs.configuration }}/BundlePayload.binlog + ${{ inputs.versionOverrideArg }} + - name: Build CLI packages (Windows) if: ${{ matrix.targets.os == 'windows-latest' }} shell: pwsh @@ -46,6 +85,7 @@ jobs: /p:ContinuousIntegrationBuild=true /p:SkipManagedBuild=true /p:TargetRids=${{ matrix.targets.rids }} + /p:BundlePayloadPath=${{ github.workspace }}/artifacts/bundle/aspire-ci-bundlepayload-${{ matrix.targets.rids }}.tar.gz ${{ inputs.versionOverrideArg }} - name: Build CLI packages (Unix) @@ -61,6 +101,7 @@ jobs: /p:ContinuousIntegrationBuild=true /p:SkipManagedBuild=true /p:TargetRids=${{ matrix.targets.rids }} + /p:BundlePayloadPath=${{ github.workspace }}/artifacts/bundle/aspire-ci-bundlepayload-${{ matrix.targets.rids }}.tar.gz ${{ inputs.versionOverrideArg }} - name: Upload CLI archives diff --git a/.github/workflows/polyglot-validation.yml b/.github/workflows/polyglot-validation.yml index 2f103e82a6d..40c06de6e0a 100644 --- a/.github/workflows/polyglot-validation.yml +++ b/.github/workflows/polyglot-validation.yml @@ -19,11 +19,11 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Download bundle + - name: Download CLI archive uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 with: - name: aspire-bundle-linux-x64 - path: ${{ github.workspace }}/artifacts/bundle + name: cli-native-archives-linux-x64 + path: ${{ github.workspace }}/artifacts/cli-archive - name: Download NuGet packages uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 @@ -37,11 +37,20 @@ jobs: name: built-nugets-for-linux-x64 path: ${{ github.workspace }}/artifacts/nugets-rid - - name: Verify bundle artifact + - name: Extract CLI artifact run: | - echo "=== Verifying self-extracting binary ===" - ls -la ${{ github.workspace }}/artifacts/bundle/aspire || { echo "ERROR: aspire binary not found"; exit 1; } - chmod +x ${{ github.workspace }}/artifacts/bundle/aspire + set -euo pipefail + + echo "=== Extracting bundle-backed CLI from archive ===" + archive=$(find "${{ github.workspace }}/artifacts/cli-archive" -type f -name 'aspire-cli-linux-x64*.tar.gz' | head -n 1) + if [ -z "$archive" ]; then + echo "ERROR: CLI archive not found in ${{ github.workspace }}/artifacts/cli-archive" + find "${{ github.workspace }}/artifacts/cli-archive" -maxdepth 5 -type f || true + exit 1 + fi + + mkdir -p "${{ github.workspace }}/artifacts/bundle" + tar -xzf "$archive" -C "${{ github.workspace }}/artifacts/bundle" - name: Build Python validation image run: | @@ -64,11 +73,11 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Download bundle + - name: Download CLI archive uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 with: - name: aspire-bundle-linux-x64 - path: ${{ github.workspace }}/artifacts/bundle + name: cli-native-archives-linux-x64 + path: ${{ github.workspace }}/artifacts/cli-archive - name: Download NuGet packages uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 @@ -82,11 +91,20 @@ jobs: name: built-nugets-for-linux-x64 path: ${{ github.workspace }}/artifacts/nugets-rid - - name: Verify bundle artifact + - name: Extract CLI artifact run: | - echo "=== Verifying self-extracting binary ===" - ls -la ${{ github.workspace }}/artifacts/bundle/aspire || { echo "ERROR: aspire binary not found"; exit 1; } - chmod +x ${{ github.workspace }}/artifacts/bundle/aspire + set -euo pipefail + + echo "=== Extracting bundle-backed CLI from archive ===" + archive=$(find "${{ github.workspace }}/artifacts/cli-archive" -type f -name 'aspire-cli-linux-x64*.tar.gz' | head -n 1) + if [ -z "$archive" ]; then + echo "ERROR: CLI archive not found in ${{ github.workspace }}/artifacts/cli-archive" + find "${{ github.workspace }}/artifacts/cli-archive" -maxdepth 5 -type f || true + exit 1 + fi + + mkdir -p "${{ github.workspace }}/artifacts/bundle" + tar -xzf "$archive" -C "${{ github.workspace }}/artifacts/bundle" - name: Build Go validation image run: | @@ -109,11 +127,11 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Download bundle + - name: Download CLI archive uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 with: - name: aspire-bundle-linux-x64 - path: ${{ github.workspace }}/artifacts/bundle + name: cli-native-archives-linux-x64 + path: ${{ github.workspace }}/artifacts/cli-archive - name: Download NuGet packages uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 @@ -127,11 +145,20 @@ jobs: name: built-nugets-for-linux-x64 path: ${{ github.workspace }}/artifacts/nugets-rid - - name: Verify bundle artifact + - name: Extract CLI artifact run: | - echo "=== Verifying self-extracting binary ===" - ls -la ${{ github.workspace }}/artifacts/bundle/aspire || { echo "ERROR: aspire binary not found"; exit 1; } - chmod +x ${{ github.workspace }}/artifacts/bundle/aspire + set -euo pipefail + + echo "=== Extracting bundle-backed CLI from archive ===" + archive=$(find "${{ github.workspace }}/artifacts/cli-archive" -type f -name 'aspire-cli-linux-x64*.tar.gz' | head -n 1) + if [ -z "$archive" ]; then + echo "ERROR: CLI archive not found in ${{ github.workspace }}/artifacts/cli-archive" + find "${{ github.workspace }}/artifacts/cli-archive" -maxdepth 5 -type f || true + exit 1 + fi + + mkdir -p "${{ github.workspace }}/artifacts/bundle" + tar -xzf "$archive" -C "${{ github.workspace }}/artifacts/bundle" - name: Build Java validation image run: | @@ -156,11 +183,11 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Download bundle + - name: Download CLI archive uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 with: - name: aspire-bundle-linux-x64 - path: ${{ github.workspace }}/artifacts/bundle + name: cli-native-archives-linux-x64 + path: ${{ github.workspace }}/artifacts/cli-archive - name: Download NuGet packages uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 @@ -174,11 +201,20 @@ jobs: name: built-nugets-for-linux-x64 path: ${{ github.workspace }}/artifacts/nugets-rid - - name: Verify bundle artifact + - name: Extract CLI artifact run: | - echo "=== Verifying self-extracting binary ===" - ls -la ${{ github.workspace }}/artifacts/bundle/aspire || { echo "ERROR: aspire binary not found"; exit 1; } - chmod +x ${{ github.workspace }}/artifacts/bundle/aspire + set -euo pipefail + + echo "=== Extracting bundle-backed CLI from archive ===" + archive=$(find "${{ github.workspace }}/artifacts/cli-archive" -type f -name 'aspire-cli-linux-x64*.tar.gz' | head -n 1) + if [ -z "$archive" ]; then + echo "ERROR: CLI archive not found in ${{ github.workspace }}/artifacts/cli-archive" + find "${{ github.workspace }}/artifacts/cli-archive" -maxdepth 5 -type f || true + exit 1 + fi + + mkdir -p "${{ github.workspace }}/artifacts/bundle" + tar -xzf "$archive" -C "${{ github.workspace }}/artifacts/bundle" - name: Build Rust validation image run: | @@ -201,11 +237,11 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Download bundle + - name: Download CLI archive uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 with: - name: aspire-bundle-linux-x64 - path: ${{ github.workspace }}/artifacts/bundle + name: cli-native-archives-linux-x64 + path: ${{ github.workspace }}/artifacts/cli-archive - name: Download NuGet packages uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9 @@ -219,11 +255,20 @@ jobs: name: built-nugets-for-linux-x64 path: ${{ github.workspace }}/artifacts/nugets-rid - - name: Verify bundle artifact + - name: Extract CLI artifact run: | - echo "=== Verifying self-extracting binary ===" - ls -la ${{ github.workspace }}/artifacts/bundle/aspire || { echo "ERROR: aspire binary not found"; exit 1; } - chmod +x ${{ github.workspace }}/artifacts/bundle/aspire + set -euo pipefail + + echo "=== Extracting bundle-backed CLI from archive ===" + archive=$(find "${{ github.workspace }}/artifacts/cli-archive" -type f -name 'aspire-cli-linux-x64*.tar.gz' | head -n 1) + if [ -z "$archive" ]; then + echo "ERROR: CLI archive not found in ${{ github.workspace }}/artifacts/cli-archive" + find "${{ github.workspace }}/artifacts/cli-archive" -maxdepth 5 -type f || true + exit 1 + fi + + mkdir -p "${{ github.workspace }}/artifacts/bundle" + tar -xzf "$archive" -C "${{ github.workspace }}/artifacts/bundle" - name: Build TypeScript validation image run: | @@ -271,6 +316,6 @@ jobs: if [ "${{ needs.validate_rust.result }}" == "failure" ]; then echo "⚠️ Rust SDK validation failed (known issues - not blocking)" fi - + - name: All validations passed run: echo "✅ All required polyglot SDK validations passed!" diff --git a/.github/workflows/polyglot-validation/setup-local-cli.sh b/.github/workflows/polyglot-validation/setup-local-cli.sh index 08e65e936b9..13243e5a942 100644 --- a/.github/workflows/polyglot-validation/setup-local-cli.sh +++ b/.github/workflows/polyglot-validation/setup-local-cli.sh @@ -26,13 +26,6 @@ cp "$BUNDLE_DIR/aspire" "$ASPIRE_HOME/bin/" chmod +x "$ASPIRE_HOME/bin/aspire" echo " ✓ Installed to $ASPIRE_HOME/bin/aspire" -# Verify CLI works -echo "=== Verifying CLI ===" -"$ASPIRE_HOME/bin/aspire" --version || { - echo "ERROR: aspire --version failed" - exit 1 -} - # Extract the embedded bundle so runtime/dotnet and other components are available # Commands like 'aspire init' and 'aspire add' need the bundled dotnet for NuGet operations echo "=== Extracting bundle ===" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4f027a856b9..eab7145d481 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -67,17 +67,11 @@ jobs: build_cli_archives: name: Build native CLI archives + needs: build_packages uses: ./.github/workflows/build-cli-native-archives.yml with: versionOverrideArg: ${{ inputs.versionOverrideArg }} - build_bundle: - name: Build bundle - needs: [build_packages, build_cli_archives] - uses: ./.github/workflows/build-bundle.yml - with: - versionOverrideArg: ${{ inputs.versionOverrideArg }} - integrations_test_lin: uses: ./.github/workflows/run-tests.yml name: Integrations Linux @@ -193,7 +187,7 @@ jobs: # Only run CLI E2E tests during PR builds if: ${{ github.event_name == 'pull_request' }} uses: ./.github/workflows/run-tests.yml - needs: [setup_for_tests_lin, build_packages, build_cli_archives, build_bundle] + needs: [setup_for_tests_lin, build_packages, build_cli_archives] strategy: fail-fast: false matrix: ${{ fromJson(needs.setup_for_tests_lin.outputs.cli_e2e_tests_matrix) }} @@ -209,7 +203,7 @@ jobs: polyglot_validation: name: Polyglot SDK Validation uses: ./.github/workflows/polyglot-validation.yml - needs: [build_packages, build_bundle] + needs: [build_packages, build_cli_archives] with: versionOverrideArg: ${{ inputs.versionOverrideArg }} @@ -243,7 +237,6 @@ jobs: runs-on: ubuntu-latest name: Final Test Results needs: [ - build_bundle, build_cli_archives, cli_e2e_tests, endtoend_tests, diff --git a/Aspire.slnx b/Aspire.slnx index 9a174df70c9..15240a498ee 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -370,7 +370,6 @@ - diff --git a/docs/specs/bundle.md b/docs/specs/bundle.md index d97c88a44bb..cb954124863 100644 --- a/docs/specs/bundle.md +++ b/docs/specs/bundle.md @@ -30,15 +30,11 @@ This document specifies the **Aspire Bundle**, a self-contained distribution pac The Aspire Bundle is a platform-specific archive containing the Aspire CLI and all runtime components: -- **Aspire CLI** (native AOT executable) -- **.NET Runtime** (for running managed components) -- **Pre-built AppHost Server** (for polyglot app hosts) -- **Aspire Dashboard** (no longer distributed via NuGet) +- **Aspire CLI** (native AOT executable, includes native certificate management) +- **Aspire Managed** (unified self-contained binary: Dashboard + AppHost Server + NuGet Helper) - **Developer Control Plane (DCP)** (no longer distributed via NuGet) -- **NuGet Helper Tool** (for package search and restore without SDK) -- **Dev-Certs Tool** (for HTTPS certificate management without SDK) -**Key change**: DCP and Dashboard are now bundled with the CLI installation, not downloaded as NuGet packages. This applies to **all** Aspire applications, including .NET ones. This: +**Key change**: DCP and Dashboard are now bundled with the CLI installation, not downloaded as NuGet packages. Dashboard, AppHost Server, and NuGet Helper are consolidated into a single `aspire-managed` binary that dispatches via subcommands. Certificate management is handled natively in the CLI (no subprocess needed). This: - Eliminates large NuGet package downloads on first run - Ensures version consistency between CLI and runtime components @@ -98,16 +94,19 @@ DCP and Dashboard distribution via NuGet packages causes: ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ spawns ┌───────────────────────────────────┐ │ -│ │ aspire │ ───────────────▶│ .NET RUNTIME │ │ -│ │ (Native AOT) │ │ │ │ -│ │ │ │ • Runs AppHost Server │ │ -│ │ Commands: │ │ • Runs NuGet Helper Tool │ │ -│ │ • run │ │ • Hosts Dashboard │ │ -│ │ • add │ └───────────────────────────────────┘ │ -│ │ • new │ │ │ -│ │ • publish │ ▼ │ -│ └──────┬───────┘ ┌───────────────────────────────────┐ │ -│ │ │ APPHOST SERVER │ │ +│ │ aspire │ ───────────────▶│ ASPIRE-MANAGED │ │ +│ │ (Native AOT) │ │ (self-contained single binary) │ │ +│ │ │ │ │ │ +│ │ Commands: │ │ Subcommands: │ │ +│ │ • run │ │ • dashboard (Aspire Dashboard) │ │ +│ │ • add │ │ • server (AppHost Server) │ │ +│ │ • new │ │ • nuget (NuGet operations) │ │ +│ │ • publish │ └───────────────────────────────────┘ │ +│ │ │ │ │ +│ │ Native: │ ▼ │ +│ │ • cert mgmt │ ┌───────────────────────────────────┐ │ +│ └──────┬───────┘ │ APPHOST SERVER │ │ +│ │ │ (aspire-managed server) │ │ │ │ JSON-RPC │ │ │ │ │◀────────────────────▶│ • Aspire.Hosting.* assemblies │ │ │ │ (socket) │ • RemoteHostServer endpoint │ │ @@ -118,10 +117,10 @@ DCP and Dashboard distribution via NuGet packages causes: │ │ ▼ ▼ ▼ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ │ │ │ DASHBOARD │ │ DCP │ │INTEGRATIONS│ │ -│ │ │ │ │ │ │ │ │ -│ │ │ dashboard/ │ │ dcp/ │ │~/.aspire/ │ │ -│ │ └─────────────┘ └─────────────┘ │ packages/ │ │ -│ │ └────────────┘ │ +│ │ │ (aspire- │ │ │ │ │ │ +│ │ │ managed │ │ dcp/ │ │~/.aspire/ │ │ +│ │ │ dashboard) │ └─────────────┘ │ packages/ │ │ +│ │ └─────────────┘ └────────────┘ │ │ │ ▲ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │ │ USER'S APPHOST │────────┘ │ @@ -139,11 +138,11 @@ When a user runs `aspire run` with a TypeScript app host: 1. **CLI reads project configuration** from `.aspire/settings.json` 2. **CLI discovers bundle layout** using priority-based resolution -3. **CLI downloads missing integrations** using the NuGet Helper Tool +3. **CLI downloads missing integrations** using aspire-managed's NuGet subcommand 4. **CLI generates `appsettings.json`** for the AppHost Server with integration list -5. **CLI starts AppHost Server** using the bundled .NET runtime +5. **CLI starts AppHost Server** using aspire-managed's server subcommand 6. **CLI starts guest app host** (TypeScript) which connects via JSON-RPC -7. **AppHost Server orchestrates** containers, Dashboard, and DCP +7. **AppHost Server orchestrates** containers, Dashboard (via aspire-managed dashboard), and DCP --- @@ -155,70 +154,24 @@ When a user runs `aspire run` with a TypeScript app host: aspire-{version}-{platform}/ │ ├── aspire[.exe] # Native AOT CLI (~25 MB) +│ # (includes native certificate management) │ -├── layout.json # Bundle metadata -│ -├── runtime/ # .NET 10 Runtime (~106 MB) -│ ├── dotnet[.exe] # Muxer executable -│ ├── LICENSE.txt -│ ├── host/ -│ │ └── fxr/{version}/ -│ │ └── hostfxr.{dll|so|dylib} -│ └── shared/ -│ ├── Microsoft.NETCore.App/{version}/ -│ │ └── *.dll -│ └── Microsoft.AspNetCore.App/{version}/ -│ └── *.dll -│ -├── aspire-server/ # Pre-built AppHost Server (~19 MB) -│ ├── aspire-server[.exe] # Single-file executable -│ └── appsettings.json # Default config -│ -├── dashboard/ # Aspire Dashboard (~42 MB) -│ ├── aspire-dashboard[.exe] # Single-file executable -│ ├── wwwroot/ -│ └── ... +├── managed/ # Unified managed binary (~65 MB) +│ └── aspire-managed[.exe] # Self-contained single-file executable +│ # Subcommands: dashboard | server | nuget │ ├── dcp/ # Developer Control Plane (~127 MB) │ ├── dcp[.exe] # Native executable │ └── ... │ -└── tools/ # Helper tools (~5 MB) - ├── aspire-nuget/ # NuGet operations - │ ├── aspire-nuget[.exe] # Single-file executable - │ └── ... - │ - └── dev-certs/ # HTTPS certificate tool - ├── dotnet-dev-certs.dll - ├── dotnet-dev-certs.deps.json - └── dotnet-dev-certs.runtimeconfig.json +└── (no more runtime/, dashboard/, aspire-server/, tools/ directories) ``` -**Total Bundle Size:** -- **Unzipped:** ~323 MB -- **Zipped:** ~113 MB - -### layout.json Schema - -```json -{ - "version": "13.2.0", - "platform": "linux-x64", - "runtimeVersion": "10.0.0", - "components": { - "cli": "aspire", - "runtime": "runtime", - "apphostServer": "aspire-server", - "dashboard": "dashboard", - "dcp": "dcp", - "nugetHelper": "tools/aspire-nuget", - "devCerts": "tools/dev-certs" - }, - "builtInIntegrations": [] -} -``` +**Key change from previous layout**: The separate `.NET Runtime` (~106 MB), `dashboard/` (~42 MB), `aspire-server/` (~19 MB), `tools/aspire-nuget/` (~5 MB), and `tools/dev-certs/` directories have been consolidated into a single `managed/aspire-managed` self-contained binary. Certificate management has been moved natively into the CLI itself, eliminating the need for a separate dev-certs tool. ---- +**Total Bundle Size:** +- **Unzipped:** ~220 MB (down from ~323 MB — eliminated separate runtime) +- **Zipped:** ~80 MB ## Self-Extracting Binary @@ -261,7 +214,7 @@ The service uses a file lock (`.aspire-bundle-lock`) in the extraction directory **Extraction flow:** 1. Check for embedded `bundle.tar.gz` resource — if absent, return `NoPayload` 2. Check version marker (`.aspire-bundle-version`) — if version matches, return `AlreadyUpToDate` -3. Clean well-known layout directories (runtime, dashboard, dcp, aspire-server, tools) — preserves `bin/` +3. Clean well-known layout directories (managed, dcp) — preserves `bin/` 4. Extract payload using .NET `TarReader` with path-traversal and symlink validation 5. Set Unix file permissions from tar entry metadata (execute bit, etc.) 6. Write version marker with assembly informational version @@ -342,14 +295,12 @@ The parent directory check supports the installed layout where the CLI binary li |----------|-------------|---------| | `ASPIRE_LAYOUT_PATH` | Root of the bundle | `/opt/aspire` | | `ASPIRE_DCP_PATH` | DCP binaries location | `/opt/aspire/dcp` | -| `ASPIRE_DASHBOARD_PATH` | Dashboard executable path | `/opt/aspire/dashboard/aspire-dashboard` | -| `ASPIRE_RUNTIME_PATH` | Bundled .NET runtime directory (guest apphosts only) | `/opt/aspire/runtime` | +| `ASPIRE_DASHBOARD_PATH` | Path used by Aspire.Hosting to locate the dashboard binary (now points to `aspire-managed`) | `/opt/aspire/managed/aspire-managed` | +| `ASPIRE_MANAGED_PATH` | CLI-only path for the `aspire-managed` binary | `/opt/aspire/managed/aspire-managed` | | `ASPIRE_INTEGRATION_LIBS_PATH` | Path to integration DLLs for aspire-server assembly resolution | `/home/user/.aspire/libs` | | `ASPIRE_USE_GLOBAL_DOTNET` | Force SDK mode | `true` | | `ASPIRE_REPO_ROOT` | Dev mode (Aspire repo path, DEBUG builds only) | `/home/user/aspire` | -**Note:** `ASPIRE_RUNTIME_PATH` is only set for guest (polyglot) apphosts. .NET apphosts use the globally installed `dotnet`. - **Note:** `ASPIRE_INTEGRATION_LIBS_PATH` is set by the CLI when running guest apphosts that require additional hosting integration packages (e.g., `Aspire.Hosting.Redis`). The aspire-server uses this path to resolve integration assemblies at runtime. ### Transition Compatibility @@ -375,7 +326,7 @@ Bundle CLI ────► runs ────► .NET AppHost (new Aspire.Hosting Bundle CLI ────► runs ────► .NET AppHost (old Aspire.Hosting) │ │ │ sets ASPIRE_DCP_PATH │ ignores (doesn't check env vars) - │ sets ASPIRE_DASHBOARD_PATH│ + │ sets ASPIRE_DASHBOARD_PATH│ │ │ │ │ Uses NuGet package paths ✓ ``` @@ -422,7 +373,7 @@ dotnet run ────► .NET AppHost (new Aspire.Hosting) | Component | When it discovers | What it does | |-----------|------------------|--------------| -| **CLI** | Before launching AppHost | Sets `ASPIRE_DCP_PATH`, `ASPIRE_DASHBOARD_PATH`, and `ASPIRE_RUNTIME_PATH` (guest only) env vars | +| **CLI** | Before launching AppHost | Sets `ASPIRE_DCP_PATH`, `ASPIRE_DASHBOARD_PATH` env vars | | **Aspire.Hosting** | At AppHost startup | Reads env vars OR does its own disk discovery OR uses NuGet | This dual-discovery approach ensures: @@ -434,13 +385,13 @@ This dual-discovery approach ensures: ## NuGet Operations -The bundle includes a managed NuGet Helper Tool that provides package search and restore functionality without requiring the .NET SDK. +The bundle includes NuGet operations via the `aspire-managed nuget` subcommand, which provides package search and restore functionality without requiring the .NET SDK. ### NuGet Helper Commands ```bash # Search for packages -{runtime}/dotnet {tools}/aspire-nuget/aspire-nuget.dll search \ +{managed}/aspire-managed nuget search \ --query "Aspire.Hosting" \ --prerelease \ --take 50 \ @@ -448,14 +399,14 @@ The bundle includes a managed NuGet Helper Tool that provides package search and --format json # Restore packages -{runtime}/dotnet {tools}/aspire-nuget/aspire-nuget.dll restore \ +{managed}/aspire-managed nuget restore \ --package "Aspire.Hosting.Redis" \ --version "13.2.0" \ --framework net10.0 \ --output ~/.aspire/packages # Create flat layout from restored packages (DLLs + XML doc files) -{runtime}/dotnet {tools}/aspire-nuget/aspire-nuget.dll layout \ +{managed}/aspire-managed nuget layout \ --assets ~/.aspire/packages/obj/project.assets.json \ --output ~/.aspire/packages/libs \ --framework net10.0 @@ -505,44 +456,39 @@ The bundle includes a managed NuGet Helper Tool that provides package search and ## Certificate Management -The bundle includes the `dotnet-dev-certs` tool for HTTPS certificate management. This enables polyglot apphosts to configure HTTPS certificates without requiring a globally-installed .NET SDK. +The CLI includes native HTTPS certificate management via ASP.NET Core's `CertificateManager` library, ported directly into the native AOT binary. This eliminates the need for a separate dev-certs tool or subprocess for certificate operations. -### Dev-Certs Tool Usage +### How It Works -```bash -# Check certificate trust status (machine-readable output) -{runtime}/dotnet {tools}/dev-certs/dotnet-dev-certs.dll https --check --trust +The `CertificateManager` from `aspnetcore/src/Shared/CertificateGeneration/` is vendored into `src/Aspire.Cli/Certificates/CertificateGeneration/`. The original `EventSource`-based logging (AOT-incompatible) has been replaced with `ILogger`, making the code fully native AOT friendly. -# Trust the development certificate (requires elevation on some platforms) -{runtime}/dotnet {tools}/dev-certs/dotnet-dev-certs.dll https --trust -``` +Platform-specific implementations handle certificate store operations: +- **Windows**: `WindowsCertificateManager` — Windows certificate store + ACLs +- **macOS**: `MacOSCertificateManager` — Keychain management via `security` CLI +- **Linux**: `UnixCertificateManager` — OpenSSL + NSS databases + .NET trust store ### Certificate Tool Abstraction -The CLI uses an `ICertificateToolRunner` abstraction to support both bundle and SDK modes: +The CLI uses an `ICertificateToolRunner` abstraction with a single implementation: -| Mode | Implementation | Usage | -|------|----------------|-------| -| Bundle | `BundleCertificateToolRunner` | Uses bundled runtime + dev-certs.dll | -| SDK | `SdkCertificateToolRunner` | Uses `dotnet dev-certs` from global SDK | +| Implementation | Description | +|----------------|-------------| +| `NativeCertificateToolRunner` | Calls `CertificateManager` directly (no subprocess) | -The appropriate implementation is selected via DI based on whether a bundle layout is detected: +The `CertificateManager` is registered as a singleton via DI, with `ILogger` injected through the constructor: ```csharp -services.AddSingleton(sp => -{ - var layout = sp.GetService(); - var devCertsPath = layout?.GetDevCertsDllPath(); - - if (devCertsPath is not null && File.Exists(devCertsPath)) - { - return new BundleCertificateToolRunner(layout!); - } - - return new SdkCertificateToolRunner(sp.GetRequiredService()); -}); +// Register CertificateManager (platform-specific) and certificate tool runner +builder.Services.AddSingleton(sp => CertificateManager.Create(sp.GetRequiredService>())); +builder.Services.AddSingleton(); ``` +### Key Operations + +- **Check trust status**: `CertificateManager.ListCertificates()` + `GetTrustLevel()` — returns structured certificate info +- **Trust certificate**: `CertificateManager.EnsureAspNetCoreHttpsDevelopmentCertificate()` — creates and trusts dev cert +- **Platform detection**: `CertificateManager.Create()` selects the right platform implementation at startup + --- ## AppHost Server @@ -569,8 +515,8 @@ When a project references integrations (e.g., `Aspire.Hosting.Redis`): ### Pre-built Mode Execution ```bash -# CLI spawns the pre-built AppHost Server -{aspire-server}/aspire-server \ +# CLI spawns the AppHost Server via aspire-managed +{managed}/aspire-managed server \ --project {user-project-path} \ --socket {socket-path} ``` @@ -689,24 +635,13 @@ The bundle installs components as siblings under `~/.aspire/`, with the CLI bina │ # - Or SDK-based CLI (CLI-only install) │ ├── .aspire-bundle-version # Version marker (hex FNV-1a hash, written after extraction) -├── layout.json # Bundle metadata (present only for bundle install) -│ -├── runtime/ # Bundled .NET runtime -│ └── dotnet │ -├── dashboard/ # Pre-built Dashboard -│ └── Aspire.Dashboard +├── managed/ # Unified managed binary (self-contained) +│ └── aspire-managed # Subcommands: dashboard | server | nuget │ ├── dcp/ # Developer Control Plane │ └── dcp │ -├── aspire-server/ # Pre-built AppHost Server (polyglot) -│ └── aspire-server -│ -├── tools/ -│ └── aspire-nuget/ # NuGet operations without SDK -│ └── aspire-nuget -│ ├── hives/ # NuGet package hives (preserved across installs) │ └── pr-{number}/ │ └── packages/ @@ -718,7 +653,8 @@ The bundle installs components as siblings under `~/.aspire/`, with the CLI bina - The CLI lives at `~/.aspire/bin/aspire` regardless of install method - With self-extracting binaries, the CLI in `bin/` contains the embedded payload; `aspire setup` extracts siblings - `.aspire-bundle-version` tracks the extracted version — extraction is skipped when hash matches -- Bundle components (`runtime/`, `dashboard/`, `dcp/`, etc.) are siblings at the `~/.aspire/` root +- `aspire-managed` is a single self-contained binary replacing separate runtime, dashboard, aspire-server, and tools directories +- Certificate management is native to the CLI (no external tool needed) - NuGet hives and settings are preserved across installations and re-extractions - `LayoutDiscovery` finds the bundle by checking the CLI's parent directory for components @@ -757,16 +693,7 @@ irm https://aka.ms/install-aspire.ps1 | iex -Args '--install-dir', 'C:\aspire' For testing PR builds before they are merged: -**Bundle from PR (self-contained):** -```bash -# Linux/macOS -./eng/scripts/get-aspire-cli-bundle-pr.sh 1234 - -# Windows -.\eng\scripts\get-aspire-cli-bundle-pr.ps1 -PRNumber 1234 -``` - -**Existing CLI from PR (requires SDK):** +**CLI from PR (bundle-backed):** ```bash # Linux/macOS ./eng/scripts/get-aspire-cli-pr.sh 1234 @@ -775,7 +702,7 @@ For testing PR builds before they are merged: .\eng\scripts\get-aspire-cli-pr.ps1 -PRNumber 1234 ``` -Both bundle and CLI-only PR scripts also download NuGet package artifacts (`built-nugets` and `built-nugets-for-{rid}`) and install them as a NuGet hive at `~/.aspire/hives/pr-{N}/packages/`. This enables `aspire new` and `aspire add` to resolve PR-built package versions when the channel is set to `pr-{N}`. +The PR script also downloads NuGet package artifacts (`built-nugets` and `built-nugets-for-{rid}`) and installs them as a NuGet hive at `~/.aspire/hives/pr-{N}/packages/`. This enables `aspire new` and `aspire add` to resolve PR-built package versions when the channel is set to `pr-{N}`. --- @@ -804,16 +731,12 @@ Downloaded integration packages are cached in: | Component | On Disk | Zipped | |-----------|---------|--------| | DCP (platform-specific) | ~286 MB | ~100 MB | -| .NET 10 Runtime (incl. ASP.NET Core) | ~200 MB | ~70 MB | -| Dashboard (framework-dependent) | ~43 MB | ~15 MB | -| CLI (native AOT) | ~22 MB | ~10 MB | -| AppHost Server (core only) | ~21 MB | ~8 MB | -| NuGet Helper (aspire-nuget) | ~5 MB | ~2 MB | -| Dev-certs Tool | ~0.1 MB | ~0.05 MB | -| **Total** | **~577 MB** | **~204 MB** | - -*AppHost Server includes core hosting only - all integrations are downloaded on-demand.* -*Dashboard is framework-dependent (not self-contained), sharing the bundled .NET runtime.* +| Aspire Managed (self-contained: Dashboard + Server + NuGet + .NET Runtime) | ~65 MB | ~25 MB | +| CLI (native AOT, includes certificate management) | ~22 MB | ~10 MB | +| **Total** | **~373 MB** | **~135 MB** | + +*Aspire Managed is a single self-contained binary that includes the .NET runtime, eliminating the need for a separate runtime directory.* +*Certificate management is handled natively in the CLI — no separate tool needed.* *Sizes vary by platform. Linux tends to be smaller than Windows.* ### Distribution Formats @@ -965,7 +888,7 @@ Changes to internal CLI classes maintain backward compatibility through: 3. **Graceful degradation** - If bundle components are missing, fall back to SDK - - If NuGetHelper is unavailable, fall back to `dotnet` commands + - If NuGet operations are unavailable, fall back to `dotnet` commands - Error messages guide users to resolution ### Test Compatibility @@ -992,8 +915,7 @@ Tests continue to work because: | `ASPIRE_REPO_ROOT` | Development mode | Uses SDK with project references | | `ASPIRE_LAYOUT_PATH` | Bundle location | Overrides auto-detection | | `ASPIRE_DCP_PATH` | DCP override | Works in both modes | -| `ASPIRE_DASHBOARD_PATH` | Dashboard override | Works in both modes | -| `ASPIRE_RUNTIME_PATH` | .NET runtime override | For guest apphosts only | +| `ASPIRE_MANAGED_PATH` | Aspire-managed override (CLI only) | Works in both modes | ### Migration Path @@ -1271,7 +1193,7 @@ This section tracks the implementation progress of the bundle feature. - [x] **Layout discovery service** - `src/Aspire.Cli/Layout/LayoutDiscovery.cs` - [x] **Layout process runner** - `src/Aspire.Cli/Layout/LayoutProcessRunner.cs` - [x] **Bundle NuGet service** - `src/Aspire.Cli/NuGet/BundleNuGetService.cs` -- [x] **NuGet Helper tool** - `src/Aspire.Cli.NuGetHelper/` +- [x] **NuGet operations** - embedded in `src/Aspire.Managed/NuGet/` - [x] Search command (NuGet v3 HTTP API) - [x] Restore command (NuGet RestoreRunner) - [x] Layout command (flat DLL + XML doc layout from project.assets.json) @@ -1295,15 +1217,16 @@ This section tracks the implementation progress of the bundle feature. - Framework-dependent deployment (uses bundled runtime) - [x] **Certificate management** - `src/Aspire.Cli/Certificates/` - `ICertificateToolRunner` abstraction - - `BundleCertificateToolRunner` - uses bundled runtime + dev-certs.dll - - `SdkCertificateToolRunner` - uses global `dotnet dev-certs` + - `NativeCertificateToolRunner` - calls `CertificateManager` directly (no subprocess) + - `CertificateGeneration/` - vendored from aspnetcore, EventSource replaced with ILogger +- [x] **Aspire Managed unified binary** - `src/Aspire.Managed/` + - Self-contained single binary: `aspire-managed dashboard|server|nuget` + - Replaces separate runtime, dashboard, aspire-server, and tools directories - [x] **Bundle build tooling** - `tools/CreateLayout/` - - Downloads .NET SDK and extracts runtime + dev-certs - - Copies DCP, Dashboard, aspire-server, NuGetHelper - - Generates layout.json metadata - - Enables RollForward=Major for all managed tools + - Builds aspire-managed as self-contained single-file binary + - Copies DCP - `--embed-in-cli` option creates self-extracting binary -- [x] **Installation scripts** - `eng/scripts/get-aspire-cli-bundle-pr.sh`, `eng/scripts/get-aspire-cli-bundle-pr.ps1` +- [x] **Installation scripts** - `eng/scripts/get-aspire-cli-pr.sh`, `eng/scripts/get-aspire-cli-pr.ps1` - Downloads bundle archive from PR build artifacts - Extracts to `~/.aspire/` with CLI in `bin/` subdirectory - Downloads and installs NuGet hive packages for PR channel @@ -1339,13 +1262,14 @@ This section tracks the implementation progress of the bundle feature. | `src/Aspire.Cli/Layout/LayoutDiscovery.cs` | Priority-based layout discovery (env > config > relative) | | `src/Aspire.Cli/Layout/LayoutProcessRunner.cs` | Run managed DLLs via layout's .NET runtime | | `src/Aspire.Cli/NuGet/BundleNuGetService.cs` | NuGet operations wrapper for bundle mode | -| `src/Aspire.Cli.NuGetHelper/` | Managed tool for search/restore/layout | +| `src/Aspire.Managed/NuGet/` | NuGet search/restore/layout commands (embedded in aspire-managed) | | `src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs` | Bundle-mode server runner | | `src/Aspire.Cli/Projects/GuestAppHostProject.cs` | Main polyglot handler with bundle/SDK mode switching | | `src/Aspire.Hosting/Dcp/DcpOptions.cs` | DCP/Dashboard path resolution with env var support | | `src/Aspire.Cli/Certificates/ICertificateToolRunner.cs` | Certificate tool abstraction | -| `src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs` | Bundled dev-certs runner | -| `src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs` | SDK-based dev-certs runner | +| `src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs` | Native certificate management (no subprocess) | +| `src/Aspire.Cli/Certificates/CertificateGeneration/` | Vendored CertificateManager from aspnetcore (ILogger-based) | +| `src/Aspire.Managed/Program.cs` | Unified managed binary entry point (dashboard/server/nuget) | | `src/Shared/BundleTrailer.cs` | (Deleted) Previously held trailer read/write logic | | `src/Aspire.Cli/Bundles/IBundleService.cs` | Bundle extraction interface + result enum | | `src/Aspire.Cli/Bundles/BundleService.cs` | Centralized extraction with .NET TarReader | @@ -1361,59 +1285,32 @@ This section tracks the implementation progress of the bundle feature. The bundle is built using the `tools/CreateLayout` tool, which assembles all components into the final bundle layout. -### SDK Download Approach +### Aspire Managed Build -The bundle's .NET runtime is extracted from the official .NET SDK, which provides several advantages: - -1. **Single download**: The SDK contains the runtime, ASP.NET Core framework, and dev-certs tool -2. **Version consistency**: All components come from the same SDK release -3. **Official source**: Direct from Microsoft's build infrastructure +The `aspire-managed` binary is published as a self-contained single-file executable, which includes the .NET runtime. This eliminates the need to separately download and bundle the .NET SDK/runtime. ```text -SDK download (~200 MB) -├── dotnet.exe → runtime/dotnet.exe -├── host/ → runtime/host/ -├── shared/Microsoft.NETCore.App/10.0.x/ → runtime/shared/Microsoft.NETCore.App/ -├── shared/Microsoft.AspNetCore.App/10.0.x/ → runtime/shared/Microsoft.AspNetCore.App/ -├── sdk/10.0.x/DotnetTools/dotnet-dev-certs → tools/dev-certs/ -└── (discard: sdk/, templates/, packs/, etc.) +aspire-managed (self-contained, ~65 MB) +├── .NET 10 Runtime (embedded) +├── ASP.NET Core Framework (embedded) +├── Aspire.Dashboard (embedded) +├── Aspire.Hosting.RemoteHost / aspire-server (embedded) +├── NuGet Commands (embedded) +└── All managed dependencies ``` -The SDK version is discovered dynamically from `https://builds.dotnet.microsoft.com/dotnet/release-metadata/releases-index.json`. - -### RollForward Configuration - -All managed tools in the bundle are configured with `rollForward: Major` in their runtimeconfig.json files. This allows: - -- Tools built for .NET 8.0 or 9.0 to run on the bundled .NET 10+ runtime -- Maximum compatibility with older Aspire.Hosting packages -- Simpler bundle maintenance (single runtime version) - -The CreateLayout tool automatically patches all `*.runtimeconfig.json` files: - -```json -{ - "runtimeOptions": { - "rollForward": "Major", - "framework": { - "name": "Microsoft.AspNetCore.App", - "version": "8.0.0" - } - } -} -``` +**Advantages over the previous SDK-download approach:** +1. **Simpler build**: No SDK download or extraction step +2. **Smaller total size**: Single binary with tree-shaking vs full runtime directory +3. **Single file**: One binary instead of hundreds of files across multiple directories +4. **Version consistency**: All components compiled together ### Build Steps -1. **Download .NET SDK** for the target platform -2. **Extract runtime components** (muxer, host, shared frameworks) -3. **Extract dev-certs tool** from `sdk/*/DotnetTools/dotnet-dev-certs/` -4. **Build and copy managed tools** (aspire-server, aspire-dashboard, NuGetHelper) -5. **Download and copy DCP** binaries -6. **Patch runtimeconfig.json files** to enable RollForward=Major -7. **Generate layout.json** with component metadata -8. **Create archive** (tar.gz for Unix, ZIP for Windows) with `COPYFILE_DISABLE=1` to suppress macOS xattr headers -9. **Create self-extracting binary** — appends tar.gz payload + 32-byte trailer to native AOT CLI +1. **Build aspire-managed** as a self-contained single-file binary (includes .NET runtime, Dashboard, AppHost Server, NuGet operations) +2. **Download and copy DCP** binaries +3. **Create archive** (tar.gz for Unix, ZIP for Windows) with `COPYFILE_DISABLE=1` to suppress macOS xattr headers +4. **Create self-extracting binary** — appends tar.gz payload + 32-byte trailer to native AOT CLI ### Self-Extracting Binary Build diff --git a/eng/Bundle.proj b/eng/Bundle.proj index e671888fea6..626184870d4 100644 --- a/eng/Bundle.proj +++ b/eng/Bundle.proj @@ -1,20 +1,19 @@ @@ -23,32 +22,33 @@ Debug - + $(TargetRids) win-x64 osx-arm64 linux-x64 - + $([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)', '..')) $(RepoRoot)artifacts\ $(ArtifactsDir)log\$(Configuration)\ $(ArtifactsDir)bundle\$(TargetRid)\ - + $(RepoRoot)src\Aspire.Cli\Aspire.Cli.csproj - $(RepoRoot)src\Aspire.Cli.NuGetHelper\Aspire.Cli.NuGetHelper.csproj - $(RepoRoot)src\Aspire.Hosting.RemoteHost\Aspire.Hosting.RemoteHost.csproj - $(RepoRoot)src\Aspire.Dashboard\Aspire.Dashboard.csproj + $(RepoRoot)src\Aspire.Managed\Aspire.Managed.csproj $(RepoRoot)src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj $(RepoRoot)tools\CreateLayout\CreateLayout.csproj - + $(VersionPrefix)-$(VersionSuffix) $(VersionPrefix)-dev - + + + <_VersionSuffixArg Condition="'$(VersionSuffix)' != ''">/p:VersionSuffix=$(VersionSuffix) + <_BinlogArg Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir) @@ -67,7 +67,7 @@ - + @@ -89,10 +89,6 @@ <_CliBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishCli.binlog <_BundleArchivePath>$(ArtifactsDir)bundle\aspire-$(BundleVersion)-$(TargetRid).tar.gz - - <_VersionSuffixArg Condition="'$(VersionSuffix)' != ''">/p:VersionSuffix=$(VersionSuffix) @@ -100,15 +96,11 @@ - <_NuGetHelperBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishNuGetHelper.binlog - <_AppHostServerBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishAppHostServer.binlog - <_DashboardBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishDashboard.binlog + <_ManagedBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishManaged.binlog - - - - - + + + @@ -124,16 +116,10 @@ <_BundleOutputDirArg>$(BundleOutputDir.TrimEnd('\').TrimEnd('/')) <_ArtifactsDirArg>$(ArtifactsDir.TrimEnd('\').TrimEnd('/')) - - - <_RuntimeArgs Condition="'$(BundleRuntimePath)' != ''">--runtime "$(BundleRuntimePath)" - <_RuntimeArgs Condition="'$(BundleRuntimePath)' == ''">--download-runtime - - <_CreateLayoutArgs>--output "$(_BundleOutputDirArg)" --artifacts "$(_ArtifactsDirArg)" --rid $(TargetRid) --bundle-version $(BundleVersion) --runtime-version $(BundleRuntimeVersion) --verbose $(_RuntimeArgs) --archive + + <_CreateLayoutArgs>--output "$(_BundleOutputDirArg)" --artifacts "$(_ArtifactsDirArg)" --rid $(TargetRid) --bundle-version $(BundleVersion) --verbose --archive - + diff --git a/eng/Versions.props b/eng/Versions.props index 0dc60eb7c0f..9bf00bbdc0c 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -15,8 +15,6 @@ 8.0.415 9.0.306 - - 10.0.102 3.1.0 1.21.0 3.1.0 diff --git a/eng/build.ps1 b/eng/build.ps1 index 9fab54f5769..611fc8cf22a 100644 --- a/eng/build.ps1 +++ b/eng/build.ps1 @@ -160,11 +160,6 @@ if ($bundle) { $bundleArgs += "/p:SkipNativeBuild=true" } - # Pass through runtime version if set - if ($runtimeVersion) { - $bundleArgs += "/p:BundleRuntimeVersion=$runtimeVersion" - } - # CI flag is passed to Bundle.proj which handles version computation via Versions.props if ($ci) { $bundleArgs += "/p:ContinuousIntegrationBuild=true" diff --git a/eng/build.sh b/eng/build.sh index 58596335da2..f23a40c25b4 100755 --- a/eng/build.sh +++ b/eng/build.sh @@ -254,11 +254,6 @@ if [ "$build_bundle" = true ]; then fi done - # Pass through runtime version if set - if [ -n "$runtime_version" ]; then - bundle_args+=("/p:BundleRuntimeVersion=$runtime_version") - fi - # CI flag is passed to Bundle.proj which handles version computation via Versions.props if [ "${CI:-}" = "true" ]; then bundle_args+=("/p:ContinuousIntegrationBuild=true") diff --git a/eng/clipack/Common.projitems b/eng/clipack/Common.projitems index 0b20ebe2845..137c5532b8a 100644 --- a/eng/clipack/Common.projitems +++ b/eng/clipack/Common.projitems @@ -24,7 +24,7 @@ + DependsOnTargets="_PublishProject;_ValidateNativeBinaryFileSignatureOnUnix;PrepareOutputPathForPublishToDisk" /> @@ -42,6 +42,7 @@ + @@ -95,28 +96,4 @@ vs regex: $(FileSignatureRegex)" /> - - - <_MaxFileSizeMB Condition="'$(CliRuntime)' == 'win-x86'">75 - <_MaxFileSizeMB Condition="'$(_MaxFileSizeMB)' == '' and $([System.OperatingSystem]::IsWindows())">30 - <_MaxFileSizeMB Condition="'$(_MaxFileSizeMB)' == '' and !$([System.OperatingSystem]::IsWindows())">30 - - - <_GetFileSizeMBCommand Condition="$([System.OperatingSystem]::IsWindows())">pwsh -NoProfile -Command "[Math]::Ceiling((Get-Item '$(_NativeBinaryPath)' -ErrorAction Stop).Length / 1MB)" - - - <_GetFileSizeMBCommand Condition="!$([System.OperatingSystem]::IsWindows())">du -m '$(_NativeBinaryPath)' | cut -f1 - - - - - - - - - - - - diff --git a/eng/pipelines/templates/build_sign_native.yml b/eng/pipelines/templates/build_sign_native.yml index e8d869fd433..5cd83286e13 100644 --- a/eng/pipelines/templates/build_sign_native.yml +++ b/eng/pipelines/templates/build_sign_native.yml @@ -33,8 +33,10 @@ jobs: - ${{ if eq(parameters.agentOs, 'windows') }}: - scriptName: build.cmd + - dotnetScript: dotnet.cmd - ${{ else }}: - scriptName: build.sh + - dotnetScript: dotnet.sh pool: ${{ if eq(parameters.agentOs, 'windows') }}: @@ -61,6 +63,19 @@ jobs: displayName: 🟣Restore steps: + - script: >- + $(Build.SourcesDirectory)/$(dotnetScript) + msbuild + $(Build.SourcesDirectory)/eng/Bundle.proj + /restore + /p:Configuration=$(_BuildConfig) + /p:TargetRid=${{ targetRid }} + /p:BundleVersion=ci-bundlepayload + /p:SkipNativeBuild=true + /p:ContinuousIntegrationBuild=true + /bl:$(Build.Arcade.LogsPath)BundlePayload.binlog + displayName: 🟣Build bundle payload + - script: >- $(Build.SourcesDirectory)/$(scriptName) --ci @@ -68,6 +83,7 @@ jobs: --restore /p:SkipManagedBuild=true /p:TargetRids=${{ targetRid }} + /p:BundlePayloadPath=$(Build.SourcesDirectory)/artifacts/bundle/aspire-ci-bundlepayload-${{ targetRid }}.tar.gz $(_buildArgs) ${{ parameters.extraBuildArgs }} /bl:$(Build.Arcade.LogsPath)Build.binlog diff --git a/eng/scripts/README.md b/eng/scripts/README.md index b5b628cdc38..2478b74d761 100644 --- a/eng/scripts/README.md +++ b/eng/scripts/README.md @@ -4,26 +4,11 @@ This directory contains scripts to download and install the Aspire CLI for diffe ## Scripts -### CLI Only (requires .NET SDK) +### CLI installers - **`get-aspire-cli.sh`** - Bash script for Unix-like systems (Linux, macOS) - **`get-aspire-cli.ps1`** - PowerShell script for cross-platform use (Windows, Linux, macOS) -### Self-Contained Bundle (no .NET SDK required) - -- **`install-aspire-bundle.sh`** - Bash script for Unix-like systems (Linux, macOS) -- **`install-aspire-bundle.ps1`** - PowerShell script for Windows - -The **bundle** includes everything needed to run Aspire applications without a .NET SDK: -- Aspire CLI (native AOT) -- .NET Runtime -- Aspire Dashboard -- Developer Control Plane (DCP) -- Pre-built AppHost Server -- NuGet Helper Tool - -This is ideal for polyglot developers using TypeScript, Python, Go, etc. - ## Current Limitations Supported Quality values: @@ -212,107 +197,7 @@ $env:ASPIRE_REPO = 'myfork/aspire' ./get-aspire-cli-pr.ps1 1234 ``` -## Aspire Bundle Installation - -The bundle scripts install a self-contained distribution that doesn't require a .NET SDK. - -### Quick Install - -**Linux/macOS:** -```bash -curl -sSL https://aka.ms/install-aspire-bundle.sh | bash -``` - -**Windows (PowerShell):** -```powershell -iex ((New-Object System.Net.WebClient).DownloadString('https://aka.ms/install-aspire-bundle.ps1')) -``` - -### Bundle Script Parameters - -#### Bash Script (`install-aspire-bundle.sh`) - -| Parameter | Short | Description | Default | -|------------------|-------|---------------------------------------------------|-----------------------| -| `--install-path` | `-i` | Directory to install the bundle | `$HOME/.aspire` | -| `--version` | | Specific version to install | latest release | -| `--os` | | Operating system (linux, osx) | auto-detect | -| `--arch` | | Architecture (x64, arm64) | auto-detect | -| `--skip-path` | | Do not add aspire to PATH | `false` | -| `--force` | | Overwrite existing installation | `false` | -| `--dry-run` | | Show what would be done without installing | `false` | -| `--verbose` | `-v` | Enable verbose output | `false` | -| `--help` | `-h` | Show help message | | - -#### PowerShell Script (`install-aspire-bundle.ps1`) - -| Parameter | Description | Default | -|-----------------|---------------------------------------------------|----------------------------------| -| `-InstallPath` | Directory to install the bundle | `$env:LOCALAPPDATA\Aspire` | -| `-Version` | Specific version to install | latest release | -| `-Architecture` | Architecture (x64, arm64) | auto-detect | -| `-SkipPath` | Do not add aspire to PATH | `false` | -| `-Force` | Overwrite existing installation | `false` | -| `-DryRun` | Show what would be done without installing | `false` | - -### Bundle Examples - -```bash -# Install latest version -./install-aspire-bundle.sh - -# Install specific version -./install-aspire-bundle.sh --version "9.2.0" - -# Install to custom location -./install-aspire-bundle.sh --install-path "/opt/aspire" - -# Dry run to see what would happen -./install-aspire-bundle.sh --dry-run --verbose -``` - -```powershell -# Install latest version -.\install-aspire-bundle.ps1 - -# Install specific version -.\install-aspire-bundle.ps1 -Version "9.2.0" - -# Install to custom location -.\install-aspire-bundle.ps1 -InstallPath "C:\Tools\Aspire" - -# Force reinstall -.\install-aspire-bundle.ps1 -Force -``` - -### Updating and Uninstalling - -**Update an existing bundle installation:** -```bash -aspire update --self -``` - -**Uninstall:** -```bash -# Linux/macOS -rm -rf ~/.aspire - -# Windows (PowerShell) -Remove-Item -Recurse -Force "$env:LOCALAPPDATA\Aspire" -``` - -### Bundle vs CLI-Only - -| Feature | CLI-Only Scripts | Bundle Scripts | Self-Extracting Binary | -|---------|-----------------|----------------|----------------------| -| Requires .NET SDK | Yes | No | No | -| Package size | ~25 MB | ~200 MB compressed | ~210 MB (single file) | -| Polyglot support | Partial | Full | Full | -| Components included | CLI only | CLI, Runtime, Dashboard, DCP | CLI, Runtime, Dashboard, DCP | -| Installation steps | Download + PATH | Download + extract + PATH | Download + `aspire setup` | -| Use case | .NET developers | TypeScript, Python, Go developers | Simplest install path | - -### Self-Extracting Binary +## Self-Extracting Binary The Aspire CLI can also be distributed as a self-extracting binary that embeds the full bundle payload inside the native AOT executable. This is the simplest installation method: diff --git a/eng/scripts/get-aspire-cli-bundle-pr.ps1 b/eng/scripts/get-aspire-cli-bundle-pr.ps1 deleted file mode 100644 index 4f429c593da..00000000000 --- a/eng/scripts/get-aspire-cli-bundle-pr.ps1 +++ /dev/null @@ -1,625 +0,0 @@ -#!/usr/bin/env pwsh - -<# -.SYNOPSIS - Download and install the Aspire CLI Bundle from a specific PR's build artifacts - -.DESCRIPTION - Downloads and installs the Aspire CLI Bundle from a specific pull request's latest successful build. - Automatically detects the current platform (OS and architecture) and downloads the appropriate artifact. - - The bundle artifact contains a self-extracting Aspire CLI binary that embeds all runtime components. - The script downloads the binary, places it in the install directory, and runs `aspire setup` to - extract the embedded components (Dashboard, DCP, runtime, AppHost Server, NuGet tools). - -.PARAMETER PRNumber - Pull request number (required) - -.PARAMETER WorkflowRunId - Workflow run ID to download from (optional) - -.PARAMETER InstallPath - Directory to install bundle (default: $HOME/.aspire on Unix, %USERPROFILE%\.aspire on Windows) - -.PARAMETER OS - Override OS detection (win, linux, osx) - -.PARAMETER Architecture - Override architecture detection (x64, arm64) - -.PARAMETER SkipPath - Do not add the install path to PATH environment variable - -.PARAMETER KeepArchive - Keep downloaded archive files after installation - -.PARAMETER Help - Show this help message - -.EXAMPLE - .\get-aspire-cli-bundle-pr.ps1 1234 - -.EXAMPLE - .\get-aspire-cli-bundle-pr.ps1 1234 -WorkflowRunId 12345678 - -.EXAMPLE - .\get-aspire-cli-bundle-pr.ps1 1234 -InstallPath "C:\my-aspire-bundle" - -.EXAMPLE - .\get-aspire-cli-bundle-pr.ps1 1234 -OS linux -Architecture arm64 -Verbose - -.EXAMPLE - .\get-aspire-cli-bundle-pr.ps1 1234 -WhatIf - -.EXAMPLE - .\get-aspire-cli-bundle-pr.ps1 1234 -SkipPath - -.EXAMPLE - Piped execution - iex "& { $(irm https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-bundle-pr.ps1) } - -.NOTES - Requires GitHub CLI (gh) to be installed and authenticated - Requires appropriate permissions to download artifacts from target repository - -.PARAMETER ASPIRE_REPO (environment variable) - Override repository (owner/name). Default: dotnet/aspire - Example: $env:ASPIRE_REPO = 'myfork/aspire' -#> - -[CmdletBinding(SupportsShouldProcess)] -param( - [Parameter(Position = 0, HelpMessage = "Pull request number")] - [ValidateRange(1, [int]::MaxValue)] - [int]$PRNumber, - - [Parameter(HelpMessage = "Workflow run ID to download from")] - [ValidateRange(1, [long]::MaxValue)] - [long]$WorkflowRunId, - - [Parameter(HelpMessage = "Directory to install bundle")] - [string]$InstallPath = "", - - [Parameter(HelpMessage = "Override OS detection")] - [ValidateSet("", "win", "linux", "osx")] - [string]$OS = "", - - [Parameter(HelpMessage = "Override architecture detection")] - [ValidateSet("", "x64", "arm64")] - [string]$Architecture = "", - - [Parameter(HelpMessage = "Skip adding to PATH")] - [switch]$SkipPath, - - [Parameter(HelpMessage = "Keep downloaded archive files")] - [switch]$KeepArchive, - - [Parameter(HelpMessage = "Show help")] - [switch]$Help -) - -# ============================================================================= -# Constants -# ============================================================================= - -$script:BUNDLE_ARTIFACT_NAME_PREFIX = "aspire-bundle" -$script:BUILT_NUGETS_ARTIFACT_NAME = "built-nugets" -$script:BUILT_NUGETS_RID_ARTIFACT_NAME = "built-nugets-for" -$script:REPO = if ($env:ASPIRE_REPO) { $env:ASPIRE_REPO } else { "dotnet/aspire" } -$script:GH_REPOS_BASE = "repos/$script:REPO" - -# ============================================================================= -# Logging functions -# ============================================================================= - -function Write-VerboseMessage { - param([string]$Message) - if ($VerbosePreference -ne 'SilentlyContinue') { - Write-Host $Message -ForegroundColor Yellow - } -} - -function Write-ErrorMessage { - param([string]$Message) - Write-Host "Error: $Message" -ForegroundColor Red -} - -function Write-WarnMessage { - param([string]$Message) - Write-Host "Warning: $Message" -ForegroundColor Yellow -} - -function Write-InfoMessage { - param([string]$Message) - Write-Host $Message -} - -function Write-SuccessMessage { - param([string]$Message) - Write-Host $Message -ForegroundColor Green -} - -# ============================================================================= -# Platform detection -# ============================================================================= - -function Get-HostOS { - if ($IsWindows -or $env:OS -eq "Windows_NT") { - return "win" - } - elseif ($IsMacOS) { - return "osx" - } - elseif ($IsLinux) { - return "linux" - } - else { - return "win" # Default to Windows for PowerShell 5.1 - } -} - -function Get-HostArchitecture { - $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture - switch ($arch) { - "X64" { return "x64" } - "Arm64" { return "arm64" } - default { return "x64" } - } -} - -function Get-RuntimeIdentifier { - param( - [string]$TargetOS, - [string]$TargetArch - ) - - if ([string]::IsNullOrEmpty($TargetOS)) { - $TargetOS = Get-HostOS - } - - if ([string]::IsNullOrEmpty($TargetArch)) { - $TargetArch = Get-HostArchitecture - } - - return "$TargetOS-$TargetArch" -} - -# ============================================================================= -# GitHub API functions -# ============================================================================= - -function Test-GhDependency { - $ghPath = Get-Command "gh" -ErrorAction SilentlyContinue - if (-not $ghPath) { - Write-ErrorMessage "GitHub CLI (gh) is required but not installed." - Write-InfoMessage "Installation instructions: https://cli.github.com/" - return $false - } - - try { - $ghVersion = & gh --version 2>&1 - Write-VerboseMessage "GitHub CLI (gh) found: $($ghVersion | Select-Object -First 1)" - return $true - } - catch { - Write-ErrorMessage "GitHub CLI (gh) command failed: $_" - return $false - } -} - -function Invoke-GhApiCall { - param( - [string]$Endpoint, - [string]$JqFilter = "", - [string]$ErrorMessage = "Failed to call GitHub API" - ) - - $ghArgs = @("api", $Endpoint) - if (-not [string]::IsNullOrEmpty($JqFilter)) { - $ghArgs += @("--jq", $JqFilter) - } - - Write-VerboseMessage "Calling GitHub API: gh $($ghArgs -join ' ')" - - try { - $result = & gh @ghArgs 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-ErrorMessage "$ErrorMessage (API endpoint: $Endpoint): $result" - return $null - } - return $result - } - catch { - Write-ErrorMessage "$ErrorMessage (API endpoint: $Endpoint): $_" - return $null - } -} - -function Get-PrHeadSha { - param([int]$PrNumber) - - Write-VerboseMessage "Getting HEAD SHA for PR #$PrNumber" - - $headSha = Invoke-GhApiCall -Endpoint "$script:GH_REPOS_BASE/pulls/$PrNumber" -JqFilter ".head.sha" -ErrorMessage "Failed to get HEAD SHA for PR #$PrNumber" - - if ([string]::IsNullOrEmpty($headSha) -or $headSha -eq "null") { - Write-ErrorMessage "Could not retrieve HEAD SHA for PR #$PrNumber" - Write-InfoMessage "This could mean:" - Write-InfoMessage " - The PR number does not exist" - Write-InfoMessage " - You don't have access to the repository" - return $null - } - - Write-VerboseMessage "PR #$PrNumber HEAD SHA: $headSha" - return $headSha -} - -function Find-WorkflowRun { - param([string]$HeadSha) - - Write-VerboseMessage "Finding ci.yml workflow run for SHA: $HeadSha" - - $workflowRunId = Invoke-GhApiCall -Endpoint "$script:GH_REPOS_BASE/actions/workflows/ci.yml/runs?event=pull_request&head_sha=$HeadSha" -JqFilter ".workflow_runs | sort_by(.created_at, .updated_at) | reverse | .[0].id" -ErrorMessage "Failed to query workflow runs for SHA: $HeadSha" - - if ([string]::IsNullOrEmpty($workflowRunId) -or $workflowRunId -eq "null") { - Write-ErrorMessage "No ci.yml workflow run found for PR SHA: $HeadSha" - Write-InfoMessage "Check at https://github.com/$script:REPO/actions/workflows/ci.yml" - return $null - } - - Write-VerboseMessage "Found workflow run ID: $workflowRunId" - return $workflowRunId -} - -# ============================================================================= -# Bundle download and install -# ============================================================================= - -function Get-AspireBundle { - param( - [string]$WorkflowRunId, - [string]$Rid, - [string]$TempDir - ) - - $bundleArtifactName = "$script:BUNDLE_ARTIFACT_NAME_PREFIX-$Rid" - $downloadDir = Join-Path $TempDir "bundle" - - if ($WhatIfPreference) { - Write-InfoMessage "[WhatIf] Would download $bundleArtifactName" - return $downloadDir - } - - Write-InfoMessage "Downloading bundle artifact: $bundleArtifactName ..." - - New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null - - $ghArgs = @("run", "download", $WorkflowRunId, "-R", $script:REPO, "--name", $bundleArtifactName, "-D", $downloadDir) - Write-VerboseMessage "Downloading with: gh $($ghArgs -join ' ')" - - try { - & gh @ghArgs 2>&1 | Out-Null - if ($LASTEXITCODE -ne 0) { - throw "gh run download failed with exit code $LASTEXITCODE" - } - } - catch { - Write-ErrorMessage "Failed to download artifact '$bundleArtifactName' from run: $WorkflowRunId" - Write-InfoMessage "If the workflow is still running, the artifact may not be available yet." - Write-InfoMessage "Check at https://github.com/$script:REPO/actions/runs/$WorkflowRunId#artifacts" - Write-InfoMessage "" - - # Try to list available artifacts from the workflow run - try { - $artifactsJson = & gh api "repos/$script:REPO/actions/runs/$WorkflowRunId/artifacts" --jq '.artifacts[].name' 2>&1 - if ($LASTEXITCODE -eq 0 -and $artifactsJson) { - $bundleArtifacts = $artifactsJson | Where-Object { $_ -like "$script:BUNDLE_ARTIFACT_NAME_PREFIX-*" } - if ($bundleArtifacts) { - Write-InfoMessage "Available bundle artifacts:" - foreach ($artifact in $bundleArtifacts) { - Write-InfoMessage " $artifact" - } - } - else { - Write-InfoMessage "No bundle artifacts found in this workflow run." - } - } - } - catch { - Write-VerboseMessage "Could not query available artifacts: $_" - } - - return $null - } - - Write-VerboseMessage "Successfully downloaded bundle to: $downloadDir" - return $downloadDir -} - -function Install-AspireBundle { - param( - [string]$DownloadDir, - [string]$InstallDir - ) - - if ($WhatIfPreference) { - Write-InfoMessage "[WhatIf] Would install bundle to: $InstallDir" - return $true - } - - # Find the self-extracting binary in the downloaded artifact - $cliExe = if ($IsWindows -or $env:OS -eq "Windows_NT") { "aspire.exe" } else { "aspire" } - $binaryPath = Join-Path $DownloadDir $cliExe - if (-not (Test-Path $binaryPath)) { - # Search in subdirectories - $found = Get-ChildItem -Path $DownloadDir -Filter $cliExe -Recurse | Select-Object -First 1 - if ($found) { - $binaryPath = $found.FullName - } else { - Write-ErrorMessage "Could not find $cliExe in downloaded artifact" - return $false - } - } - - # Place the self-extracting binary in bin/ - $binDir = Join-Path $InstallDir "bin" - if (-not (Test-Path $binDir)) { - New-Item -ItemType Directory -Path $binDir -Force | Out-Null - } - $cliPath = Join-Path $binDir $cliExe - Copy-Item -Path $binaryPath -Destination $cliPath -Force - - # Bundle extraction happens lazily on first command that needs the layout - Write-SuccessMessage "Aspire CLI bundle successfully installed to: $InstallDir" - return $true -} - -# ============================================================================= -# PATH management -# ============================================================================= - -function Add-ToUserPath { - param([string]$PathToAdd) - - if ($WhatIfPreference) { - Write-InfoMessage "[WhatIf] Would add $PathToAdd to user PATH" - return - } - - $currentPath = [Environment]::GetEnvironmentVariable("PATH", "User") - - if ($currentPath -split ";" | Where-Object { $_ -eq $PathToAdd }) { - Write-InfoMessage "Path $PathToAdd already exists in PATH, skipping addition" - return - } - - $newPath = "$PathToAdd;$currentPath" - [Environment]::SetEnvironmentVariable("PATH", $newPath, "User") - - # Also update current session - $env:PATH = "$PathToAdd;$env:PATH" - - Write-InfoMessage "Successfully added $PathToAdd to PATH" - Write-InfoMessage "You may need to restart your terminal for the change to take effect" -} - -# ============================================================================= -# NuGet hive download and install functions -# ============================================================================= - -function Get-BuiltNugets { - param( - [string]$WorkflowRunId, - [string]$Rid, - [string]$TempDir - ) - - $downloadDir = Join-Path $TempDir "built-nugets" - - if ($WhatIfPreference) { - Write-InfoMessage "[WhatIf] Would download built NuGet packages" - return $downloadDir - } - - Write-InfoMessage "Downloading built NuGet artifacts..." - New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null - - try { - $ghArgs = @("run", "download", $WorkflowRunId, "-R", $script:REPO, "--name", $script:BUILT_NUGETS_ARTIFACT_NAME, "-D", $downloadDir) - & gh @ghArgs 2>&1 | Out-Null - if ($LASTEXITCODE -ne 0) { throw "Failed to download $($script:BUILT_NUGETS_ARTIFACT_NAME)" } - - $ridArtifactName = "$($script:BUILT_NUGETS_RID_ARTIFACT_NAME)-$Rid" - $ghArgs = @("run", "download", $WorkflowRunId, "-R", $script:REPO, "--name", $ridArtifactName, "-D", $downloadDir) - & gh @ghArgs 2>&1 | Out-Null - if ($LASTEXITCODE -ne 0) { throw "Failed to download $ridArtifactName" } - } - catch { - Write-WarnMessage "Failed to download NuGet packages: $($_.Exception.Message)" - return $null - } - - return $downloadDir -} - -function Install-BuiltNugets { - param( - [string]$DownloadDir, - [string]$NugetHiveDir - ) - - if ($WhatIfPreference) { - Write-InfoMessage "[WhatIf] Would copy nugets to $NugetHiveDir" - return - } - - if (Test-Path $NugetHiveDir) { - Remove-Item $NugetHiveDir -Recurse -Force - } - New-Item -ItemType Directory -Path $NugetHiveDir -Force | Out-Null - - $nupkgFiles = Get-ChildItem -Path $DownloadDir -Filter "*.nupkg" -Recurse - if ($nupkgFiles.Count -eq 0) { - Write-WarnMessage "No .nupkg files found in downloaded artifact" - return - } - - foreach ($file in $nupkgFiles) { - Copy-Item $file.FullName -Destination $NugetHiveDir - } - - Write-InfoMessage "NuGet packages installed to: $NugetHiveDir" -} - -# ============================================================================= -# Main function -# ============================================================================= - -function Main { - if ($Help) { - Get-Help $MyInvocation.MyCommand.Path -Detailed - return - } - - if ($PRNumber -eq 0) { - Write-ErrorMessage "PR number is required" - Write-InfoMessage "Use -Help for usage information" - exit 1 - } - - # Check dependencies - if (-not (Test-GhDependency)) { - exit 1 - } - - # Set default install path - if ([string]::IsNullOrEmpty($InstallPath)) { - if ((Get-HostOS) -eq "win") { - $InstallPath = Join-Path $env:USERPROFILE ".aspire" - } - else { - $InstallPath = Join-Path $HOME ".aspire" - } - } - - Write-InfoMessage "Starting bundle download for PR #$PRNumber" - Write-InfoMessage "Install path: $InstallPath" - - # Get workflow run ID - $runId = $WorkflowRunId - if (-not $runId) { - $headSha = Get-PrHeadSha -PrNumber $PRNumber - if (-not $headSha) { - exit 1 - } - - $runId = Find-WorkflowRun -HeadSha $headSha - if (-not $runId) { - exit 1 - } - } - - Write-InfoMessage "Using workflow run https://github.com/$script:REPO/actions/runs/$runId" - - # Compute RID - $rid = Get-RuntimeIdentifier -TargetOS $OS -TargetArch $Architecture - Write-VerboseMessage "Computed RID: $rid" - - # Create temp directory - $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-bundle-pr-$([System.Guid]::NewGuid().ToString('N').Substring(0,8))" - New-Item -ItemType Directory -Path $tempDir -Force | Out-Null - - try { - # Download bundle - $downloadDir = Get-AspireBundle -WorkflowRunId $runId -Rid $rid -TempDir $tempDir - if (-not $downloadDir) { - exit 1 - } - - # Install bundle - if (-not (Install-AspireBundle -DownloadDir $downloadDir -InstallDir $InstallPath)) { - exit 1 - } - - # Download and install NuGet hive packages (needed for 'aspire new' and 'aspire add') - $nugetHiveDir = Join-Path $InstallPath "hives" "pr-$PRNumber" "packages" - $nugetDownloadDir = Get-BuiltNugets -WorkflowRunId $runId -Rid $rid -TempDir $tempDir - if (-not $nugetDownloadDir) { - Write-ErrorMessage "Failed to download NuGet packages" - exit 1 - } - Install-BuiltNugets -DownloadDir $nugetDownloadDir -NugetHiveDir $nugetHiveDir - - # Verify installation (CLI is now in bin/ subdirectory) - $binDir = Join-Path $InstallPath "bin" - $cliPath = Join-Path $binDir "aspire.exe" - if (-not (Test-Path $cliPath)) { - $cliPath = Join-Path $binDir "aspire" - } - - if ((Test-Path $cliPath) -and -not $WhatIfPreference) { - Write-InfoMessage "" - Write-InfoMessage "Verifying installation..." - try { - $version = & $cliPath --version 2>&1 - Write-SuccessMessage "Bundle verification passed!" - Write-InfoMessage "Installed version: $version" - } - catch { - Write-WarnMessage "Bundle verification failed - CLI may not work correctly" - } - } - - # Add to PATH (use bin/ subdirectory, same as CLI-only install) - if (-not $SkipPath) { - Add-ToUserPath -PathToAdd $binDir - } - else { - Write-InfoMessage "Skipping PATH configuration due to -SkipPath flag" - } - - # Save the global channel setting to the PR channel - # This allows 'aspire new' and 'aspire init' to use the same channel by default - if (-not $WhatIfPreference) { - Write-VerboseMessage "Setting global config: channel = pr-$PRNumber" - try { - $output = & $cliPath config set -g channel "pr-$PRNumber" 2>&1 - Write-VerboseMessage "Global config saved: channel = pr-$PRNumber" - } - catch { - Write-WarnMessage "Failed to set global channel config via aspire CLI (non-fatal)" - } - } - else { - Write-InfoMessage "[DRY RUN] Would run: $cliPath config set -g channel pr-$PRNumber" - } - -# Print success message - Write-InfoMessage "" - Write-SuccessMessage "============================================" - Write-SuccessMessage " Aspire Bundle from PR #$PRNumber Installed" - Write-SuccessMessage "============================================" - Write-InfoMessage "" - Write-InfoMessage "Bundle location: $InstallPath" - Write-InfoMessage "" - Write-InfoMessage "To use:" - Write-InfoMessage " $cliPath --help" - Write-InfoMessage " $cliPath run" - Write-InfoMessage "" - Write-InfoMessage "The bundle includes everything needed to run Aspire apps" - Write-InfoMessage "without requiring a globally-installed .NET SDK." - } - finally { - # Cleanup temp directory - if (-not $KeepArchive -and (Test-Path $tempDir)) { - Write-VerboseMessage "Cleaning up temporary files..." - Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue - } - elseif ($KeepArchive) { - Write-InfoMessage "Archive files kept in: $tempDir" - } - } -} - -# Run main -Main diff --git a/eng/scripts/get-aspire-cli-bundle-pr.sh b/eng/scripts/get-aspire-cli-bundle-pr.sh deleted file mode 100644 index e0679db2c0c..00000000000 --- a/eng/scripts/get-aspire-cli-bundle-pr.sh +++ /dev/null @@ -1,785 +0,0 @@ -#!/usr/bin/env bash - -# get-aspire-cli-bundle-pr.sh - Download and install the Aspire CLI Bundle from a specific PR's build artifacts -# Usage: ./get-aspire-cli-bundle-pr.sh PR_NUMBER [OPTIONS] -# -# The bundle artifact contains a self-extracting Aspire CLI binary that embeds all -# runtime components. The script downloads the binary, places it in the install -# directory, and runs `aspire setup` to extract the embedded components. - -set -euo pipefail - -# Global constants / defaults -readonly BUNDLE_ARTIFACT_NAME_PREFIX="aspire-bundle" -readonly BUILT_NUGETS_ARTIFACT_NAME="built-nugets" -readonly BUILT_NUGETS_RID_ARTIFACT_NAME="built-nugets-for" - -# Repository: Allow override via ASPIRE_REPO env var (owner/name). Default: dotnet/aspire -readonly REPO="${ASPIRE_REPO:-dotnet/aspire}" -readonly GH_REPOS_BASE="repos/${REPO}" - -# Global constants -readonly RED='\033[0;31m' -readonly GREEN='\033[0;32m' -readonly YELLOW='\033[1;33m' -readonly RESET='\033[0m' - -# Variables (defaults set after parsing arguments) -INSTALL_PREFIX="" -PR_NUMBER="" -WORKFLOW_RUN_ID="" -OS_ARG="" -ARCH_ARG="" -SHOW_HELP=false -VERBOSE=false -KEEP_ARCHIVE=false -DRY_RUN=false -SKIP_PATH=false -HOST_OS="unset" - -# Function to show help -show_help() { - cat << 'EOF' -Aspire CLI Bundle PR Download Script - -DESCRIPTION: - Downloads and installs the Aspire CLI Bundle from a specific pull request's latest successful build. - Automatically detects the current platform (OS and architecture) and downloads the appropriate artifact. - - The bundle artifact contains a self-extracting Aspire CLI binary that embeds all runtime - components. The script downloads the binary, places it in the install directory, and runs - `aspire setup` to extract the embedded components: - - Dashboard (web-based monitoring UI) - - DCP (Developer Control Plane for orchestration) - - .NET runtime (for running managed components) - - AppHost Server (for polyglot apps - TypeScript, Python, Go, etc.) - - NuGet Helper tools - - This enables running Aspire applications WITHOUT requiring a globally-installed .NET SDK. - - The script queries the GitHub API to find the latest successful run of the 'ci.yml' workflow - for the specified PR, then downloads and extracts the bundle archive for your platform. - -USAGE: - ./get-aspire-cli-bundle-pr.sh PR_NUMBER [OPTIONS] - ./get-aspire-cli-bundle-pr.sh PR_NUMBER --run-id WORKFLOW_RUN_ID [OPTIONS] - - PR_NUMBER Pull request number (required) - --run-id, -r WORKFLOW_ID Workflow run ID to download from (optional) - -i, --install-path PATH Directory to install bundle (default: ~/.aspire) - --os OS Override OS detection (win, linux, osx) - --arch ARCH Override architecture detection (x64, arm64) - --skip-path Do not add the install path to PATH environment variable - -v, --verbose Enable verbose output - -k, --keep-archive Keep downloaded archive files after installation - --dry-run Show what would be done without performing actions - -h, --help Show this help message - -EXAMPLES: - ./get-aspire-cli-bundle-pr.sh 1234 - ./get-aspire-cli-bundle-pr.sh 1234 --run-id 12345678 - ./get-aspire-cli-bundle-pr.sh 1234 --install-path ~/my-aspire-bundle - ./get-aspire-cli-bundle-pr.sh 1234 --os linux --arch arm64 --verbose - ./get-aspire-cli-bundle-pr.sh 1234 --skip-path - ./get-aspire-cli-bundle-pr.sh 1234 --dry-run - - curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-bundle-pr.sh | bash -s -- - -REQUIREMENTS: - - GitHub CLI (gh) must be installed and authenticated - - Permissions to download artifacts from the target repository - -ENVIRONMENT VARIABLES: - ASPIRE_REPO Override repository (owner/name). Default: dotnet/aspire - Example: export ASPIRE_REPO=myfork/aspire - -EOF -} - -# Function to parse command line arguments -parse_args() { - # Check for help flag first (can be anywhere in arguments) - for arg in "$@"; do - if [[ "$arg" == "-h" || "$arg" == "--help" ]]; then - SHOW_HELP=true - return 0 - fi - done - - # Check that at least one argument is provided - if [[ $# -lt 1 ]]; then - say_error "At least one argument is required. The first argument must be a PR number." - say_info "Use --help for usage information." - exit 1 - fi - - # First argument must be the PR number (cannot start with --) - if [[ "$1" == --* ]]; then - say_error "First argument must be a PR number, not an option. Got: '$1'" - say_info "Use --help for usage information." - exit 1 - fi - - # Validate that the first argument is a valid PR number (positive integer) - if [[ "$1" =~ ^[1-9][0-9]*$ ]]; then - PR_NUMBER="$1" - shift - else - say_error "First argument must be a valid PR number" - say_info "Use --help for usage information." - exit 1 - fi - - while [[ $# -gt 0 ]]; do - case $1 in - --run-id|-r) - if [[ $# -lt 2 || -z "$2" ]]; then - say_error "Option '$1' requires a non-empty value" - exit 1 - fi - if [[ ! "$2" =~ ^[0-9]+$ ]]; then - say_error "Run ID must be a number. Got: '$2'" - exit 1 - fi - WORKFLOW_RUN_ID="$2" - shift 2 - ;; - -i|--install-path) - if [[ $# -lt 2 || -z "$2" ]]; then - say_error "Option '$1' requires a non-empty value" - exit 1 - fi - INSTALL_PREFIX="$2" - shift 2 - ;; - --os) - if [[ $# -lt 2 || -z "$2" ]]; then - say_error "Option '$1' requires a non-empty value" - exit 1 - fi - OS_ARG="$2" - shift 2 - ;; - --arch) - if [[ $# -lt 2 || -z "$2" ]]; then - say_error "Option '$1' requires a non-empty value" - exit 1 - fi - ARCH_ARG="$2" - shift 2 - ;; - -k|--keep-archive) - KEEP_ARCHIVE=true - shift - ;; - --skip-path) - SKIP_PATH=true - shift - ;; - --dry-run) - DRY_RUN=true - shift - ;; - -v|--verbose) - VERBOSE=true - shift - ;; - *) - say_error "Unknown option '$1'" - say_info "Use --help for usage information." - exit 1 - ;; - esac - done -} - -# ============================================================================= -# Logging functions -# ============================================================================= - -say_verbose() { - if [[ "$VERBOSE" == true ]]; then - echo -e "${YELLOW}$1${RESET}" >&2 - fi -} - -say_error() { - echo -e "${RED}Error: $1${RESET}" >&2 -} - -say_warn() { - echo -e "${YELLOW}Warning: $1${RESET}" >&2 -} - -say_info() { - echo -e "$1" >&2 -} - -say_success() { - echo -e "${GREEN}$1${RESET}" >&2 -} - -# ============================================================================= -# Platform detection -# ============================================================================= - -detect_os() { - local uname_s - uname_s=$(uname -s) - - case "$uname_s" in - Darwin*) - printf "osx" - ;; - Linux*) - printf "linux" - ;; - CYGWIN*|MINGW*|MSYS*) - printf "win" - ;; - *) - printf "unsupported" - return 1 - ;; - esac -} - -detect_architecture() { - local uname_m - uname_m=$(uname -m) - - case "$uname_m" in - x86_64|amd64) - printf "x64" - ;; - aarch64|arm64) - printf "arm64" - ;; - *) - say_error "Architecture $uname_m not supported." - return 1 - ;; - esac -} - -get_runtime_identifier() { - local target_os="${1:-$HOST_OS}" - local target_arch="${2:-}" - - if [[ -z "$target_arch" ]]; then - if ! target_arch=$(detect_architecture); then - return 1 - fi - fi - - printf "%s-%s" "$target_os" "$target_arch" -} - -# ============================================================================= -# Temp directory management -# ============================================================================= - -new_temp_dir() { - local prefix="$1" - if [[ "$DRY_RUN" == true ]]; then - printf "/tmp/%s-whatif" "$prefix" - return 0 - fi - local dir - if ! dir=$(mktemp -d -t "${prefix}-XXXXXXXX"); then - say_error "Unable to create temporary directory" - return 1 - fi - say_verbose "Creating temporary directory: $dir" - printf "%s" "$dir" -} - -remove_temp_dir() { - local dir="$1" - if [[ -z "$dir" || ! -d "$dir" ]]; then - return 0 - fi - if [[ "$DRY_RUN" == true ]]; then - return 0 - fi - if [[ "$KEEP_ARCHIVE" != true ]]; then - say_verbose "Cleaning up temporary files..." - rm -rf "$dir" || say_warn "Failed to clean up temporary directory: $dir" - else - printf "Archive files kept in: %s\n" "$dir" - fi -} - -# ============================================================================= -# PATH management -# ============================================================================= - -add_to_path() { - local config_file="$1" - local bin_path="$2" - local command="$3" - - if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would add '$command' to $config_file" - return 0 - fi - - if [[ ":$PATH:" == *":$bin_path:"* ]]; then - say_info "Path $bin_path already exists in \$PATH, skipping addition" - elif [[ -f "$config_file" ]] && grep -Fxq "$command" "$config_file"; then - say_info "Command already exists in $config_file, skipping addition" - elif [[ -w $config_file ]]; then - echo -e "\n# Added by get-aspire-cli-bundle-pr.sh script" >> "$config_file" - echo "$command" >> "$config_file" - say_info "Successfully added aspire bundle to \$PATH in $config_file" - else - say_info "Manually add the following to $config_file (or similar):" - say_info " $command" - fi -} - -add_to_shell_profile() { - local bin_path="$1" - local bin_path_unexpanded="$2" - local xdg_config_home="${XDG_CONFIG_HOME:-$HOME/.config}" - - local shell_name - if [[ -n "${SHELL:-}" ]]; then - shell_name=$(basename "$SHELL") - else - shell_name=$(ps -p $$ -o comm= 2>/dev/null || echo "sh") - fi - - case "$shell_name" in - bash|zsh|fish) ;; - sh|dash|ash) shell_name="sh" ;; - *) shell_name="bash" ;; - esac - - say_verbose "Detected shell: $shell_name" - - local config_files - case "$shell_name" in - bash) config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile" ;; - zsh) config_files="$HOME/.zshrc $HOME/.zshenv" ;; - fish) config_files="$HOME/.config/fish/config.fish" ;; - sh) config_files="$HOME/.profile" ;; - *) config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile" ;; - esac - - local config_file - for file in $config_files; do - if [[ -f "$file" ]]; then - config_file="$file" - break - fi - done - - if [[ -z "${config_file:-}" ]]; then - say_warn "No existing shell profile file found. Not adding to PATH automatically." - say_info "Add Aspire bundle to PATH manually by adding:" - say_info " export PATH=\"$bin_path_unexpanded:\$PATH\"" - return 0 - fi - - case "$shell_name" in - bash|zsh|sh) - add_to_path "$config_file" "$bin_path" "export PATH=\"$bin_path_unexpanded:\$PATH\"" - ;; - fish) - add_to_path "$config_file" "$bin_path" "fish_add_path $bin_path_unexpanded" - ;; - esac - - if [[ "$DRY_RUN" != true ]]; then - printf "\nTo use the Aspire CLI bundle in new terminal sessions, restart your terminal or run:\n" - say_info " source $config_file" - fi -} - -# ============================================================================= -# GitHub API functions -# ============================================================================= - -check_gh_dependency() { - if ! command -v gh >/dev/null 2>&1; then - say_error "GitHub CLI (gh) is required but not installed." - say_info "Installation instructions: https://cli.github.com/" - return 1 - fi - - if ! gh_version_output=$(gh --version 2>&1); then - say_error "GitHub CLI (gh) command failed: $gh_version_output" - return 1 - fi - - say_verbose "GitHub CLI (gh) found: $(echo "$gh_version_output" | head -1)" -} - -gh_api_call() { - local endpoint="$1" - local jq_filter="${2:-}" - local error_message="${3:-Failed to call GitHub API}" - local gh_cmd=(gh api "$endpoint") - if [[ -n "$jq_filter" ]]; then - gh_cmd+=(--jq "$jq_filter") - fi - say_verbose "Calling GitHub API: ${gh_cmd[*]}" - local api_output - if ! api_output=$("${gh_cmd[@]}" 2>&1); then - say_error "$error_message (API endpoint: $endpoint): $api_output" - return 1 - fi - printf "%s" "$api_output" -} - -get_pr_head_sha() { - local pr_number="$1" - - say_verbose "Getting HEAD SHA for PR #$pr_number" - - local head_sha - if ! head_sha=$(gh_api_call "${GH_REPOS_BASE}/pulls/$pr_number" ".head.sha" "Failed to get HEAD SHA for PR #$pr_number"); then - say_info "This could mean:" - say_info " - The PR number does not exist" - say_info " - You don't have access to the repository" - exit 1 - fi - - if [[ -z "$head_sha" || "$head_sha" == "null" ]]; then - say_error "Could not retrieve HEAD SHA for PR #$pr_number" - exit 1 - fi - - say_verbose "PR #$pr_number HEAD SHA: $head_sha" - printf "%s" "$head_sha" -} - -find_workflow_run() { - local head_sha="$1" - - say_verbose "Finding ci.yml workflow run for SHA: $head_sha" - - local workflow_run_id - if ! workflow_run_id=$(gh_api_call "${GH_REPOS_BASE}/actions/workflows/ci.yml/runs?event=pull_request&head_sha=$head_sha" ".workflow_runs | sort_by(.created_at, .updated_at) | reverse | .[0].id" "Failed to query workflow runs for SHA: $head_sha"); then - return 1 - fi - - if [[ -z "$workflow_run_id" || "$workflow_run_id" == "null" ]]; then - say_error "No ci.yml workflow run found for PR SHA: $head_sha" - say_info "Check at https://github.com/${REPO}/actions/workflows/ci.yml" - return 1 - fi - - say_verbose "Found workflow run ID: $workflow_run_id" - printf "%s" "$workflow_run_id" -} - -# ============================================================================= -# Bundle download and install -# ============================================================================= - -download_aspire_bundle() { - local workflow_run_id="$1" - local rid="$2" - local temp_dir="$3" - - local bundle_artifact_name="${BUNDLE_ARTIFACT_NAME_PREFIX}-${rid}" - local download_dir="${temp_dir}/bundle" - local download_command=(gh run download "$workflow_run_id" -R "$REPO" --name "$bundle_artifact_name" -D "$download_dir") - - if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would download $bundle_artifact_name with: ${download_command[*]}" - printf "%s" "$download_dir" - return 0 - fi - - say_info "Downloading bundle artifact: $bundle_artifact_name ..." - say_verbose "Downloading with: ${download_command[*]}" - - if ! "${download_command[@]}"; then - say_verbose "gh run download command failed. Command: ${download_command[*]}" - say_error "Failed to download artifact '$bundle_artifact_name' from run: $workflow_run_id" - say_info "If the workflow is still running, the artifact may not be available yet." - say_info "Check at https://github.com/${REPO}/actions/runs/$workflow_run_id#artifacts" - say_info "" - say_info "Available bundle artifacts:" - say_info " aspire-bundle-linux-x64" - say_info " aspire-bundle-win-x64" - say_info " aspire-bundle-osx-x64" - say_info " aspire-bundle-osx-arm64" - return 1 - fi - - say_verbose "Successfully downloaded bundle to: $download_dir" - printf "%s" "$download_dir" -} - -install_aspire_bundle() { - local download_dir="$1" - local install_dir="$2" - - if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would install bundle to: $install_dir" - return 0 - fi - - # Find the self-extracting binary in the downloaded artifact - local binary_name="aspire" - local binary_path="" - if [[ -f "$download_dir/$binary_name" ]]; then - binary_path="$download_dir/$binary_name" - else - # Search for the binary in subdirectories - binary_path=$(find "$download_dir" -name "$binary_name" -type f | head -1) - fi - - if [[ -z "$binary_path" || ! -f "$binary_path" ]]; then - say_error "Could not find aspire binary in downloaded artifact" - return 1 - fi - - # Place the self-extracting binary in bin/ - local bin_dir="$install_dir/bin" - mkdir -p "$bin_dir" - cp "$binary_path" "$bin_dir/aspire" - chmod +x "$bin_dir/aspire" - - # Bundle extraction happens lazily on first command that needs the layout - say_success "Aspire CLI bundle successfully installed to: $install_dir" -} - -# ============================================================================= -# NuGet hive download and install functions -# ============================================================================= - -download_built_nugets() { - local workflow_run_id="$1" - local rid="$2" - local temp_dir="$3" - - local download_dir="${temp_dir}/built-nugets" - local nugets_download_command=(gh run download "$workflow_run_id" -R "$REPO" --name "$BUILT_NUGETS_ARTIFACT_NAME" -D "$download_dir") - local nugets_rid_filename="$BUILT_NUGETS_RID_ARTIFACT_NAME-${rid}" - local nugets_rid_download_command=(gh run download "$workflow_run_id" -R "$REPO" --name "$nugets_rid_filename" -D "$download_dir") - - if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would download built nugets with: ${nugets_download_command[*]}" - say_info "[DRY RUN] Would download rid specific built nugets with: ${nugets_rid_download_command[*]}" - printf "%s" "$download_dir" - return 0 - fi - - say_info "Downloading built NuGet artifacts - $BUILT_NUGETS_ARTIFACT_NAME" - say_verbose "Downloading with: ${nugets_download_command[*]}" - - if ! "${nugets_download_command[@]}"; then - say_error "Failed to download artifact '$BUILT_NUGETS_ARTIFACT_NAME' from run: $workflow_run_id" - return 1 - fi - - say_info "Downloading RID-specific NuGet artifacts - $nugets_rid_filename ..." - say_verbose "Downloading with: ${nugets_rid_download_command[*]}" - - if ! "${nugets_rid_download_command[@]}"; then - say_error "Failed to download artifact '$nugets_rid_filename' from run: $workflow_run_id" - return 1 - fi - - say_verbose "Successfully downloaded NuGet packages to: $download_dir" - printf "%s" "$download_dir" - return 0 -} - -install_built_nugets() { - local download_dir="$1" - local nuget_install_dir="$2" - - if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would copy nugets to $nuget_install_dir" - return 0 - fi - - # Remove and recreate the target directory to ensure clean state - if [[ -d "$nuget_install_dir" ]]; then - say_verbose "Removing existing nuget directory: $nuget_install_dir" - rm -rf "$nuget_install_dir" - fi - mkdir -p "$nuget_install_dir" - - say_verbose "Copying nugets from $download_dir to $nuget_install_dir" - - if ! find "$download_dir" -name "*.nupkg" -exec cp -R {} "$nuget_install_dir"/ \;; then - say_error "Failed to copy NuGet artifact files" - return 1 - fi - - say_verbose "Successfully installed NuGet packages to: $nuget_install_dir" - say_info "NuGet packages successfully installed to: ${GREEN}$nuget_install_dir${RESET}" - return 0 -} - -# ============================================================================= -# Main download and install function -# ============================================================================= - -download_and_install_bundle() { - local temp_dir="$1" - local head_sha workflow_run_id rid - - if [[ -n "$WORKFLOW_RUN_ID" ]]; then - say_info "Starting bundle download for PR #$PR_NUMBER with workflow run ID: $WORKFLOW_RUN_ID" - workflow_run_id="$WORKFLOW_RUN_ID" - else - say_info "Starting bundle download for PR #$PR_NUMBER" - - if ! head_sha=$(get_pr_head_sha "$PR_NUMBER"); then - return 1 - fi - - if ! workflow_run_id=$(find_workflow_run "$head_sha"); then - return 1 - fi - fi - - say_info "Using workflow run https://github.com/${REPO}/actions/runs/$workflow_run_id" - - # Compute RID - if ! rid=$(get_runtime_identifier "$OS_ARG" "$ARCH_ARG"); then - return 1 - fi - say_verbose "Computed RID: $rid" - - # Download bundle - local download_dir - if ! download_dir=$(download_aspire_bundle "$workflow_run_id" "$rid" "$temp_dir"); then - return 1 - fi - - # Install bundle - if ! install_aspire_bundle "$download_dir" "$INSTALL_PREFIX"; then - return 1 - fi - - # Download and install NuGet hive packages (needed for 'aspire new' and 'aspire add') - local nuget_hive_dir="$INSTALL_PREFIX/hives/pr-$PR_NUMBER/packages" - local nuget_download_dir - if ! nuget_download_dir=$(download_built_nugets "$workflow_run_id" "$rid" "$temp_dir"); then - say_error "Failed to download NuGet packages" - return 1 - fi - - if ! install_built_nugets "$nuget_download_dir" "$nuget_hive_dir"; then - say_error "Failed to install NuGet packages" - return 1 - fi - - # Verify installation - local cli_path="$INSTALL_PREFIX/bin/aspire" - if [[ -f "$cli_path" && "$DRY_RUN" != true ]]; then - say_info "" - say_info "Verifying installation..." - if "$cli_path" --version >/dev/null 2>&1; then - say_success "Bundle verification passed!" - say_info "Installed version: $("$cli_path" --version 2>/dev/null || echo 'unknown')" - else - say_warn "Bundle verification failed - CLI may not work correctly" - fi - fi -} - -# ============================================================================= -# Main Execution -# ============================================================================= - -parse_args "$@" - -if [[ "$SHOW_HELP" == true ]]; then - show_help - exit 0 -fi - -HOST_OS=$(detect_os) - -if [[ "$HOST_OS" == "unsupported" ]]; then - say_error "Unsupported operating system: $(uname -s)" - exit 1 -fi - -check_gh_dependency - -# Set default install prefix if not provided -if [[ -z "$INSTALL_PREFIX" ]]; then - INSTALL_PREFIX="$HOME/.aspire" - INSTALL_PREFIX_UNEXPANDED="\$HOME/.aspire" -else - INSTALL_PREFIX_UNEXPANDED="$INSTALL_PREFIX" -fi - -# Validate install prefix contains only safe characters to prevent shell injection -# when writing to shell profile -if [[ ! "$INSTALL_PREFIX" =~ ^[a-zA-Z0-9/_.-]+$ ]] && [[ ! "$INSTALL_PREFIX" =~ ^\$HOME ]]; then - say_error "Install prefix contains invalid characters: $INSTALL_PREFIX" - say_info "Path must contain only alphanumeric characters, /, _, ., and -" - exit 1 -fi - -# Create temporary directory -if [[ "$DRY_RUN" == true ]]; then - temp_dir="/tmp/aspire-bundle-pr-dry-run" -else - temp_dir=$(mktemp -d -t aspire-bundle-pr-download-XXXXXX) - say_verbose "Creating temporary directory: $temp_dir" -fi - -# Set trap for cleanup -cleanup() { - remove_temp_dir "$temp_dir" -} -trap cleanup EXIT - -# Download and install bundle -if ! download_and_install_bundle "$temp_dir"; then - exit 1 -fi - -# Add to shell profile for persistent PATH (use bin/ subdirectory, same as CLI-only install) -if [[ "$SKIP_PATH" != true ]]; then - add_to_shell_profile "$INSTALL_PREFIX/bin" "$INSTALL_PREFIX_UNEXPANDED/bin" - - if [[ ":$PATH:" != *":$INSTALL_PREFIX/bin:"* ]]; then - if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would add $INSTALL_PREFIX/bin to PATH" - else - export PATH="$INSTALL_PREFIX/bin:$PATH" - fi - fi -fi - -# Save the global channel setting to the PR channel -# This allows 'aspire new' and 'aspire init' to use the same channel by default -cli_path="$INSTALL_PREFIX/bin/aspire" -if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would run: $cli_path config set -g channel pr-$PR_NUMBER" -else - say_verbose "Setting global config: channel = pr-$PR_NUMBER" - if "$cli_path" config set -g channel "pr-$PR_NUMBER" 2>/dev/null; then - say_verbose "Global config saved: channel = pr-$PR_NUMBER" - else - say_warn "Failed to set global channel config via aspire CLI (non-fatal)" - fi -fi - -say_info "" -say_success "============================================" -say_success " Aspire Bundle from PR #$PR_NUMBER Installed" -say_success "============================================" -say_info "" -say_info "Bundle location: $INSTALL_PREFIX" -say_info "" -say_info "To use:" -say_info " $INSTALL_PREFIX/bin/aspire --help" -say_info " $INSTALL_PREFIX/bin/aspire run" -say_info "" -say_info "The bundle includes everything needed to run Aspire apps" -say_info "without requiring a globally-installed .NET SDK." diff --git a/eng/scripts/install-aspire-bundle.ps1 b/eng/scripts/install-aspire-bundle.ps1 deleted file mode 100644 index 2b2e89ec528..00000000000 --- a/eng/scripts/install-aspire-bundle.ps1 +++ /dev/null @@ -1,414 +0,0 @@ -<# -.SYNOPSIS - Downloads and installs the Aspire Bundle (self-contained distribution). - -.DESCRIPTION - This script downloads and installs the Aspire Bundle, which includes everything - needed to run Aspire applications without a .NET SDK. - - NOTE: This script is different from get-aspire-cli-pr.ps1: - - install-aspire-bundle.ps1: Installs the full self-contained bundle (runtime, dashboard, DCP, etc.) - for polyglot development without requiring .NET SDK. - - get-aspire-cli-pr.ps1: Downloads just the CLI from a PR build for testing/development purposes. - - The bundle includes: - - - Aspire CLI (native AOT) - - .NET Runtime - - Aspire Dashboard - - Developer Control Plane (DCP) - - Pre-built AppHost Server - - NuGet Helper Tool - - This enables polyglot development (TypeScript, Python, Go, etc.) without - requiring a global .NET SDK installation. - -.PARAMETER InstallPath - Directory to install the bundle. Default: $env:LOCALAPPDATA\Aspire - -.PARAMETER Version - Specific version to install (e.g., "9.2.0"). Default: latest release. - -.PARAMETER Architecture - Architecture to install (x64, arm64). Default: auto-detect. - -.PARAMETER SkipPath - Do not add aspire to PATH environment variable. - -.PARAMETER Force - Overwrite existing installation. - -.PARAMETER DryRun - Show what would be done without installing. - -.PARAMETER Verbose - Enable verbose output. - -.EXAMPLE - .\install-aspire-bundle.ps1 - Installs the latest version to the default location. - -.EXAMPLE - .\install-aspire-bundle.ps1 -Version "9.2.0" - Installs a specific version. - -.EXAMPLE - .\install-aspire-bundle.ps1 -InstallPath "C:\Tools\Aspire" - Installs to a custom location. - -.EXAMPLE - iex ((New-Object System.Net.WebClient).DownloadString('https://aka.ms/install-aspire-bundle.ps1')) - Piped execution from URL. - -.NOTES - After installation, you may need to restart your terminal. - - To update an existing installation: - aspire update --self - - To uninstall: - Remove-Item -Recurse -Force "$env:LOCALAPPDATA\Aspire" -#> - -[CmdletBinding()] -param( - [string]$InstallPath = "", - [string]$Version = "", - [ValidateSet("x64", "arm64", "")] - [string]$Architecture = "", - [switch]$SkipPath, - [switch]$Force, - [switch]$DryRun -) - -$ErrorActionPreference = "Stop" -$ProgressPreference = "SilentlyContinue" # Speeds up Invoke-WebRequest - -# Constants -$ScriptVersion = "1.0.0" -$GitHubRepo = "dotnet/aspire" -$GitHubReleasesApi = "https://api.github.com/repos/$GitHubRepo/releases" -$UserAgent = "install-aspire-bundle.ps1/$ScriptVersion" - -# ═══════════════════════════════════════════════════════════════════════════════ -# LOGGING FUNCTIONS -# ═══════════════════════════════════════════════════════════════════════════════ - -function Write-Status { - param([string]$Message) - Write-Host "aspire-bundle: " -ForegroundColor Green -NoNewline - Write-Host $Message -} - -function Write-Info { - param([string]$Message) - Write-Host "aspire-bundle: " -ForegroundColor Cyan -NoNewline - Write-Host $Message -} - -function Write-Warn { - param([string]$Message) - Write-Host "aspire-bundle: WARNING: " -ForegroundColor Yellow -NoNewline - Write-Host $Message -} - -function Write-Err { - param([string]$Message) - Write-Host "aspire-bundle: ERROR: " -ForegroundColor Red -NoNewline - Write-Host $Message -} - -function Write-Verbose-Log { - param([string]$Message) - if ($VerbosePreference -eq "Continue") { - Write-Host "aspire-bundle: [VERBOSE] " -ForegroundColor DarkGray -NoNewline - Write-Host $Message -ForegroundColor DarkGray - } -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# PLATFORM DETECTION -# ═══════════════════════════════════════════════════════════════════════════════ - -function Get-Architecture { - if ($Architecture) { - Write-Verbose-Log "Using specified architecture: $Architecture" - return $Architecture - } - - $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture - switch ($arch) { - "X64" { return "x64" } - "Arm64" { return "arm64" } - default { - Write-Err "Unsupported architecture: $arch" - exit 1 - } - } -} - -function Get-PlatformRid { - $arch = Get-Architecture - return "win-$arch" -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# VERSION RESOLUTION -# ═══════════════════════════════════════════════════════════════════════════════ - -function Get-LatestVersion { - Write-Verbose-Log "Querying GitHub for latest release..." - - try { - $headers = @{ - "User-Agent" = $UserAgent - "Accept" = "application/vnd.github+json" - } - - $response = Invoke-RestMethod -Uri "$GitHubReleasesApi/latest" -Headers $headers -TimeoutSec 30 - $tagName = $response.tag_name - - if (-not $tagName) { - Write-Err "Could not determine latest version from GitHub" - exit 1 - } - - # Remove 'v' prefix if present - $version = $tagName -replace "^v", "" - Write-Verbose-Log "Latest version: $version" - return $version - } - catch { - Write-Err "Failed to query GitHub releases API: $_" - exit 1 - } -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# DOWNLOAD AND INSTALLATION -# ═══════════════════════════════════════════════════════════════════════════════ - -function Get-DownloadUrl { - param([string]$Ver) - - $rid = Get-PlatformRid - $filename = "aspire-bundle-$Ver-$rid.zip" - return "https://github.com/$GitHubRepo/releases/download/v$Ver/$filename" -} - -function Download-Bundle { - param( - [string]$Url, - [string]$OutputPath - ) - - $rid = Get-PlatformRid - Write-Status "Downloading Aspire Bundle v$Version for $rid..." - Write-Verbose-Log "URL: $Url" - - if ($DryRun) { - Write-Info "[DRY RUN] Would download: $Url" - return - } - - try { - $headers = @{ "User-Agent" = $UserAgent } - Invoke-WebRequest -Uri $Url -OutFile $OutputPath -Headers $headers -TimeoutSec 600 -UseBasicParsing - Write-Verbose-Log "Download complete: $OutputPath" - } - catch { - Write-Err "Failed to download bundle from: $Url" - Write-Host "" - Write-Info "Possible causes:" - Write-Info " - Version $Version may not have a bundle release yet" - Write-Info " - Platform $rid may not be supported" - Write-Info " - Network connectivity issues" - Write-Host "" - Write-Info "Check available releases at:" - Write-Info " https://github.com/$GitHubRepo/releases" - exit 1 - } -} - -function Extract-Bundle { - param( - [string]$ArchivePath, - [string]$DestPath - ) - - Write-Status "Extracting bundle to $DestPath..." - - if ($DryRun) { - Write-Info "[DRY RUN] Would extract to: $DestPath" - return - } - - # Create destination directory - if (-not (Test-Path $DestPath)) { - New-Item -ItemType Directory -Path $DestPath -Force | Out-Null - } - - try { - Expand-Archive -Path $ArchivePath -DestinationPath $DestPath -Force - Write-Verbose-Log "Extraction complete" - } - catch { - Write-Err "Failed to extract bundle archive: $_" - exit 1 - } -} - -function Verify-Installation { - param([string]$InstallDir) - - $cliPath = Join-Path $InstallDir "aspire.exe" - - if (-not (Test-Path $cliPath)) { - Write-Err "Installation verification failed: CLI not found" - exit 1 - } - - try { - $versionOutput = & $cliPath --version 2>&1 - Write-Verbose-Log "Installed version: $versionOutput" - } - catch { - Write-Warn "Could not verify CLI version" - } -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# PATH CONFIGURATION -# ═══════════════════════════════════════════════════════════════════════════════ - -function Configure-Path { - param([string]$InstallDir) - - if ($SkipPath) { - Write-Verbose-Log "Skipping PATH configuration (-SkipPath specified)" - return - } - - if ($DryRun) { - Write-Info "[DRY RUN] Would add to PATH: $InstallDir" - return - } - - # Check if already in PATH - $currentPath = [Environment]::GetEnvironmentVariable("PATH", "User") - if ($currentPath -split ";" | Where-Object { $_ -eq $InstallDir }) { - Write-Verbose-Log "Install directory already in PATH" - return - } - - # Add to user PATH - $newPath = "$InstallDir;$currentPath" - [Environment]::SetEnvironmentVariable("PATH", $newPath, "User") - - # Update current session - $env:PATH = "$InstallDir;$env:PATH" - - # GitHub Actions support - if ($env:GITHUB_PATH) { - Add-Content -Path $env:GITHUB_PATH -Value $InstallDir - Write-Verbose-Log "Added to GITHUB_PATH for CI" - } - - Write-Info "Added $InstallDir to user PATH" -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# MAIN -# ═══════════════════════════════════════════════════════════════════════════════ - -function Main { - Write-Status "Aspire Bundle Installer v$ScriptVersion" - Write-Host "" - - # Set defaults - if (-not $InstallPath) { - $InstallPath = if ($env:ASPIRE_INSTALL_PATH) { - $env:ASPIRE_INSTALL_PATH - } else { - Join-Path $env:LOCALAPPDATA "Aspire" - } - } - - if (-not $Version) { - $Version = if ($env:ASPIRE_BUNDLE_VERSION) { - $env:ASPIRE_BUNDLE_VERSION - } else { - Get-LatestVersion - } - } - - $rid = Get-PlatformRid - - Write-Info "Version: $Version" - Write-Info "Platform: $rid" - Write-Info "Install path: $InstallPath" - Write-Host "" - - # Check for existing installation - $cliPath = Join-Path $InstallPath "aspire.exe" - if ((Test-Path $cliPath) -and -not $Force -and -not $DryRun) { - Write-Warn "Aspire is already installed at $InstallPath" - Write-Info "Use -Force to overwrite, or run 'aspire update --self' to update" - exit 1 - } - - # Create temp directory - $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-bundle-$([Guid]::NewGuid().ToString('N'))" - New-Item -ItemType Directory -Path $tempDir -Force | Out-Null - - try { - $archivePath = Join-Path $tempDir "aspire-bundle.zip" - $downloadUrl = Get-DownloadUrl -Ver $Version - - # Download - Download-Bundle -Url $downloadUrl -OutputPath $archivePath - - # Remove existing installation if -Force - if ((Test-Path $InstallPath) -and $Force -and -not $DryRun) { - Write-Verbose-Log "Removing existing installation..." - Remove-Item -Path $InstallPath -Recurse -Force - } - - # Extract - Extract-Bundle -ArchivePath $archivePath -DestPath $InstallPath - - # Verify - if (-not $DryRun) { - Verify-Installation -InstallDir $InstallPath - } - - # Configure PATH - Configure-Path -InstallDir $InstallPath - - Write-Host "" - Write-Host "aspire-bundle: " -ForegroundColor Green -NoNewline - Write-Host "✓ " -ForegroundColor Green -NoNewline - Write-Host "Aspire Bundle v$Version installed successfully!" - Write-Host "" - - if ($SkipPath) { - Write-Info "To use aspire, add to your PATH:" - Write-Info " `$env:PATH = `"$InstallPath;`$env:PATH`"" - } else { - Write-Info "You may need to restart your terminal for PATH changes to take effect." - } - Write-Host "" - Write-Info "Get started:" - Write-Info " aspire new" - Write-Info " aspire run" - Write-Host "" - } - finally { - # Cleanup temp directory - if (Test-Path $tempDir) { - Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue - } - } -} - -Main diff --git a/eng/scripts/install-aspire-bundle.sh b/eng/scripts/install-aspire-bundle.sh deleted file mode 100644 index 2220b507f2b..00000000000 --- a/eng/scripts/install-aspire-bundle.sh +++ /dev/null @@ -1,609 +0,0 @@ -#!/usr/bin/env bash - -# install-aspire-bundle.sh - Download and install the Aspire Bundle (self-contained distribution) -# Usage: ./install-aspire-bundle.sh [OPTIONS] -# curl -sSL /install-aspire-bundle.sh | bash -s -- [OPTIONS] - -set -euo pipefail - -# Global constants -readonly SCRIPT_VERSION="1.0.0" -readonly USER_AGENT="install-aspire-bundle.sh/${SCRIPT_VERSION}" -readonly DOWNLOAD_TIMEOUT_SEC=600 -readonly GITHUB_REPO="dotnet/aspire" -readonly GITHUB_RELEASES_API="https://api.github.com/repos/${GITHUB_REPO}/releases" - -# Colors for output -readonly RED='\033[0;31m' -readonly GREEN='\033[0;32m' -readonly YELLOW='\033[1;33m' -readonly BLUE='\033[0;34m' -readonly RESET='\033[0m' - -# Default values -INSTALL_PATH="" -VERSION="" -OS="" -ARCH="" -SHOW_HELP=false -VERBOSE=false -DRY_RUN=false -SKIP_PATH=false -FORCE=false - -# ═══════════════════════════════════════════════════════════════════════════════ -# LOGGING FUNCTIONS -# ═══════════════════════════════════════════════════════════════════════════════ - -say() { - echo -e "${GREEN}aspire-bundle:${RESET} $*" -} - -say_info() { - echo -e "${BLUE}aspire-bundle:${RESET} $*" -} - -say_warning() { - echo -e "${YELLOW}aspire-bundle: WARNING:${RESET} $*" >&2 -} - -say_error() { - echo -e "${RED}aspire-bundle: ERROR:${RESET} $*" >&2 -} - -say_verbose() { - if [[ "$VERBOSE" == true ]]; then - echo -e "${BLUE}aspire-bundle: [VERBOSE]${RESET} $*" - fi -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# HELP -# ═══════════════════════════════════════════════════════════════════════════════ - -show_help() { - cat << 'EOF' -Aspire Bundle Installation Script - -DESCRIPTION: - Downloads and installs the Aspire Bundle - a self-contained distribution that - includes everything needed to run Aspire applications without a .NET SDK: - - • Aspire CLI (native AOT) - • .NET Runtime - • Aspire Dashboard - • Developer Control Plane (DCP) - • Pre-built AppHost Server - • NuGet Helper Tool - - This enables polyglot development (TypeScript, Python, Go, etc.) without - requiring a global .NET SDK installation. - -USAGE: - ./install-aspire-bundle.sh [OPTIONS] - -OPTIONS: - -i, --install-path PATH Directory to install the bundle - Default: $HOME/.aspire - --version VERSION Specific version to install (e.g., "9.2.0") - Default: latest release - --os OS Operating system (linux, osx) - Default: auto-detect - --arch ARCH Architecture (x64, arm64) - Default: auto-detect - --skip-path Do not add aspire to PATH - --force Overwrite existing installation - --dry-run Show what would be done without installing - -v, --verbose Enable verbose output - -h, --help Show this help message - -EXAMPLES: - # Install latest version - ./install-aspire-bundle.sh - - # Install specific version - ./install-aspire-bundle.sh --version "9.2.0" - - # Install to custom location - ./install-aspire-bundle.sh --install-path "/opt/aspire" - - # Piped execution - curl -sSL https://aka.ms/install-aspire-bundle.sh | bash - curl -sSL https://aka.ms/install-aspire-bundle.sh | bash -s -- --version "9.2.0" - -ENVIRONMENT VARIABLES: - ASPIRE_INSTALL_PATH Default installation path - ASPIRE_BUNDLE_VERSION Default version to install - -NOTES: - After installation, you may need to restart your shell or run: - source ~/.bashrc (or ~/.zshrc) - - To update an existing installation: - aspire update --self - - To uninstall: - rm -rf ~/.aspire - -EOF -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# ARGUMENT PARSING -# ═══════════════════════════════════════════════════════════════════════════════ - -parse_args() { - while [[ $# -gt 0 ]]; do - case $1 in - -i|--install-path) - if [[ $# -lt 2 || -z "$2" ]]; then - say_error "Option '$1' requires a non-empty value" - exit 1 - fi - INSTALL_PATH="$2" - shift 2 - ;; - --version) - if [[ $# -lt 2 || -z "$2" ]]; then - say_error "Option '$1' requires a non-empty value" - exit 1 - fi - VERSION="$2" - shift 2 - ;; - --os) - if [[ $# -lt 2 || -z "$2" ]]; then - say_error "Option '$1' requires a non-empty value" - exit 1 - fi - OS="$2" - shift 2 - ;; - --arch) - if [[ $# -lt 2 || -z "$2" ]]; then - say_error "Option '$1' requires a non-empty value" - exit 1 - fi - ARCH="$2" - shift 2 - ;; - --skip-path) - SKIP_PATH=true - shift - ;; - --force) - FORCE=true - shift - ;; - --dry-run) - DRY_RUN=true - shift - ;; - -v|--verbose) - VERBOSE=true - shift - ;; - -h|--help) - SHOW_HELP=true - shift - ;; - *) - say_error "Unknown option: $1" - say_info "Use --help for usage information." - exit 1 - ;; - esac - done -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# PLATFORM DETECTION -# ═══════════════════════════════════════════════════════════════════════════════ - -# Supported RIDs for the bundle -readonly SUPPORTED_RIDS="linux-x64 linux-arm64 osx-x64 osx-arm64" - -detect_os() { - if [[ -n "$OS" ]]; then - say_verbose "Using specified OS: $OS" - return - fi - - local uname_out - uname_out="$(uname -s)" - - case "$uname_out" in - Linux*) - OS="linux" - ;; - Darwin*) - OS="osx" - ;; - *) - say_error "Unsupported operating system: $uname_out" - say_info "For Windows, use install-aspire-bundle.ps1" - exit 1 - ;; - esac - - say_verbose "Detected OS: $OS" -} - -detect_arch() { - if [[ -n "$ARCH" ]]; then - say_verbose "Using specified architecture: $ARCH" - return - fi - - local uname_arch - uname_arch="$(uname -m)" - - case "$uname_arch" in - x86_64|amd64) - ARCH="x64" - ;; - aarch64|arm64) - ARCH="arm64" - ;; - *) - say_error "Unsupported architecture: $uname_arch" - exit 1 - ;; - esac - - say_verbose "Detected architecture: $ARCH" -} - -get_platform_rid() { - echo "${OS}-${ARCH}" -} - -validate_rid() { - local rid="$1" - - # Check if the RID is in the supported list - if ! echo "$SUPPORTED_RIDS" | grep -qw "$rid"; then - say_error "Unsupported platform: $rid" - say_info "" - say_info "The Aspire Bundle is currently available for:" - for supported_rid in $SUPPORTED_RIDS; do - say_info " • $supported_rid" - done - say_info "" - say_info "If you need support for $rid, please open an issue at:" - say_info " https://github.com/${GITHUB_REPO}/issues" - exit 1 - fi -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# VERSION RESOLUTION -# ═══════════════════════════════════════════════════════════════════════════════ - -get_latest_version() { - say_verbose "Querying GitHub for latest release..." - - local response - response=$(curl -sSL --fail \ - -H "User-Agent: ${USER_AGENT}" \ - -H "Accept: application/vnd.github+json" \ - "${GITHUB_RELEASES_API}/latest" 2>/dev/null) || { - say_error "Failed to query GitHub releases API" - exit 1 - } - - local tag_name - tag_name=$(echo "$response" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4) - - if [[ -z "$tag_name" ]]; then - say_error "Could not determine latest version from GitHub" - exit 1 - fi - - # Remove 'v' prefix if present - VERSION="${tag_name#v}" - say_verbose "Latest version: $VERSION" -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# DOWNLOAD AND INSTALLATION -# ═══════════════════════════════════════════════════════════════════════════════ - -get_download_url() { - local rid - rid=$(get_platform_rid) - - # Bundle filename pattern: aspire-bundle-{version}-{rid}.tar.gz - local filename="aspire-bundle-${VERSION}-${rid}.tar.gz" - - echo "https://github.com/${GITHUB_REPO}/releases/download/v${VERSION}/${filename}" -} - -download_bundle() { - local url="$1" - local output="$2" - - say "Downloading Aspire Bundle v${VERSION} for $(get_platform_rid)..." - say_verbose "URL: $url" - - if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would download: $url" - return 0 - fi - - local http_code - http_code=$(curl -sSL --fail \ - -H "User-Agent: ${USER_AGENT}" \ - -w "%{http_code}" \ - --connect-timeout 30 \ - --max-time "${DOWNLOAD_TIMEOUT_SEC}" \ - -o "$output" \ - "$url" 2>/dev/null) || { - say_error "Failed to download bundle from: $url" - say_info "HTTP status: $http_code" - say_info "" - say_info "Possible causes:" - say_info " • Version ${VERSION} may not have a bundle release yet" - say_info " • Platform $(get_platform_rid) may not be supported" - say_info " • Network connectivity issues" - say_info "" - say_info "Check available releases at:" - say_info " https://github.com/${GITHUB_REPO}/releases" - exit 1 - } - - say_verbose "Download complete: $output" -} - -extract_bundle() { - local archive="$1" - local dest="$2" - - say "Extracting bundle to ${dest}..." - - if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would extract to: $dest" - return 0 - fi - - # Create destination directory - mkdir -p "$dest" - - # Extract tarball - tar -xzf "$archive" -C "$dest" --strip-components=1 || { - say_error "Failed to extract bundle archive" - exit 1 - } - - # Make executables executable (permissions may not be preserved in archive) - chmod +x "${dest}/aspire" 2>/dev/null || true - - # Make .NET runtime executable - if [[ -f "${dest}/runtime/dotnet" ]]; then - chmod +x "${dest}/runtime/dotnet" - fi - - # Make DCP executable - if [[ -f "${dest}/dcp/dcp" ]]; then - chmod +x "${dest}/dcp/dcp" - fi - - # Make Dashboard executable - if [[ -f "${dest}/dashboard/Aspire.Dashboard" ]]; then - chmod +x "${dest}/dashboard/Aspire.Dashboard" - fi - - # Make AppHost Server executable - if [[ -f "${dest}/aspire-server/aspire-server" ]]; then - chmod +x "${dest}/aspire-server/aspire-server" - fi - - # Make all tools executable - if [[ -d "${dest}/tools" ]]; then - find "${dest}/tools" -type f -exec chmod +x {} \; 2>/dev/null || true - fi - - say_verbose "Extraction complete" -} - -verify_installation() { - local install_dir="$1" - local cli_path="${install_dir}/aspire" - - if [[ ! -x "$cli_path" ]]; then - say_error "Installation verification failed: CLI not found or not executable" - exit 1 - fi - - # Try to run aspire --version - local version_output - version_output=$("$cli_path" --version 2>/dev/null) || { - say_warning "Could not verify CLI version" - return 0 - } - - say_verbose "Installed version: $version_output" -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# PATH CONFIGURATION -# ═══════════════════════════════════════════════════════════════════════════════ - -configure_path() { - local install_dir="$1" - - if [[ "$SKIP_PATH" == true ]]; then - say_verbose "Skipping PATH configuration (--skip-path specified)" - return 0 - fi - - if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would add to PATH: $install_dir" - return 0 - fi - - # Check if already in PATH - if [[ ":$PATH:" == *":${install_dir}:"* ]]; then - say_verbose "Install directory already in PATH" - return 0 - fi - - # Detect shell config file - local shell_config="" - local shell_name="${SHELL##*/}" - - case "$shell_name" in - bash) - if [[ -f "$HOME/.bashrc" ]]; then - shell_config="$HOME/.bashrc" - elif [[ -f "$HOME/.bash_profile" ]]; then - shell_config="$HOME/.bash_profile" - fi - ;; - zsh) - shell_config="$HOME/.zshrc" - ;; - fish) - shell_config="$HOME/.config/fish/config.fish" - ;; - esac - - if [[ -z "$shell_config" ]]; then - say_warning "Could not detect shell config file" - say_info "Add this to your shell profile:" - say_info " export PATH=\"${install_dir}:\$PATH\"" - return 0 - fi - - # Check if export already exists - if grep -q "export PATH=.*${install_dir}" "$shell_config" 2>/dev/null; then - say_verbose "PATH export already exists in $shell_config" - return 0 - fi - - # Add to shell config - say_verbose "Adding to $shell_config" - echo "" >> "$shell_config" - echo "# Aspire CLI" >> "$shell_config" - echo "export PATH=\"${install_dir}:\$PATH\"" >> "$shell_config" - - # Update current session PATH - export PATH="${install_dir}:$PATH" - - # Check for GitHub Actions - if [[ -n "${GITHUB_PATH:-}" ]]; then - echo "$install_dir" >> "$GITHUB_PATH" - say_verbose "Added to GITHUB_PATH for CI" - fi - - say_info "Added ${install_dir} to PATH in ${shell_config}" -} - -# ═══════════════════════════════════════════════════════════════════════════════ -# MAIN -# ═══════════════════════════════════════════════════════════════════════════════ - -main() { - parse_args "$@" - - if [[ "$SHOW_HELP" == true ]]; then - show_help - exit 0 - fi - - say "Aspire Bundle Installer v${SCRIPT_VERSION}" - echo "" - - # Detect platform - detect_os - detect_arch - - # Validate the RID is supported - validate_rid "$(get_platform_rid)" - - # Set defaults - if [[ -z "$INSTALL_PATH" ]]; then - INSTALL_PATH="${ASPIRE_INSTALL_PATH:-$HOME/.aspire}" - fi - - if [[ -z "$VERSION" ]]; then - VERSION="${ASPIRE_BUNDLE_VERSION:-}" - if [[ -z "$VERSION" ]]; then - get_latest_version - fi - fi - - # Expand ~ in install path - INSTALL_PATH="${INSTALL_PATH/#\~/$HOME}" - - # Validate install path contains only safe characters to prevent shell injection - if [[ ! "$INSTALL_PATH" =~ ^[a-zA-Z0-9/_.-]+$ ]]; then - say_error "Install path contains invalid characters: $INSTALL_PATH" - say_info "Path must contain only alphanumeric characters, /, _, ., and -" - exit 1 - fi - - say_info "Version: ${VERSION}" - say_info "Platform: $(get_platform_rid)" - say_info "Install path: ${INSTALL_PATH}" - echo "" - - # Check for existing installation - if [[ -d "$INSTALL_PATH" && "$FORCE" != true && "$DRY_RUN" != true ]]; then - if [[ -f "${INSTALL_PATH}/aspire" ]]; then - say_warning "Aspire is already installed at ${INSTALL_PATH}" - say_info "Use --force to overwrite, or run 'aspire update --self' to update" - exit 1 - fi - fi - - # Create temp directory - local temp_dir - temp_dir=$(mktemp -d) - trap "rm -rf '$temp_dir'" EXIT - - local archive_path="${temp_dir}/aspire-bundle.tar.gz" - local download_url - download_url=$(get_download_url) - - # Download - download_bundle "$download_url" "$archive_path" - - # Extract - if [[ "$DRY_RUN" != true ]]; then - # Remove existing installation if --force - if [[ -d "$INSTALL_PATH" && "$FORCE" == true ]]; then - say_verbose "Removing existing installation..." - rm -rf "$INSTALL_PATH" - fi - fi - - extract_bundle "$archive_path" "$INSTALL_PATH" - - # Verify - if [[ "$DRY_RUN" != true ]]; then - verify_installation "$INSTALL_PATH" - fi - - # Configure PATH - configure_path "$INSTALL_PATH" - - echo "" - say "${GREEN}✓${RESET} Aspire Bundle v${VERSION} installed successfully!" - echo "" - - if [[ "$SKIP_PATH" == true ]]; then - say_info "To use aspire, add to your PATH:" - say_info " export PATH=\"${INSTALL_PATH}:\$PATH\"" - else - say_info "You may need to restart your shell or run:" - say_info " source ~/.bashrc (or ~/.zshrc)" - fi - echo "" - say_info "Get started:" - say_info " aspire new" - say_info " aspire run" - echo "" -} - -main "$@" diff --git a/localhive.ps1 b/localhive.ps1 index 0cce5d158af..14eaf3de1df 100644 --- a/localhive.ps1 +++ b/localhive.ps1 @@ -2,12 +2,13 @@ <#! .SYNOPSIS - Build local NuGet packages and Aspire CLI, then create/update a hive and install the CLI (Windows/PowerShell). + Build local NuGet packages, Aspire CLI, and bundle, then create/update a hive and install everything (Windows/PowerShell). .DESCRIPTION Mirrors localhive.sh behavior on Windows. Packs the repo, creates a symlink from $HOME/.aspire/hives/ to artifacts/packages//Shipping (or copies .nupkg files), - and installs the locally-built Aspire CLI to $HOME/.aspire/bin. + installs the locally-built Aspire CLI to $HOME/.aspire/bin, and builds/installs the bundle + (aspire-managed + DCP) to $HOME/.aspire so the CLI can auto-discover it. .PARAMETER Configuration Build configuration: Release or Debug (positional parameter 0). If omitted, the script tries Release then falls back to Debug. @@ -24,6 +25,12 @@ .PARAMETER SkipCli Skip installing the locally-built CLI to $HOME/.aspire/bin. +.PARAMETER SkipBundle + Skip building and installing the bundle (aspire-managed + DCP) to $HOME/.aspire. + +.PARAMETER NativeAot + Build and install the native AOT CLI (self-extracting binary with embedded bundle) instead of the dotnet tool version. + .PARAMETER Help Show help and exit. @@ -58,6 +65,10 @@ param( [switch] $SkipCli, + [switch] $SkipBundle, + + [switch] $NativeAot, + [Alias('h')] [switch] $Help ) @@ -80,6 +91,8 @@ Options: -VersionSuffix (-v) Prerelease version suffix (default: auto-generates local.YYYYMMDD.tHHmmss) -Copy Copy .nupkg files instead of creating a symlink -SkipCli Skip installing the locally-built CLI to $HOME\.aspire\bin + -SkipBundle Skip building and installing the bundle (aspire-managed + DCP) + -NativeAot Build native AOT CLI (self-extracting with embedded bundle) -Help (-h) Show this help and exit Examples: @@ -155,9 +168,12 @@ function Get-PackagesPath { $effectiveConfig = if ($Configuration) { $Configuration } else { 'Release' } +# Skip native AOT during pack unless user will build it separately via -NativeAot + Bundle.proj +$aotArg = if (-not $NativeAot) { "/p:PublishAot=false" } else { "" } + if ($Configuration) { Write-Log "Building and packing NuGet packages [-c $Configuration] with versionsuffix '$VersionSuffix'" - & $buildScript -restore -build -pack -c $Configuration "/p:VersionSuffix=$VersionSuffix" "/p:SkipTestProjects=true" "/p:SkipPlaygroundProjects=true" + & $buildScript -restore -build -pack -c $Configuration "/p:VersionSuffix=$VersionSuffix" "/p:SkipTestProjects=true" "/p:SkipPlaygroundProjects=true" $aotArg if ($LASTEXITCODE -ne 0) { Write-Err "Build failed for configuration $Configuration." exit 1 @@ -170,7 +186,7 @@ if ($Configuration) { } else { Write-Log "Building and packing NuGet packages [-c Release] with versionsuffix '$VersionSuffix'" - & $buildScript -restore -build -pack -c Release "/p:VersionSuffix=$VersionSuffix" "/p:SkipTestProjects=true" "/p:SkipPlaygroundProjects=true" + & $buildScript -restore -build -pack -c Release "/p:VersionSuffix=$VersionSuffix" "/p:SkipTestProjects=true" "/p:SkipPlaygroundProjects=true" $aotArg if ($LASTEXITCODE -ne 0) { Write-Err "Build failed for configuration Release." exit 1 @@ -236,22 +252,81 @@ else { } } -# Install the locally-built CLI to $HOME/.aspire/bin -if (-not $SkipCli) { - $cliBinDir = Join-Path (Join-Path $HOME '.aspire') 'bin' - # The CLI is built as part of the pack target in artifacts/bin/Aspire.Cli.Tool//net10.0/publish - $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0" "publish" +# Determine the RID for the current platform +if ($IsWindows) { + $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'win-arm64' } else { 'win-x64' } +} elseif ($IsMacOS) { + $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'osx-arm64' } else { 'osx-x64' } +} else { + $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'linux-arm64' } else { 'linux-x64' } +} + +$aspireRoot = Join-Path $HOME '.aspire' +$cliBinDir = Join-Path $aspireRoot 'bin' + +# Build the bundle (aspire-managed + DCP, and optionally native AOT CLI) +if (-not $SkipBundle) { + $bundleProjPath = Join-Path $RepoRoot "eng" "Bundle.proj" + $skipNativeArg = if ($NativeAot) { '' } else { '/p:SkipNativeBuild=true' } + + Write-Log "Building bundle (aspire-managed + DCP$(if ($NativeAot) { ' + native AOT CLI' }))..." + $buildArgs = @($bundleProjPath, '-c', $effectiveConfig, "/p:VersionSuffix=$VersionSuffix") + if (-not $NativeAot) { + $buildArgs += '/p:SkipNativeBuild=true' + } + & dotnet build @buildArgs + if ($LASTEXITCODE -ne 0) { + Write-Err "Bundle build failed." + exit 1 + } - if (-not (Test-Path -LiteralPath $cliPublishDir)) { - # Fallback: try the non-publish directory - $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0" + $bundleLayoutDir = Join-Path $RepoRoot "artifacts" "bundle" $bundleRid + + if (-not (Test-Path -LiteralPath $bundleLayoutDir)) { + Write-Err "Bundle layout not found at $bundleLayoutDir" + exit 1 + } + + # Copy managed/ and dcp/ to $HOME/.aspire so the CLI auto-discovers them + foreach ($component in @('managed', 'dcp')) { + $sourceDir = Join-Path $bundleLayoutDir $component + $destDir = Join-Path $aspireRoot $component + if (Test-Path -LiteralPath $sourceDir) { + if (Test-Path -LiteralPath $destDir) { + Remove-Item -LiteralPath $destDir -Force -Recurse + } + Write-Log "Copying $component/ to $destDir" + Copy-Item -LiteralPath $sourceDir -Destination $destDir -Recurse -Force + } else { + Write-Warn "$component/ not found in bundle layout at $sourceDir" + } } + Write-Log "Bundle installed to $aspireRoot (managed/ + dcp/)" +} + +# Install the CLI to $HOME/.aspire/bin +if (-not $SkipCli) { $cliExeName = if ($IsWindows) { 'aspire.exe' } else { 'aspire' } + + if ($NativeAot) { + # Native AOT CLI is produced by Bundle.proj's _PublishNativeCli target + $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli" $effectiveConfig "net10.0" $bundleRid "native" + if (-not (Test-Path -LiteralPath $cliPublishDir)) { + $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli" $effectiveConfig "net10.0" $bundleRid "publish" + } + } else { + # Framework-dependent CLI from dotnet tool build + $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0" "publish" + if (-not (Test-Path -LiteralPath $cliPublishDir)) { + $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0" + } + } + $cliSourcePath = Join-Path $cliPublishDir $cliExeName if (Test-Path -LiteralPath $cliSourcePath) { - Write-Log "Installing Aspire CLI to $cliBinDir" + Write-Log "Installing Aspire CLI$(if ($NativeAot) { ' (native AOT)' }) to $cliBinDir" New-Item -ItemType Directory -Path $cliBinDir -Force | Out-Null # Copy all files from the publish directory (CLI and its dependencies) @@ -286,4 +361,9 @@ if (-not $SkipCli) { Write-Log "The locally-built CLI was installed to: $(Join-Path (Join-Path $HOME '.aspire') 'bin')" Write-Host } +if (-not $SkipBundle) { + Write-Log "Bundle (aspire-managed + DCP) installed to: $(Join-Path $HOME '.aspire')" + Write-Log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + Write-Host +} Write-Log 'The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required.' diff --git a/localhive.sh b/localhive.sh index 40c08fcc6e3..ef16006d044 100755 --- a/localhive.sh +++ b/localhive.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Build local NuGet packages and Aspire CLI, then create/update a hive and install the CLI. +# Build local NuGet packages, Aspire CLI, and bundle, then create/update a hive and install everything. # # Usage: # ./localhive.sh [options] @@ -12,6 +12,8 @@ # -v, --versionsuffix Prerelease version suffix (default: auto-generates local.YYYYMMDD.tHHmmss) # --copy Copy .nupkg files instead of creating a symlink # --skip-cli Skip installing the locally-built CLI to $HOME/.aspire/bin +# --skip-bundle Skip building and installing the bundle (aspire-managed + DCP) +# --native-aot Build native AOT CLI (self-extracting with embedded bundle) # -h, --help Show this help and exit # # Notes: @@ -33,6 +35,8 @@ Options: -v, --versionsuffix Prerelease version suffix (default: auto-generates local.YYYYMMDD.tHHmmss) --copy Copy .nupkg files instead of creating a symlink --skip-cli Skip installing the locally-built CLI to \$HOME/.aspire/bin + --skip-bundle Skip building and installing the bundle (aspire-managed + DCP) + --native-aot Build native AOT CLI (self-extracting with embedded bundle) -h, --help Show this help and exit Examples: @@ -71,6 +75,8 @@ CONFIG="" HIVE_NAME="local" USE_COPY=0 SKIP_CLI=0 +SKIP_BUNDLE=0 +NATIVE_AOT=0 VERSION_SUFFIX="" is_valid_versionsuffix() { local s="$1" @@ -109,6 +115,10 @@ while [[ $# -gt 0 ]]; do USE_COPY=1; shift ;; --skip-cli) SKIP_CLI=1; shift ;; + --skip-bundle) + SKIP_BUNDLE=1; shift ;; + --native-aot) + NATIVE_AOT=1; shift ;; --) shift; break ;; Release|Debug|release|debug) @@ -146,10 +156,16 @@ log "Using prerelease version suffix: $VERSION_SUFFIX" # Track effective configuration EFFECTIVE_CONFIG="${CONFIG:-Release}" +# Skip native AOT during pack unless user will build it separately via --native-aot + Bundle.proj +AOT_ARG="" +if [[ $NATIVE_AOT -eq 0 ]]; then + AOT_ARG="/p:PublishAot=false" +fi + if [ -n "$CONFIG" ]; then log "Building and packing NuGet packages [-c $CONFIG] with versionsuffix '$VERSION_SUFFIX'" # Single invocation: restore + build + pack to ensure all Build-triggered targets run and packages are produced. - "$REPO_ROOT/build.sh" --restore --build --pack -c "$CONFIG" /p:VersionSuffix="$VERSION_SUFFIX" /p:SkipTestProjects=true /p:SkipPlaygroundProjects=true + "$REPO_ROOT/build.sh" --restore --build --pack -c "$CONFIG" /p:VersionSuffix="$VERSION_SUFFIX" /p:SkipTestProjects=true /p:SkipPlaygroundProjects=true $AOT_ARG PKG_DIR="$REPO_ROOT/artifacts/packages/$CONFIG/Shipping" if [ ! -d "$PKG_DIR" ]; then error "Could not find packages path $PKG_DIR for CONFIG=$CONFIG" @@ -157,7 +173,7 @@ if [ -n "$CONFIG" ]; then fi else log "Building and packing NuGet packages [-c Release] with versionsuffix '$VERSION_SUFFIX'" - "$REPO_ROOT/build.sh" --restore --build --pack -c Release /p:VersionSuffix="$VERSION_SUFFIX" /p:SkipTestProjects=true /p:SkipPlaygroundProjects=true + "$REPO_ROOT/build.sh" --restore --build --pack -c Release /p:VersionSuffix="$VERSION_SUFFIX" /p:SkipTestProjects=true /p:SkipPlaygroundProjects=true $AOT_ARG PKG_DIR="$REPO_ROOT/artifacts/packages/Release/Shipping" if [ ! -d "$PKG_DIR" ]; then error "Could not find packages path $PKG_DIR for CONFIG=Release" @@ -207,21 +223,92 @@ else fi fi -# Install the locally-built CLI to $HOME/.aspire/bin -if [[ $SKIP_CLI -eq 0 ]]; then - CLI_BIN_DIR="$HOME/.aspire/bin" - # The CLI is built as part of the pack target in artifacts/bin/Aspire.Cli.Tool//net10.0/publish - CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0/publish" +# Determine the RID for the current platform +ARCH=$(uname -m) +case "$(uname -s)" in + Darwin) + if [[ "$ARCH" == "arm64" ]]; then BUNDLE_RID="osx-arm64"; else BUNDLE_RID="osx-x64"; fi + ;; + Linux) + if [[ "$ARCH" == "aarch64" ]]; then BUNDLE_RID="linux-arm64"; else BUNDLE_RID="linux-x64"; fi + ;; + *) + BUNDLE_RID="linux-x64" + ;; +esac + +ASPIRE_ROOT="$HOME/.aspire" +CLI_BIN_DIR="$ASPIRE_ROOT/bin" + +# Build the bundle (aspire-managed + DCP, and optionally native AOT CLI) +if [[ $SKIP_BUNDLE -eq 0 ]]; then + BUNDLE_PROJ="$REPO_ROOT/eng/Bundle.proj" + + if [[ $NATIVE_AOT -eq 1 ]]; then + log "Building bundle (aspire-managed + DCP + native AOT CLI)..." + dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" "/p:VersionSuffix=$VERSION_SUFFIX" + else + log "Building bundle (aspire-managed + DCP)..." + dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" /p:SkipNativeBuild=true "/p:VersionSuffix=$VERSION_SUFFIX" + fi + if [[ $? -ne 0 ]]; then + error "Bundle build failed." + exit 1 + fi + + BUNDLE_LAYOUT_DIR="$REPO_ROOT/artifacts/bundle/$BUNDLE_RID" + + if [[ ! -d "$BUNDLE_LAYOUT_DIR" ]]; then + error "Bundle layout not found at $BUNDLE_LAYOUT_DIR" + exit 1 + fi + + # Copy managed/ and dcp/ to $HOME/.aspire so the CLI auto-discovers them + for component in managed dcp; do + SOURCE_DIR="$BUNDLE_LAYOUT_DIR/$component" + DEST_DIR="$ASPIRE_ROOT/$component" + if [[ -d "$SOURCE_DIR" ]]; then + rm -rf "$DEST_DIR" + log "Copying $component/ to $DEST_DIR" + cp -r "$SOURCE_DIR" "$DEST_DIR" + # Ensure executables are executable + if [[ "$component" == "managed" ]]; then + chmod +x "$DEST_DIR/aspire-managed" 2>/dev/null || true + elif [[ "$component" == "dcp" ]]; then + find "$DEST_DIR" -type f -name "dcp" -exec chmod +x {} \; 2>/dev/null || true + fi + else + warn "$component/ not found in bundle layout at $SOURCE_DIR" + fi + done - if [ ! -d "$CLI_PUBLISH_DIR" ]; then - # Fallback: try the non-publish directory - CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0" + log "Bundle installed to $ASPIRE_ROOT (managed/ + dcp/)" +fi + +# Install the CLI to $HOME/.aspire/bin +if [[ $SKIP_CLI -eq 0 ]]; then + if [[ $NATIVE_AOT -eq 1 ]]; then + # Native AOT CLI from Bundle.proj publish + CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli/$EFFECTIVE_CONFIG/net10.0/$BUNDLE_RID/native" + if [[ ! -d "$CLI_PUBLISH_DIR" ]]; then + CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli/$EFFECTIVE_CONFIG/net10.0/$BUNDLE_RID/publish" + fi + else + # Framework-dependent CLI from dotnet tool build + CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0/publish" + if [[ ! -d "$CLI_PUBLISH_DIR" ]]; then + CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0" + fi fi CLI_SOURCE_PATH="$CLI_PUBLISH_DIR/aspire" if [ -f "$CLI_SOURCE_PATH" ]; then - log "Installing Aspire CLI to $CLI_BIN_DIR" + if [[ $NATIVE_AOT -eq 1 ]]; then + log "Installing Aspire CLI (native AOT) to $CLI_BIN_DIR" + else + log "Installing Aspire CLI to $CLI_BIN_DIR" + fi mkdir -p "$CLI_BIN_DIR" # Copy all files from the publish directory (CLI and its dependencies) @@ -255,4 +342,9 @@ if [[ $SKIP_CLI -eq 0 ]]; then log "The locally-built CLI was installed to: $HOME/.aspire/bin" echo fi +if [[ $SKIP_BUNDLE -eq 0 ]]; then + log "Bundle (aspire-managed + DCP) installed to: $HOME/.aspire" + log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + echo +fi log "The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required." diff --git a/src/Aspire.Cli.NuGetHelper/Aspire.Cli.NuGetHelper.csproj b/src/Aspire.Cli.NuGetHelper/Aspire.Cli.NuGetHelper.csproj deleted file mode 100644 index 842f7e446f9..00000000000 --- a/src/Aspire.Cli.NuGetHelper/Aspire.Cli.NuGetHelper.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - Exe - net10.0 - enable - enable - aspire-nuget - Aspire.Cli.NuGetHelper - false - false - - false - - - - - - - - - - - diff --git a/src/Aspire.Cli.NuGetHelper/Program.cs b/src/Aspire.Cli.NuGetHelper/Program.cs deleted file mode 100644 index b2da6ba23bf..00000000000 --- a/src/Aspire.Cli.NuGetHelper/Program.cs +++ /dev/null @@ -1,31 +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.CommandLine; -using Aspire.Cli.NuGetHelper.Commands; - -namespace Aspire.Cli.NuGetHelper; - -/// -/// NuGet Helper Tool - Provides NuGet operations for the Aspire CLI bundle. -/// This tool runs under the bundled .NET runtime and provides package search, -/// restore, and layout generation functionality without requiring the .NET SDK. -/// -public static class Program -{ - /// - /// Entry point for the NuGet Helper tool. - /// - /// Command line arguments. - /// Exit code (0 for success). - public static async Task Main(string[] args) - { - var rootCommand = new RootCommand("Aspire NuGet Helper - Package operations for Aspire CLI bundle"); - - rootCommand.Subcommands.Add(SearchCommand.Create()); - rootCommand.Subcommands.Add(RestoreCommand.Create()); - rootCommand.Subcommands.Add(LayoutCommand.Create()); - - return await rootCommand.Parse(args).InvokeAsync().ConfigureAwait(false); - } -} diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs index a69179b0254..f356fe97c92 100644 --- a/src/Aspire.Cli/Bundles/BundleService.cs +++ b/src/Aspire.Cli/Bundles/BundleService.cs @@ -41,11 +41,8 @@ internal sealed class BundleService(ILayoutDiscovery layoutDiscovery, ILogger internal static readonly string[] s_layoutDirectories = [ - BundleDiscovery.RuntimeDirectoryName, - BundleDiscovery.DashboardDirectoryName, - BundleDiscovery.DcpDirectoryName, - BundleDiscovery.AppHostServerDirectoryName, - "tools" + BundleDiscovery.ManagedDirectoryName, + BundleDiscovery.DcpDirectoryName ]; /// diff --git a/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs deleted file mode 100644 index bf0469f8364..00000000000 --- a/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs +++ /dev/null @@ -1,195 +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; -using System.Text; -using System.Text.Json; -using Aspire.Cli.Bundles; -using Aspire.Cli.DotNet; -using Aspire.Cli.Layout; -using Microsoft.Extensions.Logging; - -namespace Aspire.Cli.Certificates; - -/// -/// Certificate tool runner that uses the bundled dev-certs DLL with the bundled runtime. -/// -internal sealed class BundleCertificateToolRunner( - IBundleService bundleService, - ILogger logger) : ICertificateToolRunner -{ - private async Task GetLayoutAsync(CancellationToken cancellationToken) - { - return await bundleService.EnsureExtractedAndGetLayoutAsync(cancellationToken).ConfigureAwait(false) - ?? throw new InvalidOperationException("Bundle layout not found after extraction."); - } - - public async Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync( - DotNetCliRunnerInvocationOptions options, - CancellationToken cancellationToken) - { - var layout = await GetLayoutAsync(cancellationToken); - var muxerPath = layout.GetMuxerPath(); - var devCertsPath = layout.GetDevCertsPath(); - - if (muxerPath is null) - { - throw new InvalidOperationException("Bundle runtime not found. The bundle may be corrupt."); - } - - if (devCertsPath is null || !File.Exists(devCertsPath)) - { - throw new InvalidOperationException("dev-certs tool not found in bundle. The bundle may be corrupt or incomplete."); - } - - var outputBuilder = new StringBuilder(); - - var startInfo = new ProcessStartInfo(muxerPath) - { - WorkingDirectory = Environment.CurrentDirectory, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - - // Use ArgumentList to prevent command injection - startInfo.ArgumentList.Add(devCertsPath); - startInfo.ArgumentList.Add("https"); - startInfo.ArgumentList.Add("--check-trust-machine-readable"); - - using var process = new Process { StartInfo = startInfo }; - - process.OutputDataReceived += (sender, e) => - { - if (e.Data is not null) - { - outputBuilder.AppendLine(e.Data); - options.StandardOutputCallback?.Invoke(e.Data); - } - }; - - process.ErrorDataReceived += (sender, e) => - { - if (e.Data is not null) - { - options.StandardErrorCallback?.Invoke(e.Data); - } - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - await process.WaitForExitAsync(cancellationToken); - - var exitCode = process.ExitCode; - - // Parse the JSON output - try - { - var jsonOutput = outputBuilder.ToString().Trim(); - if (string.IsNullOrEmpty(jsonOutput)) - { - return (exitCode, new CertificateTrustResult - { - HasCertificates = false, - TrustLevel = null, - Certificates = [] - }); - } - - var certificates = JsonSerializer.Deserialize(jsonOutput, Aspire.Cli.JsonSourceGenerationContext.Default.ListDevCertInfo); - if (certificates is null || certificates.Count == 0) - { - return (exitCode, new CertificateTrustResult - { - HasCertificates = false, - TrustLevel = null, - Certificates = [] - }); - } - - // Find the highest versioned valid certificate - var now = DateTimeOffset.Now; - var validCertificates = certificates - .Where(c => c.IsHttpsDevelopmentCertificate && c.ValidityNotBefore <= now && now <= c.ValidityNotAfter) - .OrderByDescending(c => c.Version) - .ToList(); - - var highestVersionedCert = validCertificates.FirstOrDefault(); - var trustLevel = highestVersionedCert?.TrustLevel; - - return (exitCode, new CertificateTrustResult - { - HasCertificates = validCertificates.Count > 0, - TrustLevel = trustLevel, - Certificates = certificates - }); - } - catch (JsonException ex) - { - logger.LogDebug(ex, "Failed to parse dev-certs machine-readable output"); - return (exitCode, null); - } - } - - public async Task TrustHttpCertificateAsync( - DotNetCliRunnerInvocationOptions options, - CancellationToken cancellationToken) - { - var layout = await GetLayoutAsync(cancellationToken); - var muxerPath = layout.GetMuxerPath(); - var devCertsPath = layout.GetDevCertsPath(); - - if (muxerPath is null) - { - throw new InvalidOperationException("Bundle runtime not found. The bundle may be corrupt."); - } - - if (devCertsPath is null || !File.Exists(devCertsPath)) - { - throw new InvalidOperationException("dev-certs tool not found in bundle. The bundle may be corrupt or incomplete."); - } - - var startInfo = new ProcessStartInfo(muxerPath) - { - WorkingDirectory = Environment.CurrentDirectory, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - - // Use ArgumentList to prevent command injection - startInfo.ArgumentList.Add(devCertsPath); - startInfo.ArgumentList.Add("https"); - startInfo.ArgumentList.Add("--trust"); - - using var process = new Process { StartInfo = startInfo }; - - process.OutputDataReceived += (sender, e) => - { - if (e.Data is not null) - { - options.StandardOutputCallback?.Invoke(e.Data); - } - }; - - process.ErrorDataReceived += (sender, e) => - { - if (e.Data is not null) - { - options.StandardErrorCallback?.Invoke(e.Data); - } - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - await process.WaitForExitAsync(cancellationToken); - - return process.ExitCode; - } -} \ No newline at end of file diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateExportFormat.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateExportFormat.cs new file mode 100644 index 00000000000..70705da5fce --- /dev/null +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateExportFormat.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Certificates.Generation; + +internal enum CertificateKeyExportFormat +{ + Pfx, + Pem, +} diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs new file mode 100644 index 00000000000..f148f1ca07b --- /dev/null +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs @@ -0,0 +1,1596 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +#nullable enable + +namespace Microsoft.AspNetCore.Certificates.Generation; + +internal abstract class CertificateManager +{ + // This is the version of the ASP.NET Core HTTPS development certificate that will be generated by tooling built with this version of the library. + // Increment this when making any structural changes to the generated certificate (e.g. changing extensions, key usages, SANs, etc.). + // Version 6 was introduced in SDK 10.0.102 and runtime 10.0.2. + internal const int CurrentAspNetCoreCertificateVersion = 6; + // This is the minimum version of the certificate that will be considered valid by runtime components built using this version of the library. + // Increment this only when making breaking changes to the certificate or during major runtime version increments. Must always be less than or equal to CurrentAspNetCoreCertificateVersion. + // This determines the minimum version of the tooling required to generate a certificate that will be considered valid by the runtime. + // Version 4 was introduced in SDK 10.0.100 and runtime 10.0.0. + internal const int CurrentMinimumAspNetCoreCertificateVersion = 4; + + // OID used for HTTPS certs + internal const string AspNetHttpsOid = "1.3.6.1.4.1.311.84.1.1"; + internal const string AspNetHttpsOidFriendlyName = "ASP.NET Core HTTPS development certificate"; + + private const string ServerAuthenticationEnhancedKeyUsageOid = "1.3.6.1.5.5.7.3.1"; + private const string ServerAuthenticationEnhancedKeyUsageOidFriendlyName = "Server Authentication"; + + internal const string SubjectKeyIdentifierOid = "2.5.29.14"; + internal const string AuthorityKeyIdentifierOid = "2.5.29.35"; + + // dns names of the host from a container + private const string LocalhostDockerHttpsDnsName = "host.docker.internal"; + private const string ContainersDockerHttpsDnsName = "host.containers.internal"; + + // wildcard DNS names + private const string LocalhostWildcardHttpsDnsName = "*.dev.localhost"; + private const string InternalWildcardHttpsDnsName = "*.dev.internal"; + + // main cert subject + private const string LocalhostHttpsDnsName = "localhost"; + internal const string LocalhostHttpsDistinguishedName = "CN=" + LocalhostHttpsDnsName; + + public const int RSAMinimumKeySizeInBits = 2048; + + public static CertificateManager Create(ILogger logger) => OperatingSystem.IsWindows() ? +#pragma warning disable CA1416 // Validate platform compatibility + new WindowsCertificateManager(logger) : +#pragma warning restore CA1416 // Validate platform compatibility + OperatingSystem.IsMacOS() ? + new MacOSCertificateManager(logger) as CertificateManager : + new UnixCertificateManager(logger); + + protected CertificateManagerLogger Log { get; } + + // Setting to 0 means we don't append the version byte, + // which is what all machines currently have. + public int AspNetHttpsCertificateVersion + { + get; + // For testing purposes only + internal set + { + ArgumentOutOfRangeException.ThrowIfLessThan( + value, + MinimumAspNetHttpsCertificateVersion, + $"{nameof(AspNetHttpsCertificateVersion)} cannot be lesser than {nameof(MinimumAspNetHttpsCertificateVersion)}"); + field = value; + } + } + + public int MinimumAspNetHttpsCertificateVersion + { + get; + // For testing purposes only + internal set + { + ArgumentOutOfRangeException.ThrowIfGreaterThan( + value, + AspNetHttpsCertificateVersion, + $"{nameof(MinimumAspNetHttpsCertificateVersion)} cannot be greater than {nameof(AspNetHttpsCertificateVersion)}"); + field = value; + } + } + + public string Subject { get; } + + public CertificateManager(ILogger logger) : this(logger, LocalhostHttpsDistinguishedName, CurrentAspNetCoreCertificateVersion, CurrentMinimumAspNetCoreCertificateVersion) + { + } + + // For testing purposes only + internal CertificateManager(string subject, int version) + : this(NullLogger.Instance, subject, version, version) + { + } + + // For testing purposes only + internal CertificateManager(ILogger logger, string subject, int generatedVersion, int minimumVersion) + { + Log = new CertificateManagerLogger(logger); + Subject = subject; + AspNetHttpsCertificateVersion = generatedVersion; + MinimumAspNetHttpsCertificateVersion = minimumVersion; + } + + /// + /// This only checks if the certificate has the OID for ASP.NET Core HTTPS development certificates - + /// it doesn't check the subject, validity, key usages, etc. + /// + public static bool IsHttpsDevelopmentCertificate(X509Certificate2 certificate) + { + foreach (var extension in certificate.Extensions.OfType()) + { + if (string.Equals(AspNetHttpsOid, extension.Oid?.Value, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + + public IList ListCertificates( + StoreName storeName, + StoreLocation location, + bool isValid, + bool requireExportable = true) + { + Log.ListCertificatesStart(location, storeName); + var certificates = new List(); + try + { + using var store = new X509Store(storeName, location); + store.Open(OpenFlags.ReadOnly); + PopulateCertificatesFromStore(store, certificates, requireExportable); + IEnumerable matchingCertificates = certificates; + matchingCertificates = matchingCertificates + .Where(c => HasOid(c, AspNetHttpsOid)); + + if (Log.IsEnabled()) + { + Log.DescribeFoundCertificates(ToCertificateDescription(matchingCertificates)); + } + + if (isValid) + { + // Ensure the certificate hasn't expired, has a private key and its exportable + // (for container/unix scenarios). + Log.CheckCertificatesValidity(); + var now = DateTimeOffset.Now; + var validCertificates = matchingCertificates + .Where(c => IsValidCertificate(c, now, requireExportable)) + .OrderByDescending(GetCertificateVersion) + .ToArray(); + + if (Log.IsEnabled()) + { + var invalidCertificates = matchingCertificates.Except(validCertificates); + Log.DescribeValidCertificates(ToCertificateDescription(validCertificates)); + Log.DescribeInvalidCertificates(ToCertificateDescription(invalidCertificates)); + } + + // Ensure the certificate meets the minimum version requirement. + var validMinVersionCertificates = validCertificates + .Where(c => GetCertificateVersion(c) >= MinimumAspNetHttpsCertificateVersion) + .ToArray(); + + if (Log.IsEnabled()) + { + var belowMinimumVersionCertificates = validCertificates.Except(validMinVersionCertificates); + Log.DescribeMinimumVersionCertificates(ToCertificateDescription(validMinVersionCertificates)); + Log.DescribeBelowMinimumVersionCertificates(ToCertificateDescription(belowMinimumVersionCertificates)); + } + + matchingCertificates = validMinVersionCertificates; + } + + // We need to enumerate the certificates early to prevent disposing issues. + matchingCertificates = matchingCertificates.ToList(); + + var certificatesToDispose = certificates.Except(matchingCertificates); + DisposeCertificates(certificatesToDispose); + + store.Close(); + + Log.ListCertificatesEnd(); + return (IList)matchingCertificates; + } + catch (Exception e) + { + if (Log.IsEnabled()) + { + Log.ListCertificatesError(e.ToString()); + } + DisposeCertificates(certificates); + certificates.Clear(); + return certificates; + } + + bool HasOid(X509Certificate2 certificate, string oid) => + certificate.Extensions.OfType() + .Any(e => string.Equals(oid, e.Oid?.Value, StringComparison.Ordinal)); + } + + /// + /// Validate that the certificate is valid at the given date and time (and exportable if required). + /// + /// The certificate to validate. + /// The current date to validate against. + /// Whether the certificate must be exportable. + /// True if the certificate is valid; otherwise, false. + internal bool IsValidCertificate(X509Certificate2 certificate, DateTimeOffset currentDate, bool requireExportable) + { + return certificate.NotBefore <= currentDate && + currentDate <= certificate.NotAfter && + (!requireExportable || IsExportable(certificate)); + } + + internal static byte GetCertificateVersion(X509Certificate2 c) + { + var byteArray = c.Extensions.OfType() + .Where(e => string.Equals(AspNetHttpsOid, e.Oid?.Value, StringComparison.Ordinal)) + .Single() + .RawData; + + if ((byteArray.Length == AspNetHttpsOidFriendlyName.Length && byteArray[0] == (byte)'A') || byteArray.Length == 0) + { + // No Version set, default to 0 + return 0b0; + } + else + { + // Version is in the only byte of the byte array. + return byteArray[0]; + } + } + + protected virtual void PopulateCertificatesFromStore(X509Store store, List certificates, bool requireExportable) + { + certificates.AddRange(store.Certificates.OfType()); + } + + public IList GetHttpsCertificates() => + ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true); + + /// + /// Ensures that a valid ASP.NET Core HTTPS development certificate is present. + /// + /// The date and time before which the certificate is not valid. + /// The date and time after which the certificate is not valid. + /// Path to export the certificate (directory must exist). + /// Whether to trust the certificate or simply add it to the CurrentUser/My store. + /// Whether to include the private key in the exported certificate. + /// Password for the exported certificate. + /// Format for exporting the certificate key. + /// Whether the operation is interactive (dotnet dev-certs tool) or non-interactive (first run experience). + /// The result of the ensure operation. + /// There was an error ensuring the certificate exists. + /// + /// The minimum certificate version checks behave differently based on whether the operation is interactive or not. In interactive mode, + /// the certificate will only be considered valid if it meets or exceeds the current version of the certificate. In non-interactive mode, + /// the certificate will be considered valid as long as it meets the minimum supported version requirement. This is to allow first run + /// to upgrade a certificate if it becomes necessary to bump the minimum version due to security issues, etc. while not leaving users with + /// a partially valid certificate after a normal first run experience. Interactive scenarios such as the dotnet dev-certs tool should always + /// ensure the certificate is updated to at least the latest supported version. + /// + public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate( + DateTimeOffset notBefore, + DateTimeOffset notAfter, + string? path = null, + bool trust = false, + bool includePrivateKey = false, + string? password = null, + CertificateKeyExportFormat keyExportFormat = CertificateKeyExportFormat.Pfx, + bool isInteractive = true) + { + var result = EnsureCertificateResult.Succeeded; + + var allCurrentUserCertificates = ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true); + var allLocalMachineCertificates = ListCertificates(StoreName.My, StoreLocation.LocalMachine, isValid: true, requireExportable: true); + + var currentUserCertificates = allCurrentUserCertificates.Where(c => c.Subject == Subject).ToList(); + var localMachineCertificates = allLocalMachineCertificates.Where(c => c.Subject == Subject).ToList(); + var filteredCertificates = currentUserCertificates.Concat(localMachineCertificates).ToList(); + + if (isInteractive) + { + // For purposes of updating the dev cert, only consider certificates with the current version or higher as valid + // Only applies to interactive scenarios where we want to ensure we're generating the latest certificate + // For non-interactive scenarios (e.g. first run experience), we want to accept older versions of the certificate as long as they meet the minimum version requirement + // This will allow us to respond to scenarios where we need to invalidate older certificates due to security issues, etc. but not leave users + // with a partially valid certificate after their first run experience. + filteredCertificates = filteredCertificates.Where(c => GetCertificateVersion(c) >= AspNetHttpsCertificateVersion).ToList(); + } + + if (Log.IsEnabled()) + { + var excludedCertificates = allCurrentUserCertificates.Concat(allLocalMachineCertificates).Except(filteredCertificates); + Log.FilteredCertificates(ToCertificateDescription(filteredCertificates)); + Log.ExcludedCertificates(ToCertificateDescription(excludedCertificates)); + } + + // Dispose certificates we're not going to use + DisposeCertificates(allCurrentUserCertificates.Except(currentUserCertificates)); + DisposeCertificates(allLocalMachineCertificates.Except(localMachineCertificates)); + + var certificates = filteredCertificates; + + X509Certificate2? certificate = null; + var isNewCertificate = false; + if (certificates.Any()) + { + certificate = certificates.First(); + var failedToFixCertificateState = false; + if (isInteractive) + { + // Skip this step if the command is not interactive, + // as we don't want to prompt on first run experience. + foreach (var candidate in currentUserCertificates) + { + var status = CheckCertificateState(candidate); + if (!status.Success) + { + try + { + if (Log.IsEnabled()) + { + Log.CorrectCertificateStateStart(GetDescription(candidate)); + } + CorrectCertificateState(candidate); + Log.CorrectCertificateStateEnd(); + } + catch (Exception e) + { + if (Log.IsEnabled()) + { + Log.CorrectCertificateStateError(e.ToString()); + } + result = EnsureCertificateResult.FailedToMakeKeyAccessible; + // We don't return early on this type of failure to allow for tooling to + // export or trust the certificate even in this situation, as that enables + // exporting the certificate to perform any necessary fix with native tooling. + failedToFixCertificateState = true; + } + } + } + } + + if (!failedToFixCertificateState) + { + if (Log.IsEnabled()) + { + Log.ValidCertificatesFound(ToCertificateDescription(certificates)); + } + certificate = certificates.First(); + if (Log.IsEnabled()) + { + Log.SelectedCertificate(GetDescription(certificate)); + } + result = EnsureCertificateResult.ValidCertificatePresent; + } + } + else + { + Log.NoValidCertificatesFound(); + try + { + Log.CreateDevelopmentCertificateStart(); + isNewCertificate = true; + certificate = CreateAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter); + } + catch (Exception e) + { + if (Log.IsEnabled()) + { + Log.CreateDevelopmentCertificateError(e.ToString()); + } + result = EnsureCertificateResult.ErrorCreatingTheCertificate; + DisposeCertificates(certificates); + return result; + } + Log.CreateDevelopmentCertificateEnd(); + + try + { + certificate = SaveCertificate(certificate); + } + catch (Exception e) + { + Log.SaveCertificateInStoreError(e.ToString()); + if (isNewCertificate) + { + certificate?.Dispose(); + } + DisposeCertificates(certificates); + result = EnsureCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore; + return result; + } + + if (isInteractive) + { + try + { + if (Log.IsEnabled()) + { + Log.CorrectCertificateStateStart(GetDescription(certificate)); + } + CorrectCertificateState(certificate); + Log.CorrectCertificateStateEnd(); + } + catch (Exception e) + { + if (Log.IsEnabled()) + { + Log.CorrectCertificateStateError(e.ToString()); + } + + // We don't return early on this type of failure to allow for tooling to + // export or trust the certificate even in this situation, as that enables + // exporting the certificate to perform any necessary fix with native tooling. + result = EnsureCertificateResult.FailedToMakeKeyAccessible; + } + } + } + + if (path != null) + { + try + { + // If the user specified a non-existent directory, we don't want to be responsible + // for setting the permissions appropriately, so we'll bail. + var exportDir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(exportDir) && !Directory.Exists(exportDir)) + { + result = EnsureCertificateResult.ErrorExportingTheCertificateToNonExistentDirectory; + throw new InvalidOperationException($"The directory '{exportDir}' does not exist. Choose permissions carefully when creating it."); + } + + ExportCertificate(certificate, path, includePrivateKey, password, keyExportFormat); + } + catch (Exception e) + { + if (Log.IsEnabled()) + { + Log.ExportCertificateError(e.ToString()); + } + + // We don't want to mask the original source of the error here. + result = result != EnsureCertificateResult.Succeeded && result != EnsureCertificateResult.ValidCertificatePresent ? + result : + EnsureCertificateResult.ErrorExportingTheCertificate; + + if (isNewCertificate) + { + certificate?.Dispose(); + } + DisposeCertificates(certificates); + return result; + } + } + + if (trust) + { + try + { + var trustLevel = TrustCertificate(certificate); + switch (trustLevel) + { + case TrustLevel.Full: + // Leave result as-is. + break; + case TrustLevel.Partial: + result = EnsureCertificateResult.PartiallyFailedToTrustTheCertificate; + if (isNewCertificate) + { + certificate?.Dispose(); + } + DisposeCertificates(certificates); + return result; + case TrustLevel.None: + default: // Treat unknown status (should be impossible) as failure + result = EnsureCertificateResult.FailedToTrustTheCertificate; + if (isNewCertificate) + { + certificate?.Dispose(); + } + DisposeCertificates(certificates); + return result; + } + } + catch (UserCancelledTrustException) + { + result = EnsureCertificateResult.UserCancelledTrustStep; + if (isNewCertificate) + { + certificate?.Dispose(); + } + DisposeCertificates(certificates); + return result; + } + catch + { + result = EnsureCertificateResult.FailedToTrustTheCertificate; + if (isNewCertificate) + { + certificate?.Dispose(); + } + DisposeCertificates(certificates); + return result; + } + + if (result == EnsureCertificateResult.ValidCertificatePresent) + { + result = EnsureCertificateResult.ExistingHttpsCertificateTrusted; + } + else + { + result = EnsureCertificateResult.NewHttpsCertificateTrusted; + } + } + + DisposeCertificates(!isNewCertificate ? certificates : certificates.Append(certificate)); + + return result; + } + + internal ImportCertificateResult ImportCertificate(string certificatePath, string password) + { + if (!File.Exists(certificatePath)) + { + Log.ImportCertificateMissingFile(certificatePath); + return ImportCertificateResult.CertificateFileMissing; + } + + var certificates = ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: false); + if (certificates.Any()) + { + if (Log.IsEnabled()) + { + Log.ImportCertificateExistingCertificates(ToCertificateDescription(certificates)); + } + return ImportCertificateResult.ExistingCertificatesPresent; + } + + X509Certificate2? certificate = null; + try + { + try + { + Log.LoadCertificateStart(certificatePath); + certificate = X509CertificateLoader.LoadPkcs12FromFile(certificatePath, password, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet); + if (Log.IsEnabled()) + { + Log.LoadCertificateEnd(GetDescription(certificate)); + } + } + catch (Exception e) + { + if (Log.IsEnabled()) + { + Log.LoadCertificateError(e.ToString()); + } + return ImportCertificateResult.InvalidCertificate; + } + + // Note that we're checking Subject, rather than LocalhostHttpsDistinguishedName, + // because the tests use a different subject. + if (!string.Equals(certificate.Subject, Subject, StringComparison.Ordinal) || // Kestrel requires this + !IsHttpsDevelopmentCertificate(certificate)) + { + if (Log.IsEnabled()) + { + Log.NoHttpsDevelopmentCertificate(GetDescription(certificate)); + } + return ImportCertificateResult.NoDevelopmentHttpsCertificate; + } + + try + { + certificate = SaveCertificate(certificate); + } + catch (Exception e) + { + if (Log.IsEnabled()) + { + Log.SaveCertificateInStoreError(e.ToString()); + } + return ImportCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore; + } + + return ImportCertificateResult.Succeeded; + } + finally + { + certificate?.Dispose(); + } + } + + public void CleanupHttpsCertificates() + { + var certificates = ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false); + var filteredCertificates = certificates.Where(c => c.Subject == Subject); + + if (Log.IsEnabled()) + { + var excludedCertificates = certificates.Except(filteredCertificates); + Log.FilteredCertificates(ToCertificateDescription(filteredCertificates)); + Log.ExcludedCertificates(ToCertificateDescription(excludedCertificates)); + } + + foreach (var certificate in filteredCertificates) + { + // RemoveLocations.All will first remove from the trusted roots (e.g. keychain on + // macOS) and then from the local user store. + RemoveCertificate(certificate, RemoveLocations.All); + } + } + + public abstract TrustLevel GetTrustLevel(X509Certificate2 certificate); + + protected abstract X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation); + + /// Implementations may choose to throw, rather than returning . + protected abstract TrustLevel TrustCertificateCore(X509Certificate2 certificate); + + internal abstract bool IsExportable(X509Certificate2 c); + + protected abstract void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate); + + protected abstract IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation); + + protected abstract void CreateDirectoryWithPermissions(string directoryPath); + + /// + /// Will create directories to make it possible to write to . + /// If you don't want that, check for existence before calling this method. + /// + internal void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string? password, CertificateKeyExportFormat format) + { + if (Log.IsEnabled()) + { + Log.ExportCertificateStart(GetDescription(certificate), path, includePrivateKey); + } + + if (includePrivateKey && password == null) + { + Log.NoPasswordForCertificate(); + } + + var targetDirectoryPath = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(targetDirectoryPath)) + { + Log.CreateExportCertificateDirectory(targetDirectoryPath); + CreateDirectoryWithPermissions(targetDirectoryPath); + } + + byte[] bytes; + byte[] keyBytes; + byte[]? pemEnvelope = null; + RSA? key = null; + + try + { + if (includePrivateKey) + { + switch (format) + { + case CertificateKeyExportFormat.Pfx: + bytes = certificate.Export(X509ContentType.Pkcs12, password); + break; + case CertificateKeyExportFormat.Pem: + key = certificate.GetRSAPrivateKey()!; + + char[] pem; + if (password != null) + { + keyBytes = key.ExportEncryptedPkcs8PrivateKey(password, new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 100000)); + pem = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes); + pemEnvelope = Encoding.ASCII.GetBytes(pem); + } + else + { + // Export the key first to an encrypted PEM to avoid issues with System.Security.Cryptography.Cng indicating that the operation is not supported. + // This is likely by design to avoid exporting the key by mistake. + // To bypass it, we export the certificate to pem temporarily and then we import it and export it as unprotected PEM. + keyBytes = key.ExportEncryptedPkcs8PrivateKey(string.Empty, new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 1)); + pem = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes); + key.Dispose(); + key = RSA.Create(); + key.ImportFromEncryptedPem(pem, string.Empty); + Array.Clear(keyBytes, 0, keyBytes.Length); + Array.Clear(pem, 0, pem.Length); + keyBytes = key.ExportPkcs8PrivateKey(); + pem = PemEncoding.Write("PRIVATE KEY", keyBytes); + pemEnvelope = Encoding.ASCII.GetBytes(pem); + } + + Array.Clear(keyBytes, 0, keyBytes.Length); + Array.Clear(pem, 0, pem.Length); + + bytes = Encoding.ASCII.GetBytes(PemEncoding.Write("CERTIFICATE", certificate.Export(X509ContentType.Cert))); + break; + default: + throw new InvalidOperationException("Unknown format."); + } + } + else + { + if (format == CertificateKeyExportFormat.Pem) + { + bytes = Encoding.ASCII.GetBytes(PemEncoding.Write("CERTIFICATE", certificate.Export(X509ContentType.Cert))); + } + else + { + bytes = certificate.Export(X509ContentType.Cert); + } + } + } + catch (Exception e) + { + Log.ExportCertificateError(e.ToString()); + throw; + } + finally + { + key?.Dispose(); + } + + try + { + Log.WriteCertificateToDisk(path); + + // Create a temp file with the correct Unix file mode before moving it to the expected path. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var tempFilename = Path.GetTempFileName(); + File.Move(tempFilename, path, overwrite: true); + } + + File.WriteAllBytes(path, bytes); + } + catch (Exception ex) + { + Log.WriteCertificateToDiskError(ex.ToString()); + throw; + } + finally + { + Array.Clear(bytes, 0, bytes.Length); + } + + if (includePrivateKey && format == CertificateKeyExportFormat.Pem) + { + Debug.Assert(pemEnvelope != null); + + try + { + var keyPath = Path.ChangeExtension(path, ".key"); + Log.WritePemKeyToDisk(keyPath); + + // Create a temp file with the correct Unix file mode before moving it to the expected path. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var tempFilename = Path.GetTempFileName(); + File.Move(tempFilename, keyPath, overwrite: true); + } + + File.WriteAllBytes(keyPath, pemEnvelope); + } + catch (Exception ex) + { + Log.WritePemKeyToDiskError(ex.ToString()); + throw; + } + finally + { + Array.Clear(pemEnvelope, 0, pemEnvelope.Length); + } + } + } + + /// + /// Creates a new ASP.NET Core HTTPS development certificate. + /// + /// The date and time before which the certificate is not valid. + /// The date and time after which the certificate is not valid. + /// The created X509Certificate2 instance. + /// + /// When making changes to the certificate generated by this method, ensure that the constant is updated accordingly. + /// + internal X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate(DateTimeOffset notBefore, DateTimeOffset notAfter) + { + var subject = new X500DistinguishedName(Subject); + var extensions = new List(); + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName(LocalhostHttpsDnsName); + sanBuilder.AddDnsName(LocalhostWildcardHttpsDnsName); + sanBuilder.AddDnsName(InternalWildcardHttpsDnsName); + sanBuilder.AddDnsName(LocalhostDockerHttpsDnsName); + sanBuilder.AddDnsName(ContainersDockerHttpsDnsName); + sanBuilder.AddIpAddress(IPAddress.Loopback); + sanBuilder.AddIpAddress(IPAddress.IPv6Loopback); + + var keyUsage = new X509KeyUsageExtension(X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DigitalSignature, critical: true); + var enhancedKeyUsage = new X509EnhancedKeyUsageExtension( + new OidCollection() { + new Oid( + ServerAuthenticationEnhancedKeyUsageOid, + ServerAuthenticationEnhancedKeyUsageOidFriendlyName) + }, + critical: true); + + var basicConstraints = new X509BasicConstraintsExtension( + certificateAuthority: false, + hasPathLengthConstraint: false, + pathLengthConstraint: 0, + critical: true); + + byte[] bytePayload; + + if (AspNetHttpsCertificateVersion != 0) + { + bytePayload = new byte[1]; + bytePayload[0] = (byte)AspNetHttpsCertificateVersion; + } + else + { + bytePayload = Encoding.ASCII.GetBytes(AspNetHttpsOidFriendlyName); + } + + var aspNetHttpsExtension = new X509Extension( + new AsnEncodedData( + new Oid(AspNetHttpsOid, AspNetHttpsOidFriendlyName), + bytePayload), + critical: false); + + extensions.Add(basicConstraints); + extensions.Add(keyUsage); + extensions.Add(enhancedKeyUsage); + extensions.Add(sanBuilder.Build(critical: true)); + extensions.Add(aspNetHttpsExtension); + + var certificate = CreateSelfSignedCertificate(subject, extensions, notBefore, notAfter); + return certificate; + } + + internal X509Certificate2 SaveCertificate(X509Certificate2 certificate) + { + var name = StoreName.My; + var location = StoreLocation.CurrentUser; + + if (Log.IsEnabled()) + { + Log.SaveCertificateInStoreStart(GetDescription(certificate), name, location); + } + + certificate = SaveCertificateCore(certificate, name, location); + + Log.SaveCertificateInStoreEnd(); + return certificate; + } + + internal TrustLevel TrustCertificate(X509Certificate2 certificate) + { + try + { + if (Log.IsEnabled()) + { + Log.TrustCertificateStart(GetDescription(certificate)); + } + var trustLevel = TrustCertificateCore(certificate); + Log.TrustCertificateEnd(); + return trustLevel; + } + catch (Exception ex) + { + Log.TrustCertificateError(ex.ToString()); + throw; + } + } + + // Internal, for testing purposes only. + internal void RemoveAllCertificates(StoreName storeName, StoreLocation storeLocation) + { + var certificates = GetCertificatesToRemove(storeName, storeLocation); + var certificatesWithName = certificates.Where(c => c.Subject == Subject); + + var removeLocation = storeName == StoreName.My ? RemoveLocations.Local : RemoveLocations.Trusted; + + foreach (var certificate in certificates) + { + RemoveCertificate(certificate, removeLocation); + } + + DisposeCertificates(certificates); + } + + internal void RemoveCertificate(X509Certificate2 certificate, RemoveLocations locations) + { + switch (locations) + { + case RemoveLocations.Undefined: + throw new InvalidOperationException($"'{nameof(RemoveLocations.Undefined)}' is not a valid location."); + case RemoveLocations.Local: + RemoveCertificateFromUserStore(certificate); + break; + case RemoveLocations.Trusted: + RemoveCertificateFromTrustedRoots(certificate); + break; + case RemoveLocations.All: + RemoveCertificateFromTrustedRoots(certificate); + RemoveCertificateFromUserStore(certificate); + break; + default: + throw new InvalidOperationException("Invalid location."); + } + } + + internal abstract CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate); + + internal abstract void CorrectCertificateState(X509Certificate2 candidate); + + /// + /// Creates a self-signed certificate with the specified subject, extensions, and validity period. + /// + /// The subject distinguished name for the certificate. + /// The collection of X509 extensions to include in the certificate. + /// The date and time before which the certificate is not valid. + /// The date and time after which the certificate is not valid. + /// The created X509Certificate2 instance. + /// If a key with the specified minimum size cannot be created. + /// + /// If making changes to the certificate generated by this method, ensure that the constant is updated accordingly. + /// + internal static X509Certificate2 CreateSelfSignedCertificate( + X500DistinguishedName subject, + IEnumerable extensions, + DateTimeOffset notBefore, + DateTimeOffset notAfter) + { + using var key = CreateKeyMaterial(RSAMinimumKeySizeInBits); + + var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + foreach (var extension in extensions) + { + request.CertificateExtensions.Add(extension); + } + + // Only add the SKI and AKI extensions if neither is already present. + // OpenSSL needs these to correctly identify the trust chain for a private key. If multiple certificates don't have a subject key identifier and share the same subject, + // the wrong certificate can be chosen for the trust chain, leading to validation errors. + if (!request.CertificateExtensions.Any(ext => ext.Oid?.Value is SubjectKeyIdentifierOid or AuthorityKeyIdentifierOid)) + { + // RFC 5280 section 4.2.1.2 + var subjectKeyIdentifier = new X509SubjectKeyIdentifierExtension(request.PublicKey, X509SubjectKeyIdentifierHashAlgorithm.Sha256, critical: false); + // RFC 5280 section 4.2.1.1 + var authorityKeyIdentifier = X509AuthorityKeyIdentifierExtension.CreateFromSubjectKeyIdentifier(subjectKeyIdentifier); + + request.CertificateExtensions.Add(subjectKeyIdentifier); + request.CertificateExtensions.Add(authorityKeyIdentifier); + } + + var result = request.CreateSelfSigned(notBefore, notAfter); + return result; + + static RSA CreateKeyMaterial(int minimumKeySize) + { + var rsa = RSA.Create(minimumKeySize); + if (rsa.KeySize < minimumKeySize) + { + throw new InvalidOperationException($"Failed to create a key with a size of {minimumKeySize} bits"); + } + + return rsa; + } + } + + internal static void DisposeCertificates(IEnumerable disposables) + { + foreach (var disposable in disposables) + { + try + { + disposable.Dispose(); + } + catch + { + } + } + } + + protected void RemoveCertificateFromUserStore(X509Certificate2 certificate) + { + try + { + if (Log.IsEnabled()) + { + Log.RemoveCertificateFromUserStoreStart(GetDescription(certificate)); + } + RemoveCertificateFromUserStoreCore(certificate); + Log.RemoveCertificateFromUserStoreEnd(); + } + catch (Exception ex) + { + Log.RemoveCertificateFromUserStoreError(ex.ToString()); + throw; + } + } + + protected virtual void RemoveCertificateFromUserStoreCore(X509Certificate2 certificate) + { + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + var matching = store.Certificates + .OfType() + .Single(c => c.SerialNumber == certificate.SerialNumber); + + store.Remove(matching); + } + + internal string ToCertificateDescription(IEnumerable certificates) + { + var list = certificates.ToList(); + var certificatesDescription = list.Count switch + { + 0 => "no certificates", + 1 => "1 certificate", + _ => $"{list.Count} certificates", + }; + var description = list.OrderBy(c => c.Thumbprint).Select((c, i) => $" {i + 1}) " + GetDescription(c)).Prepend(certificatesDescription); + return string.Join(Environment.NewLine, description); + } + + internal string GetDescription(X509Certificate2 c) => + $"{c.Thumbprint} - {c.Subject} - Valid from {c.NotBefore:u} to {c.NotAfter:u} - IsHttpsDevelopmentCertificate: {IsHttpsDevelopmentCertificate(c).ToString().ToLowerInvariant()} - IsExportable: {IsExportable(c).ToString().ToLowerInvariant()}"; + + /// + /// is not adequate for security purposes. + /// + internal static bool AreCertificatesEqual(X509Certificate2 cert1, X509Certificate2 cert2) + { + return cert1.RawDataMemory.Span.SequenceEqual(cert2.RawDataMemory.Span); + } + + /// + /// Given a certificate, usually from the store, try to find the + /// corresponding certificate in (usually the store)."/> + /// + /// An open . + /// A certificate to search for. + /// The certificate, if any, corresponding to in . + /// True if a corresponding certificate was found. + /// has richer filtering and a lot of debugging output that's unhelpful here. + internal static bool TryFindCertificateInStore(X509Store store, X509Certificate2 certificate, [NotNullWhen(true)] out X509Certificate2? foundCertificate) + { + foundCertificate = null; + + // We specifically don't search by thumbprint to avoid being flagged for using a SHA-1 hash. + var certificatesWithSubjectName = store.Certificates.Find(X509FindType.FindBySerialNumber, certificate.SerialNumber, validOnly: false); + if (certificatesWithSubjectName.Count == 0) + { + return false; + } + + var certificatesToDispose = new List(); + foreach (var candidate in certificatesWithSubjectName.OfType()) + { + if (foundCertificate is null && AreCertificatesEqual(candidate, certificate)) + { + foundCertificate = candidate; + } + else + { + certificatesToDispose.Add(candidate); + } + } + DisposeCertificates(certificatesToDispose); + return foundCertificate is not null; + } + + internal sealed class CertificateManagerLogger + { + private readonly ILogger _logger; + + public CertificateManagerLogger() : this(NullLogger.Instance) { } + + public CertificateManagerLogger(ILogger logger) + { + _logger = logger; + } + + public bool IsEnabled() => _logger.IsEnabled(LogLevel.Debug); + + // Event 1 - Verbose + public void ListCertificatesStart(StoreLocation location, StoreName storeName) => + _logger.LogDebug("Listing certificates from {Location}\\{StoreName}", location, storeName); + + // Event 2 - Verbose + public void DescribeFoundCertificates(string matchingCertificates) => + _logger.LogDebug("Found certificates: {MatchingCertificates}", matchingCertificates); + + // Event 3 - Verbose + public void CheckCertificatesValidity() => + _logger.LogDebug("Checking certificates validity"); + + // Event 4 - Verbose + public void DescribeValidCertificates(string validCertificates) => + _logger.LogDebug("Valid certificates: {ValidCertificates}", validCertificates); + + // Event 5 - Verbose + public void DescribeInvalidCertificates(string invalidCertificates) => + _logger.LogDebug("Invalid certificates: {InvalidCertificates}", invalidCertificates); + + // Event 6 - Verbose + public void ListCertificatesEnd() => + _logger.LogDebug("Finished listing certificates."); + + // Event 7 - Error + public void ListCertificatesError(string e) => + _logger.LogError("An error occurred while listing the certificates: {Error}", e); + + // Event 8 - Verbose + public void FilteredCertificates(string filteredCertificates) => + _logger.LogDebug("Filtered certificates: {FilteredCertificates}", filteredCertificates); + + // Event 9 - Verbose + public void ExcludedCertificates(string excludedCertificates) => + _logger.LogDebug("Excluded certificates: {ExcludedCertificates}", excludedCertificates); + + // Event 14 - Verbose + public void ValidCertificatesFound(string certificates) => + _logger.LogDebug("Valid certificates: {Certificates}", certificates); + + // Event 15 - Verbose + public void SelectedCertificate(string certificate) => + _logger.LogDebug("Selected certificate: {Certificate}", certificate); + + // Event 16 - Verbose + public void NoValidCertificatesFound() => + _logger.LogDebug("No valid certificates found."); + + // Event 17 - Verbose + public void CreateDevelopmentCertificateStart() => + _logger.LogDebug("Generating HTTPS development certificate."); + + // Event 18 - Verbose + public void CreateDevelopmentCertificateEnd() => + _logger.LogDebug("Finished generating HTTPS development certificate."); + + // Event 19 - Error + public void CreateDevelopmentCertificateError(string e) => + _logger.LogError("An error has occurred generating the certificate: {Error}.", e); + + // Event 20 - Verbose + public void SaveCertificateInStoreStart(string certificate, StoreName name, StoreLocation location) => + _logger.LogDebug("Saving certificate '{Certificate}' to store {Location}\\{StoreName}.", certificate, location, name); + + // Event 21 - Verbose + public void SaveCertificateInStoreEnd() => + _logger.LogDebug("Finished saving certificate to the store."); + + // Event 22 - Error + public void SaveCertificateInStoreError(string e) => + _logger.LogError("An error has occurred saving the certificate: {Error}.", e); + + // Event 23 - Verbose + public void ExportCertificateStart(string certificate, string path, bool includePrivateKey) => + _logger.LogDebug("Saving certificate '{Certificate}' to {Path} {PrivateKey} private key.", certificate, path, includePrivateKey ? "with" : "without"); + + // Event 24 - Verbose + public void NoPasswordForCertificate() => + _logger.LogDebug("Exporting certificate with private key but no password."); + + // Event 25 - Verbose + public void CreateExportCertificateDirectory(string path) => + _logger.LogDebug("Creating directory {Path}.", path); + + // Event 26 - Error + public void ExportCertificateError(string error) => + _logger.LogError("An error has occurred while exporting the certificate: {Error}.", error); + + // Event 27 - Verbose + public void WriteCertificateToDisk(string path) => + _logger.LogDebug("Writing the certificate to: {Path}.", path); + + // Event 28 - Error + public void WriteCertificateToDiskError(string error) => + _logger.LogError("An error has occurred while writing the certificate to disk: {Error}.", error); + + // Event 29 - Verbose + public void TrustCertificateStart(string certificate) => + _logger.LogDebug("Trusting the certificate to: {Certificate}.", certificate); + + // Event 30 - Verbose + public void TrustCertificateEnd() => + _logger.LogDebug("Finished trusting the certificate."); + + // Event 31 - Error + public void TrustCertificateError(string error) => + _logger.LogError("An error has occurred while trusting the certificate: {Error}.", error); + + // Event 32 - Verbose + public void MacOSTrustCommandStart(string command) => + _logger.LogDebug("Running the trust command {Command}.", command); + + // Event 33 - Verbose + public void MacOSTrustCommandEnd() => + _logger.LogDebug("Finished running the trust command."); + + // Event 34 - Warning + public void MacOSTrustCommandError(int exitCode) => + _logger.LogWarning("An error has occurred while running the trust command: {ExitCode}.", exitCode); + + // Event 35 - Verbose + public void MacOSRemoveCertificateTrustRuleStart(string certificate) => + _logger.LogDebug("Running the remove trust command for {Certificate}.", certificate); + + // Event 36 - Verbose + public void MacOSRemoveCertificateTrustRuleEnd() => + _logger.LogDebug("Finished running the remove trust command."); + + // Event 37 - Warning + public void MacOSRemoveCertificateTrustRuleError(int exitCode) => + _logger.LogWarning("An error has occurred while running the remove trust command: {ExitCode}.", exitCode); + + // Event 38 - Verbose + public void MacOSCertificateUntrusted(string certificate) => + _logger.LogDebug("The certificate is not trusted: {Certificate}.", certificate); + + // Event 39 - Verbose + public void MacOSRemoveCertificateFromKeyChainStart(string keyChain, string certificate) => + _logger.LogDebug("Removing the certificate from the keychain {KeyChain} {Certificate}.", keyChain, certificate); + + // Event 40 - Verbose + public void MacOSRemoveCertificateFromKeyChainEnd() => + _logger.LogDebug("Finished removing the certificate from the keychain."); + + // Event 41 - Warning + public void MacOSRemoveCertificateFromKeyChainError(int exitCode) => + _logger.LogWarning("An error has occurred while running the remove trust command: {ExitCode}.", exitCode); + + // Event 42 - Verbose + public void RemoveCertificateFromUserStoreStart(string certificate) => + _logger.LogDebug("Removing the certificate from the user store {Certificate}.", certificate); + + // Event 43 - Verbose + public void RemoveCertificateFromUserStoreEnd() => + _logger.LogDebug("Finished removing the certificate from the user store."); + + // Event 44 - Error + public void RemoveCertificateFromUserStoreError(string error) => + _logger.LogError("An error has occurred while removing the certificate from the user store: {Error}.", error); + + // Event 45 - Verbose + public void WindowsAddCertificateToRootStore() => + _logger.LogDebug("Adding certificate to the trusted root certification authority store."); + + // Event 46 - Verbose + public void WindowsCertificateAlreadyTrusted() => + _logger.LogDebug("The certificate is already trusted."); + + // Event 47 - Verbose + public void WindowsCertificateTrustCanceled() => + _logger.LogDebug("Trusting the certificate was cancelled by the user."); + + // Event 48 - Verbose + public void WindowsRemoveCertificateFromRootStoreStart() => + _logger.LogDebug("Removing the certificate from the trusted root certification authority store."); + + // Event 49 - Verbose + public void WindowsRemoveCertificateFromRootStoreEnd() => + _logger.LogDebug("Finished removing the certificate from the trusted root certification authority store."); + + // Event 50 - Verbose + public void WindowsRemoveCertificateFromRootStoreNotFound() => + _logger.LogDebug("The certificate was not trusted."); + + // Event 51 - Verbose + public void CorrectCertificateStateStart(string certificate) => + _logger.LogDebug("Correcting the the certificate state for '{Certificate}'.", certificate); + + // Event 52 - Verbose + public void CorrectCertificateStateEnd() => + _logger.LogDebug("Finished correcting the certificate state."); + + // Event 53 - Error + public void CorrectCertificateStateError(string error) => + _logger.LogError("An error has occurred while correcting the certificate state: {Error}.", error); + + // Event 54 - Verbose + internal void MacOSAddCertificateToKeyChainStart(string keychain, string certificate) => + _logger.LogDebug("Importing the certificate {Certificate} to the keychain '{Keychain}'.", certificate, keychain); + + // Event 55 - Verbose + internal void MacOSAddCertificateToKeyChainEnd() => + _logger.LogDebug("Finished importing the certificate to the keychain."); + + // Event 56 - Error + internal void MacOSAddCertificateToKeyChainError(int exitCode, string output) => + _logger.LogError("An error has occurred while importing the certificate to the keychain: {ExitCode}, {Output}", exitCode, output); + + // Event 57 - Verbose + public void WritePemKeyToDisk(string path) => + _logger.LogDebug("Writing the certificate to: {Path}.", path); + + // Event 58 - Error + public void WritePemKeyToDiskError(string error) => + _logger.LogError("An error has occurred while writing the certificate to disk: {Error}.", error); + + // Event 59 - Error + internal void ImportCertificateMissingFile(string certificatePath) => + _logger.LogError("The file '{CertificatePath}' does not exist.", certificatePath); + + // Event 60 - Error + internal void ImportCertificateExistingCertificates(string certificateDescription) => + _logger.LogError("One or more HTTPS certificates exist '{CertificateDescription}'.", certificateDescription); + + // Event 61 - Verbose + internal void LoadCertificateStart(string certificatePath) => + _logger.LogDebug("Loading certificate from path '{CertificatePath}'.", certificatePath); + + // Event 62 - Verbose + internal void LoadCertificateEnd(string description) => + _logger.LogDebug("The certificate '{Description}' has been loaded successfully.", description); + + // Event 63 - Error + internal void LoadCertificateError(string error) => + _logger.LogError("An error has occurred while loading the certificate from disk: {Error}.", error); + + // Event 64 - Error + internal void NoHttpsDevelopmentCertificate(string description) => + _logger.LogError("The provided certificate '{Description}' is not a valid ASP.NET Core HTTPS development certificate.", description); + + // Event 65 - Verbose + public void MacOSCertificateAlreadyTrusted() => + _logger.LogDebug("The certificate is already trusted."); + + // Event 66 - Verbose + internal void MacOSAddCertificateToUserProfileDirStart(string directory, string certificate) => + _logger.LogDebug("Saving the certificate {Certificate} to the user profile folder '{Directory}'.", certificate, directory); + + // Event 67 - Verbose + internal void MacOSAddCertificateToUserProfileDirEnd() => + _logger.LogDebug("Finished saving the certificate to the user profile folder."); + + // Event 68 - Error + internal void MacOSAddCertificateToUserProfileDirError(string certificateThumbprint, string errorMessage) => + _logger.LogError("An error has occurred while saving certificate '{CertificateThumbprint}' in the user profile folder: {ErrorMessage}.", certificateThumbprint, errorMessage); + + // Event 69 - Error + internal void MacOSRemoveCertificateFromUserProfileDirError(string certificateThumbprint, string errorMessage) => + _logger.LogError("An error has occurred while removing certificate '{CertificateThumbprint}' from the user profile folder: {ErrorMessage}.", certificateThumbprint, errorMessage); + + // Event 70 - Error + internal void MacOSFileIsNotAValidCertificate(string path) => + _logger.LogError("The file '{Path}' is not a valid certificate.", path); + + // Event 71 - Warning + internal void MacOSDiskStoreDoesNotExist() => + _logger.LogWarning("The on-disk store directory was not found."); + + // Event 72 - Verbose + internal void UnixOpenSslCertificateDirectoryOverridePresent(string nssDbOverrideVariableName) => + _logger.LogDebug("Reading OpenSSL trusted certificates location from {NssDbOverrideVariableName}.", nssDbOverrideVariableName); + + // Event 73 - Verbose + internal void UnixNssDbOverridePresent(string environmentVariable) => + _logger.LogDebug("Reading NSS database locations from {EnvironmentVariable}.", environmentVariable); + + // Event 74 - Warning + internal void UnixNssDbDoesNotExist(string nssDb, string environmentVariable) => + _logger.LogWarning("The NSS database '{NssDb}' provided via {EnvironmentVariable} does not exist.", nssDb, environmentVariable); + + // Event 75 - Warning + internal void UnixNotTrustedByDotnet() => + _logger.LogWarning("The certificate is not trusted by .NET. This will likely affect System.Net.Http.HttpClient."); + + // Event 76 - Warning + internal void UnixNotTrustedByOpenSsl(string envVarName) => + _logger.LogWarning("The certificate is not trusted by OpenSSL. Ensure that the {EnvVarName} environment variable is set correctly.", envVarName); + + // Event 77 - Warning + internal void UnixNotTrustedByNss(string path, string browser) => + _logger.LogWarning("The certificate is not trusted in the NSS database in '{Path}'. This will likely affect the {Browser} family of browsers.", path, browser); + + // Event 78 - Verbose + internal void UnixHomeDirectoryDoesNotExist(string homeDirectory, string username) => + _logger.LogDebug("Home directory '{HomeDirectory}' does not exist. Unable to discover NSS databases for user '{Username}'. This will likely affect browsers.", homeDirectory, username); + + // Event 79 - Verbose + internal void UnixOpenSslVersionParsingFailed() => + _logger.LogDebug("OpenSSL reported its directory in an unexpected format."); + + // Event 80 - Verbose + internal void UnixOpenSslVersionFailed() => + _logger.LogDebug("Unable to determine the OpenSSL directory."); + + // Event 81 - Verbose + internal void UnixOpenSslVersionException(string exceptionMessage) => + _logger.LogDebug("Unable to determine the OpenSSL directory: {ExceptionMessage}.", exceptionMessage); + + // Event 82 - Error + internal void UnixOpenSslHashFailed(string certificatePath) => + _logger.LogError("Unable to compute the hash of certificate {CertificatePath}. OpenSSL trust is likely in an inconsistent state.", certificatePath); + + // Event 83 - Error + internal void UnixOpenSslHashException(string certificatePath, string exceptionMessage) => + _logger.LogError("Unable to compute the certificate hash: {CertificatePath}. OpenSSL trust is likely in an inconsistent state. {ExceptionMessage}", certificatePath, exceptionMessage); + + // Event 84 - Error + internal void UnixOpenSslRehashTooManyHashes(string fullName, string hash, int maxHashCollisions) => + _logger.LogError("Unable to update certificate '{FullName}' in the OpenSSL trusted certificate hash collection - {MaxHashCollisions} certificates have the hash {Hash}.", fullName, maxHashCollisions, hash); + + // Event 85 - Error + internal void UnixOpenSslRehashException(string exceptionMessage) => + _logger.LogError("Unable to update the OpenSSL trusted certificate hash collection: {ExceptionMessage}. Manually rehashing may help. See https://aka.ms/dev-certs-trust for more information.", exceptionMessage); + + // Event 86 - Warning + internal void UnixDotnetTrustException(string exceptionMessage) => + _logger.LogWarning("Failed to trust the certificate in .NET: {ExceptionMessage}.", exceptionMessage); + + // Event 87 - Verbose + internal void UnixDotnetTrustSucceeded() => + _logger.LogDebug("Trusted the certificate in .NET."); + + // Event 88 - Warning + internal void UnixOpenSslTrustFailed() => + _logger.LogWarning("Clients that validate certificate trust using OpenSSL will not trust the certificate."); + + // Event 89 - Verbose + internal void UnixOpenSslTrustSucceeded() => + _logger.LogDebug("Trusted the certificate in OpenSSL."); + + // Event 90 - Warning + internal void UnixNssDbTrustFailed(string path, string browser) => + _logger.LogWarning("Failed to trust the certificate in the NSS database in '{Path}'. This will likely affect the {Browser} family of browsers.", path, browser); + + // Event 91 - Verbose + internal void UnixNssDbTrustSucceeded(string path) => + _logger.LogDebug("Trusted the certificate in the NSS database in '{Path}'.", path); + + // Event 92 - Warning + internal void UnixDotnetUntrustException(string exceptionMessage) => + _logger.LogWarning("Failed to untrust the certificate in .NET: {ExceptionMessage}.", exceptionMessage); + + // Event 93 - Warning + internal void UnixOpenSslUntrustFailed() => + _logger.LogWarning("Failed to untrust the certificate in OpenSSL."); + + // Event 94 - Verbose + internal void UnixOpenSslUntrustSucceeded() => + _logger.LogDebug("Untrusted the certificate in OpenSSL."); + + // Event 95 - Warning + internal void UnixNssDbUntrustFailed(string path) => + _logger.LogWarning("Failed to remove the certificate from the NSS database in '{Path}'.", path); + + // Event 96 - Verbose + internal void UnixNssDbUntrustSucceeded(string path) => + _logger.LogDebug("Removed the certificate from the NSS database in '{Path}'.", path); + + // Event 97 - Warning + internal void UnixTrustPartiallySucceeded() => + _logger.LogWarning("The certificate is only partially trusted - some clients will not accept it."); + + // Event 98 - Warning + internal void UnixNssDbCheckException(string path, string exceptionMessage) => + _logger.LogWarning("Failed to look up the certificate in the NSS database in '{Path}': {ExceptionMessage}.", path, exceptionMessage); + + // Event 99 - Warning + internal void UnixNssDbAdditionException(string path, string exceptionMessage) => + _logger.LogWarning("Failed to add the certificate to the NSS database in '{Path}': {ExceptionMessage}.", path, exceptionMessage); + + // Event 100 - Warning + internal void UnixNssDbRemovalException(string path, string exceptionMessage) => + _logger.LogWarning("Failed to remove the certificate from the NSS database in '{Path}': {ExceptionMessage}.", path, exceptionMessage); + + // Event 101 - Warning + internal void UnixFirefoxProfileEnumerationException(string firefoxDirectory, string message) => + _logger.LogWarning("Failed to find the Firefox profiles in directory '{FirefoxDirectory}': {Message}.", firefoxDirectory, message); + + // Event 102 - Verbose + internal void UnixNoFirefoxProfilesFound(string firefoxDirectory) => + _logger.LogDebug("No Firefox profiles found in directory '{FirefoxDirectory}'.", firefoxDirectory); + + // Event 103 - Warning + internal void UnixNssDbTrustFailedWithProbableConflict(string path, string browser) => + _logger.LogWarning("Failed to trust the certificate in the NSS database in '{Path}'. This will likely affect the {Browser} family of browsers. This likely indicates that the database already contains an entry for the certificate under a different name. Please remove it and try again.", path, browser); + + // Event 104 - Warning + internal void UnixOpenSslCertificateDirectoryOverrideIgnored(string openSslCertDirectoryOverrideVariableName) => + _logger.LogWarning("The {OpenSslCertDirectoryOverrideVariableName} environment variable is set but will not be consumed while checking trust.", openSslCertDirectoryOverrideVariableName); + + // Event 105 - Warning + internal void UnixMissingOpenSslCommand(string openSslCommand) => + _logger.LogWarning("The {OpenSslCommand} command is unavailable. It is required for updating certificate trust in OpenSSL.", openSslCommand); + + // Event 106 - Warning + internal void UnixMissingCertUtilCommand(string certUtilCommand) => + _logger.LogWarning("The {CertUtilCommand} command is unavailable. It is required for querying and updating NSS databases, which are chiefly used to trust certificates in browsers.", certUtilCommand); + + // Event 107 - Verbose + internal void UnixOpenSslUntrustSkipped(string certPath) => + _logger.LogDebug("Untrusting the certificate in OpenSSL was skipped since '{CertPath}' does not exist.", certPath); + + // Event 108 - Warning + internal void UnixCertificateFileDeletionException(string certPath, string exceptionMessage) => + _logger.LogWarning("Failed to delete certificate file '{CertPath}': {ExceptionMessage}.", certPath, exceptionMessage); + + // Event 109 - Error + internal void UnixNotOverwritingCertificate(string certPath) => + _logger.LogError("Unable to export the certificate since '{CertPath}' already exists. Please remove it.", certPath); + + // Event 110 - LogAlways (Info) + internal void UnixSuggestSettingEnvironmentVariable(string certDir, string openSslDir, string envVarName) => + _logger.LogInformation("For OpenSSL trust to take effect, '{CertDir}' must be listed in the {EnvVarName} environment variable. For example, `export {EnvVarName}=\"{CertDir}:{OpenSslDir}\"`. See https://aka.ms/dev-certs-trust for more information.", certDir, envVarName, envVarName, certDir, openSslDir); + + // Event 111 - LogAlways (Info) + internal void UnixSuggestSettingEnvironmentVariableWithoutExample(string certDir, string envVarName) => + _logger.LogInformation("For OpenSSL trust to take effect, '{CertDir}' must be listed in the {EnvVarName} environment variable. See https://aka.ms/dev-certs-trust for more information.", certDir, envVarName); + + // Event 112 - Warning + internal void DirectoryPermissionsNotSecure(string directoryPath) => + _logger.LogWarning("Directory '{DirectoryPath}' may be readable by other users.", directoryPath); + + // Event 113 - Verbose + internal void UnixOpenSslCertificateDirectoryAlreadyConfigured(string certDir, string envVarName) => + _logger.LogDebug("The certificate directory '{CertDir}' is already included in the {EnvVarName} environment variable.", certDir, envVarName); + + // Event 114 - LogAlways (Info) + internal void UnixSuggestAppendingToEnvironmentVariable(string certDir, string envVarName) => + _logger.LogInformation("For OpenSSL trust to take effect, '{CertDir}' must be listed in the {EnvVarName} environment variable. For example, `export {EnvVarName}=\"{CertDir}:${EnvVarName}\"`. See https://aka.ms/dev-certs-trust for more information.", certDir, envVarName, envVarName, certDir, envVarName); + + // Event 115 - Verbose + internal void WslWindowsTrustSucceeded() => + _logger.LogDebug("Successfully trusted the certificate in the Windows certificate store via WSL."); + + // Event 116 - Warning + internal void WslWindowsTrustFailed() => + _logger.LogWarning("Failed to trust the certificate in the Windows certificate store via WSL."); + + // Event 117 - Warning + internal void WslWindowsTrustException(string exceptionMessage) => + _logger.LogWarning("Failed to trust the certificate in the Windows certificate store via WSL: {ExceptionMessage}.", exceptionMessage); + + // Event 118 - Verbose + public void DescribeMinimumVersionCertificates(string meetsMinimumVersionCertificates) => + _logger.LogDebug("Meets minimum version certificates: {MeetsMinimumVersionCertificates}", meetsMinimumVersionCertificates); + + // Event 119 - Verbose + public void DescribeBelowMinimumVersionCertificates(string belowMinimumVersionCertificates) => + _logger.LogDebug("Below minimum version certificates: {BelowMinimumVersionCertificates}", belowMinimumVersionCertificates); + } + + internal sealed class UserCancelledTrustException : Exception + { + } + + internal readonly struct CheckCertificateStateResult + { + public bool Success { get; } + public string? FailureMessage { get; } + + public CheckCertificateStateResult(bool success, string? failureMessage) + { + Success = success; + FailureMessage = failureMessage; + } + } + + internal enum RemoveLocations + { + Undefined, + Local, + Trusted, + All + } + + internal enum TrustLevel + { + /// No trust has been granted. + None, + /// Trust has been granted in some, but not all, clients. + Partial, + /// Trust has been granted in all clients. + Full, + } +} diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/CertificatePurpose.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificatePurpose.cs new file mode 100644 index 00000000000..7abe411dbd8 --- /dev/null +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificatePurpose.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Certificates.Generation; + +internal enum CertificatePurpose +{ + All, + HTTPS +} diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/EnsureCertificateResult.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/EnsureCertificateResult.cs new file mode 100644 index 00000000000..5c28eaca306 --- /dev/null +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/EnsureCertificateResult.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. + +namespace Microsoft.AspNetCore.Certificates.Generation; + +internal enum EnsureCertificateResult +{ + Succeeded = 1, + ValidCertificatePresent, + ErrorCreatingTheCertificate, + ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore, + ErrorExportingTheCertificate, + ErrorExportingTheCertificateToNonExistentDirectory, + FailedToTrustTheCertificate, + PartiallyFailedToTrustTheCertificate, + UserCancelledTrustStep, + FailedToMakeKeyAccessible, + ExistingHttpsCertificateTrusted, + NewHttpsCertificateTrusted +} + diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/ImportCertificateResult.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/ImportCertificateResult.cs new file mode 100644 index 00000000000..53706d8ce88 --- /dev/null +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/ImportCertificateResult.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Certificates.Generation; + +internal enum ImportCertificateResult +{ + Succeeded = 1, + CertificateFileMissing, + InvalidCertificate, + NoDevelopmentHttpsCertificate, + ExistingCertificatesPresent, + ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore, +} + diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs new file mode 100644 index 00000000000..cce4cc10ce9 --- /dev/null +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs @@ -0,0 +1,497 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Certificates.Generation; + +/// +/// Normally, we avoid the use of because it's a SHA-1 hash and, therefore, +/// not adequate for security applications. However, the MacOS security tool uses SHA-1 hashes for certificate +/// identification, so we're stuck. +/// +internal sealed class MacOSCertificateManager : CertificateManager +{ + private const UnixFileMode DirectoryPermissions = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + // User keychain. Guard with quotes when using in command lines since users may have set + // their user profile (HOME) directory to a non-standard path that includes whitespace. + private static readonly string s_macOSUserKeychain = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/Library/Keychains/login.keychain-db"; + + // System keychain. We no longer store certificates or create trust rules in the system + // keychain, but check for their presence here so that we can clean up state left behind + // by pre-.NET 7 versions of this tool. + private const string MacOSSystemKeychain = "/Library/Keychains/System.keychain"; + + // Well-known location on disk where dev-certs are stored. + private static readonly string s_macOSUserHttpsCertificateLocation = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspnet", "dev-certs", "https"); + + // Verify the certificate {0} for the SSL and X.509 Basic Policy. + private const string MacOSVerifyCertificateCommandLine = "security"; + private const string MacOSVerifyCertificateCommandLineArgumentsFormat = "verify-cert -c \"{0}\" -p basic -p ssl"; + + // Delete a certificate with the specified SHA-256 (or SHA-1) hash {0} from keychain {1}. + private const string MacOSDeleteCertificateCommandLine = "sudo"; + private const string MacOSDeleteCertificateCommandLineArgumentsFormat = "security delete-certificate -Z {0} \"{1}\""; + + // Add a certificate to the per-user trust settings in the user keychain. The trust policy + // for the certificate will be set to be always trusted for SSL and X.509 Basic Policy. + // Note: This operation will require user authentication. + private const string MacOSTrustCertificateCommandLine = "security"; + private static readonly string s_macOSTrustCertificateCommandLineArguments = $"add-trusted-cert -p basic -p ssl -k \"{s_macOSUserKeychain}\" "; + + // Import a pkcs12 certificate into the user keychain using the unwrapping passphrase {1}, and + // allow any application to access the imported key without warning. + private const string MacOSAddCertificateToKeyChainCommandLine = "security"; + private static readonly string s_macOSAddCertificateToKeyChainCommandLineArgumentsFormat = "import \"{0}\" -k \"" + s_macOSUserKeychain + "\" -t cert -f pkcs12 -P {1} -A"; + + // Remove a certificate from the admin trust settings. We no longer add certificates to the + // admin trust settings, but need this for cleaning up certs generated by pre-.NET 7 versions + // of this tool that used to create trust settings in the system keychain. + // Note: This operation will require user authentication. + private const string MacOSUntrustLegacyCertificateCommandLine = "sudo"; + private const string MacOSUntrustLegacyCertificateCommandLineArguments = "security remove-trusted-cert -d \"{0}\""; + + // Find all matching certificates on the keychain {1} that have the name {0} and print + // print their SHA-256 and SHA-1 hashes. + private const string MacOSFindCertificateOnKeychainCommandLine = "security"; + private const string MacOSFindCertificateOnKeychainCommandLineArgumentsFormat = "find-certificate -c {0} -a -Z -p \"{1}\""; + + // Format used by the tool when printing SHA-1 hashes. + private const string MacOSFindCertificateOutputRegex = "SHA-1 hash: ([0-9A-Z]+)"; + + public const string InvalidCertificateState = + "The ASP.NET Core developer certificate is in an invalid state. " + + "To fix this issue, run 'dotnet dev-certs https --clean' and 'dotnet dev-certs https' " + + "to remove all existing ASP.NET Core development certificates " + + "and create a new untrusted developer certificate. " + + "Use 'dotnet dev-certs https --trust' to trust the new certificate."; + + public MacOSCertificateManager(ILogger logger) : base(logger) + { + } + + internal MacOSCertificateManager(string subject, int version) + : base(subject, version) + { + } + + protected override TrustLevel TrustCertificateCore(X509Certificate2 publicCertificate) + { + var oldTrustLevel = GetTrustLevel(publicCertificate); + if (oldTrustLevel != TrustLevel.None) + { + Debug.Assert(oldTrustLevel == TrustLevel.Full); // Mac trust is all or nothing + Log.MacOSCertificateAlreadyTrusted(); + return oldTrustLevel; + } + + var tmpFile = Path.GetTempFileName(); + try + { + // We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key + ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pfx); + if (Log.IsEnabled()) + { + Log.MacOSTrustCommandStart($"{MacOSTrustCertificateCommandLine} {s_macOSTrustCertificateCommandLineArguments}{tmpFile}"); + } + using (var process = Process.Start(MacOSTrustCertificateCommandLine, s_macOSTrustCertificateCommandLineArguments + tmpFile)) + { + process.WaitForExit(); + if (process.ExitCode != 0) + { + Log.MacOSTrustCommandError(process.ExitCode); + throw new InvalidOperationException("There was an error trusting the certificate."); + } + } + Log.MacOSTrustCommandEnd(); + return TrustLevel.Full; + } + finally + { + try + { + File.Delete(tmpFile); + } + catch + { + // We don't care if we can't delete the temp file. + } + } + } + + internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate) + { + return File.Exists(GetCertificateFilePath(candidate)) ? + new CheckCertificateStateResult(true, null) : + new CheckCertificateStateResult(false, InvalidCertificateState); + } + + internal override void CorrectCertificateState(X509Certificate2 candidate) + { + try + { + // This path is in a well-known folder, so we trust the permissions. + var certificatePath = GetCertificateFilePath(candidate); + ExportCertificate(candidate, certificatePath, includePrivateKey: true, null, CertificateKeyExportFormat.Pfx); + } + catch (Exception ex) + { + Log.MacOSAddCertificateToUserProfileDirError(candidate.Thumbprint, ex.Message); + } + } + + // Use verify-cert to verify the certificate for the SSL and X.509 Basic Policy. + public override TrustLevel GetTrustLevel(X509Certificate2 certificate) + { + var tmpFile = Path.GetTempFileName(); + try + { + // We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key + ExportCertificate(certificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem); + + using var checkTrustProcess = Process.Start(new ProcessStartInfo( + MacOSVerifyCertificateCommandLine, + string.Format(CultureInfo.InvariantCulture, MacOSVerifyCertificateCommandLineArgumentsFormat, tmpFile)) + { + RedirectStandardOutput = true, + // Do this to avoid showing output to the console when the cert is not trusted. It is trivial to export + // the cert and replicate the command to see details. + RedirectStandardError = true, + }); + checkTrustProcess!.WaitForExit(); + return checkTrustProcess.ExitCode == 0 ? TrustLevel.Full : TrustLevel.None; + } + finally + { + File.Delete(tmpFile); + } + } + + protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate) + { + if (IsCertOnKeychain(MacOSSystemKeychain, certificate)) + { + // Pre-.NET 7 versions of this tool used to store certs and trust settings on the + // system keychain. Check if that's the case for this cert, and if so, remove the + // trust rule and the cert from the system keychain. + try + { + RemoveAdminTrustRule(certificate); + RemoveCertificateFromKeychain(MacOSSystemKeychain, certificate); + } + catch + { + } + } + + RemoveCertificateFromUserStoreCore(certificate); + } + + // Remove the certificate from the admin trust settings. + private void RemoveAdminTrustRule(X509Certificate2 certificate) + { + Log.MacOSRemoveCertificateTrustRuleStart(GetDescription(certificate)); + var certificatePath = Path.GetTempFileName(); + try + { + var certBytes = certificate.Export(X509ContentType.Cert); + File.WriteAllBytes(certificatePath, certBytes); + var processInfo = new ProcessStartInfo( + MacOSUntrustLegacyCertificateCommandLine, + string.Format( + CultureInfo.InvariantCulture, + MacOSUntrustLegacyCertificateCommandLineArguments, + certificatePath + )); + + using var process = Process.Start(processInfo); + process!.WaitForExit(); + + if (process.ExitCode != 0) + { + Log.MacOSRemoveCertificateTrustRuleError(process.ExitCode); + } + + Log.MacOSRemoveCertificateTrustRuleEnd(); + } + finally + { + try + { + File.Delete(certificatePath); + } + catch + { + // We don't care if we can't delete the temp file. + } + } + } + + private void RemoveCertificateFromKeychain(string keychain, X509Certificate2 certificate) + { + var processInfo = new ProcessStartInfo( + MacOSDeleteCertificateCommandLine, + string.Format( + CultureInfo.InvariantCulture, + MacOSDeleteCertificateCommandLineArgumentsFormat, + certificate.Thumbprint.ToUpperInvariant(), + keychain + )) + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + if (Log.IsEnabled()) + { + Log.MacOSRemoveCertificateFromKeyChainStart(keychain, GetDescription(certificate)); + } + + using (var process = Process.Start(processInfo)) + { + var output = process!.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + Log.MacOSRemoveCertificateFromKeyChainError(process.ExitCode); + throw new InvalidOperationException($@"There was an error removing the certificate with thumbprint '{certificate.Thumbprint}'. + +{output}"); + } + } + + Log.MacOSRemoveCertificateFromKeyChainEnd(); + } + + private static bool IsCertOnKeychain(string keychain, X509Certificate2 certificate) + { + var maxRegexTimeout = TimeSpan.FromMinutes(1); + const string CertificateSubjectRegex = "CN=(.*[^,]+).*"; + + var subjectMatch = Regex.Match(certificate.Subject, CertificateSubjectRegex, RegexOptions.Singleline, maxRegexTimeout); + if (!subjectMatch.Success) + { + throw new InvalidOperationException($"Can't determine the subject for the certificate with subject '{certificate.Subject}'."); + } + + var subject = subjectMatch.Groups[1].Value; + + // Run the find-certificate command, and look for the cert's hash in the output + using var findCertificateProcess = Process.Start(new ProcessStartInfo( + MacOSFindCertificateOnKeychainCommandLine, + string.Format(CultureInfo.InvariantCulture, MacOSFindCertificateOnKeychainCommandLineArgumentsFormat, subject, keychain)) + { + RedirectStandardOutput = true + }); + + var output = findCertificateProcess!.StandardOutput.ReadToEnd(); + findCertificateProcess.WaitForExit(); + + var matches = Regex.Matches(output, MacOSFindCertificateOutputRegex, RegexOptions.Multiline, maxRegexTimeout); + var hashes = matches.OfType().Select(m => m.Groups[1].Value).ToList(); + + return hashes.Any(h => string.Equals(h, certificate.Thumbprint, StringComparison.Ordinal)); + } + + // We don't have a good way of checking on the underlying implementation if it is exportable, so just return true. + internal override bool IsExportable(X509Certificate2 c) => true; + + protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation) + { + SaveCertificateToUserKeychain(certificate); + + try + { + var certBytes = certificate.Export(X509ContentType.Pfx); + + if (Log.IsEnabled()) + { + Log.MacOSAddCertificateToUserProfileDirStart(s_macOSUserKeychain, GetDescription(certificate)); + } + + // Ensure that the directory exists before writing to the file. + CreateDirectoryWithPermissions(s_macOSUserHttpsCertificateLocation); + + File.WriteAllBytes(GetCertificateFilePath(certificate), certBytes); + } + catch (Exception ex) + { + Log.MacOSAddCertificateToUserProfileDirError(certificate.Thumbprint, ex.Message); + } + + Log.MacOSAddCertificateToKeyChainEnd(); + Log.MacOSAddCertificateToUserProfileDirEnd(); + + return certificate; + } + + private void SaveCertificateToUserKeychain(X509Certificate2 certificate) + { + var passwordBytes = new byte[48]; + RandomNumberGenerator.Fill(passwordBytes.AsSpan()[0..35]); + var password = Convert.ToBase64String(passwordBytes, 0, 36); + var certBytes = certificate.Export(X509ContentType.Pfx, password); + var certificatePath = Path.GetTempFileName(); + File.WriteAllBytes(certificatePath, certBytes); + + var processInfo = new ProcessStartInfo( + MacOSAddCertificateToKeyChainCommandLine, + string.Format(CultureInfo.InvariantCulture, s_macOSAddCertificateToKeyChainCommandLineArgumentsFormat, certificatePath, password)) + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + if (Log.IsEnabled()) + { + Log.MacOSAddCertificateToKeyChainStart(s_macOSUserKeychain, GetDescription(certificate)); + } + + using (var process = Process.Start(processInfo)) + { + var output = process!.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + Log.MacOSAddCertificateToKeyChainError(process.ExitCode, output); + throw new InvalidOperationException("Failed to add the certificate to the keychain. Are you running in a non-interactive session perhaps?"); + } + } + + Log.MacOSAddCertificateToKeyChainEnd(); + } + + private static string GetCertificateFilePath(X509Certificate2 certificate) => + Path.Combine(s_macOSUserHttpsCertificateLocation, $"aspnetcore-localhost-{certificate.Thumbprint}.pfx"); + + protected override IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation) + { + return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false); + } + + protected override void PopulateCertificatesFromStore(X509Store store, List certificates, bool requireExportable) + { + if (store.Name! == StoreName.My.ToString() && store.Location == StoreLocation.CurrentUser && Directory.Exists(s_macOSUserHttpsCertificateLocation)) + { + var certsFromDisk = GetCertsFromDisk(); + + var certsFromStore = new List(); + base.PopulateCertificatesFromStore(store, certsFromStore, requireExportable); + + // Certs created by pre-.NET 7. + var onlyOnKeychain = certsFromStore.Except(certsFromDisk, ThumbprintComparer.Instance); + + // Certs created (or "upgraded") by .NET 7+. + // .NET 7+ installs the certificate on disk as well as on the user keychain (for backwards + // compatibility with pre-.NET 7). + // Note that if we require exportable certs, the actual certs we populate need to be the ones + // from the store location, and not the version from disk. If we don't require exportability, + // we favor the version of the cert that's on disk (avoiding unnecessary keychain access + // prompts). Intersect compares with the specified comparer and returns the matching elements + // from the first set. + var onDiskAndKeychain = requireExportable ? certsFromStore.Intersect(certsFromDisk, ThumbprintComparer.Instance) + : certsFromDisk.Intersect(certsFromStore, ThumbprintComparer.Instance); + + // The only times we can find a certificate on the keychain and a certificate on keychain+disk + // are when the certificate on disk and keychain has expired and a pre-.NET 7 SDK has been + // used to create a new certificate, or when a pre-.NET 7 certificate has expired and .NET 7+ + // has been used to create a new certificate. In both cases, the caller filters the invalid + // certificates out, so only the valid certificate is selected. + certificates.AddRange(onlyOnKeychain); + certificates.AddRange(onDiskAndKeychain); + } + else + { + base.PopulateCertificatesFromStore(store, certificates, requireExportable); + } + } + + private sealed class ThumbprintComparer : IEqualityComparer + { + public static readonly IEqualityComparer Instance = new ThumbprintComparer(); + +#pragma warning disable CS8769 // Nullability of reference types in type of parameter doesn't match implemented member (possibly because of nullability attributes). + bool IEqualityComparer.Equals(X509Certificate2 x, X509Certificate2 y) => + EqualityComparer.Default.Equals(x?.Thumbprint, y?.Thumbprint); +#pragma warning restore CS8769 // Nullability of reference types in type of parameter doesn't match implemented member (possibly because of nullability attributes). + + int IEqualityComparer.GetHashCode([DisallowNull] X509Certificate2 obj) => + EqualityComparer.Default.GetHashCode(obj.Thumbprint); + } + + private ICollection GetCertsFromDisk() + { + var certsFromDisk = new List(); + if (!Directory.Exists(s_macOSUserHttpsCertificateLocation)) + { + Log.MacOSDiskStoreDoesNotExist(); + } + else + { + var certificateFiles = Directory.EnumerateFiles(s_macOSUserHttpsCertificateLocation, "aspnetcore-localhost-*.pfx"); + foreach (var file in certificateFiles) + { + try + { + var certificate = X509CertificateLoader.LoadPkcs12FromFile(file, password: null); + certsFromDisk.Add(certificate); + } + catch (Exception) + { + Log.MacOSFileIsNotAValidCertificate(file); + throw; + } + } + } + + return certsFromDisk; + } + + protected override void RemoveCertificateFromUserStoreCore(X509Certificate2 certificate) + { + try + { + var certificatePath = GetCertificateFilePath(certificate); + if (File.Exists(certificatePath)) + { + File.Delete(certificatePath); + } + } + catch (Exception ex) + { + Log.MacOSRemoveCertificateFromUserProfileDirError(certificate.Thumbprint, ex.Message); + } + + if (IsCertOnKeychain(s_macOSUserKeychain, certificate)) + { + RemoveCertificateFromKeychain(s_macOSUserKeychain, certificate); + } + } + + protected override void CreateDirectoryWithPermissions(string directoryPath) + { +#pragma warning disable CA1416 // Validate platform compatibility (not supported on Windows) + var dirInfo = new DirectoryInfo(directoryPath); + if (dirInfo.Exists) + { + if ((dirInfo.UnixFileMode & ~DirectoryPermissions) != 0) + { + Log.DirectoryPermissionsNotSecure(dirInfo.FullName); + } + } + else + { + Directory.CreateDirectory(directoryPath, DirectoryPermissions); + } +#pragma warning restore CA1416 // Validate platform compatibility + } +} diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/README.md b/src/Aspire.Cli/Certificates/CertificateGeneration/README.md new file mode 100644 index 00000000000..e7b3d9070c2 --- /dev/null +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/README.md @@ -0,0 +1,20 @@ +# CertificateGeneration (Vendored from ASP.NET Core) + +This directory contains code vendored from the ASP.NET Core repository's shared `CertificateGeneration` library. + +**Source:** https://github.com/dotnet/aspnetcore/tree/main/src/Shared/CertificateGeneration + +**Last synced:** 2026-02-24 from commit [`3a973a5f4d28242262f27c86eb3f14299fe712ba`](https://github.com/dotnet/aspnetcore/commit/3a973a5f4d28242262f27c86eb3f14299fe712ba) — "Fix memory leaks in CertificateManager by improving certificate disposal patterns (#63321)" + +## Local modifications + +- Replaced `EventSource`-based logging with `ILogger`/`CertificateManagerLogger` wrapper (AOT-compatible) +- Removed static `Instance` pattern; uses `CertificateManager.Create(ILogger)` factory +- Added instance `Log` property backed by `ILogger` +- Changed `GetDescription` and `ToCertificateDescription` from `static` to instance methods +- Removed `catch when (Log.IsEnabled())` filter pattern (incompatible with ILogger) +- Replaced `new X509Certificate2(...)` with `X509CertificateLoader.LoadPkcs12FromFile(...)` (fixes SYSLIB0057) + +## Updating + +When syncing with upstream, apply the diff from the upstream commit(s) manually, preserving our local modifications listed above. diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs new file mode 100644 index 00000000000..d5efbc01cdb --- /dev/null +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs @@ -0,0 +1,1088 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography.X509Certificates; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Certificates.Generation; + +/// +/// On Unix, we trust the certificate in the following locations: +/// 1. dotnet (i.e. the CurrentUser/Root store) +/// 2. OpenSSL (i.e. adding it to a directory in $SSL_CERT_DIR) +/// 3. Firefox & Chromium (i.e. adding it to an NSS DB for each browser) +/// All of these locations are per-user. +/// +internal sealed partial class UnixCertificateManager : CertificateManager +{ + private const UnixFileMode DirectoryPermissions = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + /// The name of an environment variable consumed by OpenSSL to locate certificates. + private const string OpenSslCertificateDirectoryVariableName = "SSL_CERT_DIR"; + + private const string OpenSslCertDirectoryOverrideVariableName = "DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY"; + private const string NssDbOverrideVariableName = "DOTNET_DEV_CERTS_NSSDB_PATHS"; + // CONSIDER: we could have a distinct variable for Mozilla NSS DBs, but detecting them from the path seems sufficient for now. + + private const string BrowserFamilyChromium = "Chromium"; + private const string BrowserFamilyFirefox = "Firefox"; + + private const string PowerShellCommand = "powershell.exe"; + private const string WslInteropPath = "/proc/sys/fs/binfmt_misc/WSLInterop"; + private const string WslInteropLatePath = "/proc/sys/fs/binfmt_misc/WSLInterop-late"; + private const string WslFriendlyName = AspNetHttpsOidFriendlyName + " (WSL)"; + + private const string OpenSslCommand = "openssl"; + private const string CertUtilCommand = "certutil"; + + private const int MaxHashCollisions = 10; // Something is going badly wrong if we have this many dev certs with the same hash + + private HashSet? _availableCommands; + + public UnixCertificateManager(ILogger logger) : base(logger) + { + } + + internal UnixCertificateManager(string subject, int version) + : base(subject, version) + { + } + + public override TrustLevel GetTrustLevel(X509Certificate2 certificate) + { + var sawTrustSuccess = false; + var sawTrustFailure = false; + + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(OpenSslCertDirectoryOverrideVariableName))) + { + // Warn but don't bail. + Log.UnixOpenSslCertificateDirectoryOverrideIgnored(OpenSslCertDirectoryOverrideVariableName); + } + + // Building the chain will check whether dotnet trusts the cert. We could, instead, + // enumerate the Root store and/or look for the file in the OpenSSL directory, but + // this tests the real-world behavior. + var chain = new X509Chain(); + try + { + // This is just a heuristic for whether or not we should prompt the user to re-run with `--trust` + // so we don't need to check revocation (which doesn't really make sense for dev certs anyway) + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + if (chain.Build(certificate)) + { + sawTrustSuccess = true; + } + else + { + sawTrustFailure = true; + Log.UnixNotTrustedByDotnet(); + } + } + finally + { + // Disposing the chain does not dispose the elements we potentially built. + // Do the full walk manually to dispose. + for (var i = 0; i < chain.ChainElements.Count; i++) + { + chain.ChainElements[i].Certificate.Dispose(); + } + + chain.Dispose(); + } + + // Will become the name of the file on disk and the nickname in the NSS DBs + var certificateNickname = GetCertificateNickname(certificate); + + var sslCertDirString = Environment.GetEnvironmentVariable(OpenSslCertificateDirectoryVariableName); + if (string.IsNullOrEmpty(sslCertDirString)) + { + sawTrustFailure = true; + Log.UnixNotTrustedByOpenSsl(OpenSslCertificateDirectoryVariableName); + } + else + { + var foundCert = false; + var sslCertDirs = sslCertDirString.Split(Path.PathSeparator); + foreach (var sslCertDir in sslCertDirs) + { + var certPath = Path.Combine(sslCertDir, certificateNickname + ".pem"); + if (File.Exists(certPath)) + { + using var candidate = X509CertificateLoader.LoadCertificateFromFile(certPath); + if (AreCertificatesEqual(certificate, candidate)) + { + foundCert = true; + break; + } + } + } + + if (foundCert) + { + sawTrustSuccess = true; + } + else + { + sawTrustFailure = true; + Log.UnixNotTrustedByOpenSsl(OpenSslCertificateDirectoryVariableName); + } + } + + var nssDbs = GetNssDbs(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); + if (nssDbs.Count > 0) + { + if (!IsCommandAvailable(CertUtilCommand)) + { + // If there are browsers but we don't have certutil, we can't check trust and, + // in all probability, we can't have previously established it. + Log.UnixMissingCertUtilCommand(CertUtilCommand); + sawTrustFailure = true; + } + else + { + foreach (var nssDb in nssDbs) + { + if (IsCertificateInNssDb(certificateNickname, nssDb)) + { + sawTrustSuccess = true; + } + else + { + sawTrustFailure = true; + Log.UnixNotTrustedByNss(nssDb.Path, nssDb.IsFirefox ? BrowserFamilyFirefox : BrowserFamilyChromium); + } + } + } + } + + // Success & Failure => Partial; Success => Full; Failure => None + return sawTrustSuccess + ? sawTrustFailure + ? TrustLevel.Partial + : TrustLevel.Full + : TrustLevel.None; + } + + protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation) + { + var export = certificate.Export(X509ContentType.Pkcs12, ""); + certificate.Dispose(); + certificate = X509CertificateLoader.LoadPkcs12(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); + Array.Clear(export, 0, export.Length); + + using (var store = new X509Store(storeName, storeLocation)) + { + store.Open(OpenFlags.ReadWrite); + store.Add(certificate); + store.Close(); + }; + + return certificate; + } + + internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate) + { + // Return true as we don't perform any check. + // This is about checking storage, not trust. + return new CheckCertificateStateResult(true, null); + } + + internal override void CorrectCertificateState(X509Certificate2 candidate) + { + // Do nothing since we don't have anything to check here. + // This is about correcting storage, not trust. + } + + internal override bool IsExportable(X509Certificate2 c) => true; + + protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate) + { + var sawTrustFailure = false; + var sawTrustSuccess = false; + + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + if (TryFindCertificateInStore(store, certificate, out _)) + { + sawTrustSuccess = true; + } + else + { + try + { + using var publicCertificate = X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert)); + // FriendlyName is Windows-only, so we don't set it here. + store.Add(publicCertificate); + Log.UnixDotnetTrustSucceeded(); + sawTrustSuccess = true; + } + catch (Exception ex) + { + sawTrustFailure = true; + Log.UnixDotnetTrustException(ex.Message); + } + } + + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + // Rather than create a temporary file we'll have to clean up, we prefer to export the dev cert + // to its final location in the OpenSSL directory. As a result, any failure up until that point + // is fatal (i.e. we can't trust the cert in other locations). + + var certDir = GetOpenSslCertificateDirectory(homeDirectory)!; // May not exist + + var nickname = GetCertificateNickname(certificate); + var certPath = Path.Combine(certDir, nickname) + ".pem"; + + var needToExport = true; + + // We do our own check for file collisions since ExportCertificate silently overwrites. + if (File.Exists(certPath)) + { + try + { + using var existingCert = X509CertificateLoader.LoadCertificateFromFile(certPath); + if (!AreCertificatesEqual(existingCert, certificate)) + { + Log.UnixNotOverwritingCertificate(certPath); + return TrustLevel.None; + } + + needToExport = false; // If the bits are on disk, we don't need to re-export + } + catch + { + // If we couldn't load the file, then we also can't safely overwite it. + Log.UnixNotOverwritingCertificate(certPath); + return TrustLevel.None; + } + } + + if (needToExport) + { + // Security: we don't need the private key for trust, so we don't export it. + // Note that this will create directories as needed. We control `certPath`, so the permissions should be fine. + ExportCertificate(certificate, certPath, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem); + } + + // Once the certificate is on disk, we prefer not to throw - some subsequent trust step might succeed. + + var openSslTrustSucceeded = false; + + var isOpenSslAvailable = IsCommandAvailable(OpenSslCommand); + if (isOpenSslAvailable) + { + if (TryRehashOpenSslCertificates(certDir)) + { + openSslTrustSucceeded = true; + } + } + else + { + Log.UnixMissingOpenSslCommand(OpenSslCommand); + } + + if (openSslTrustSucceeded) + { + Log.UnixOpenSslTrustSucceeded(); + sawTrustSuccess = true; + } + else + { + // The helpers log their own failure reasons - we just describe the consequences + Log.UnixOpenSslTrustFailed(); + sawTrustFailure = true; + } + + var nssDbs = GetNssDbs(homeDirectory); + if (nssDbs.Count > 0) + { + var isCertUtilAvailable = IsCommandAvailable(CertUtilCommand); + if (!isCertUtilAvailable) + { + Log.UnixMissingCertUtilCommand(CertUtilCommand); + // We'll loop over the nssdbs anyway so they'll be listed + } + + foreach (var nssDb in nssDbs) + { + if (isCertUtilAvailable && TryAddCertificateToNssDb(certPath, nickname, nssDb)) + { + if (IsCertificateInNssDb(nickname, nssDb)) + { + Log.UnixNssDbTrustSucceeded(nssDb.Path); + sawTrustSuccess = true; + } + else + { + // If the dev cert is in the db under a different nickname, adding it will succeed (and probably even cause it to be trusted) + // but IsTrusted won't find it. This is unlikely to happen in practice, so we warn here, rather than hardening IsTrusted. + Log.UnixNssDbTrustFailedWithProbableConflict(nssDb.Path, nssDb.IsFirefox ? BrowserFamilyFirefox : BrowserFamilyChromium); + sawTrustFailure = true; + } + } + else + { + Log.UnixNssDbTrustFailed(nssDb.Path, nssDb.IsFirefox ? BrowserFamilyFirefox : BrowserFamilyChromium); + sawTrustFailure = true; + } + } + } + + if (sawTrustFailure) + { + if (sawTrustSuccess) + { + // Untrust throws in this case, but we're more lenient since a partially trusted state may be useful in practice. + Log.UnixTrustPartiallySucceeded(); + } + else + { + return TrustLevel.None; + } + } + + if (openSslTrustSucceeded) + { + Debug.Assert(IsCommandAvailable(OpenSslCommand), "How did we trust without the openssl command?"); + + var homeDirectoryWithSlash = homeDirectory[^1] == Path.DirectorySeparatorChar + ? homeDirectory + : homeDirectory + Path.DirectorySeparatorChar; + + var prettyCertDir = certDir.StartsWith(homeDirectoryWithSlash, StringComparison.Ordinal) + ? Path.Combine("$HOME", certDir[homeDirectoryWithSlash.Length..]) + : certDir; + + var hasValidSslCertDir = false; + + // Check if SSL_CERT_DIR is already set and if certDir is already included + var existingSslCertDir = Environment.GetEnvironmentVariable(OpenSslCertificateDirectoryVariableName); + if (!string.IsNullOrEmpty(existingSslCertDir)) + { + var existingDirs = existingSslCertDir.Split(Path.PathSeparator); + var certDirFullPath = Path.GetFullPath(certDir); + var isCertDirIncluded = existingDirs.Any(dir => + { + if (string.IsNullOrWhiteSpace(dir)) + { + return false; + } + + try + { + return string.Equals(Path.GetFullPath(dir), certDirFullPath, StringComparison.Ordinal); + } + catch + { + // Ignore invalid directory entries in SSL_CERT_DIR + return false; + } + }); + + if (isCertDirIncluded) + { + // The certificate directory is already in SSL_CERT_DIR, no action needed + Log.UnixOpenSslCertificateDirectoryAlreadyConfigured(prettyCertDir, OpenSslCertificateDirectoryVariableName); + hasValidSslCertDir = true; + } + else + { + // SSL_CERT_DIR is set but doesn't include our directory - suggest appending + Log.UnixSuggestAppendingToEnvironmentVariable(prettyCertDir, OpenSslCertificateDirectoryVariableName); + hasValidSslCertDir = false; + } + } + else if (TryGetOpenSslDirectory(out var openSslDir)) + { + Log.UnixSuggestSettingEnvironmentVariable(prettyCertDir, Path.Combine(openSslDir, "certs"), OpenSslCertificateDirectoryVariableName); + hasValidSslCertDir = false; + } + else + { + Log.UnixSuggestSettingEnvironmentVariableWithoutExample(prettyCertDir, OpenSslCertificateDirectoryVariableName); + hasValidSslCertDir = false; + } + + sawTrustFailure = !hasValidSslCertDir; + } + + // Check to see if we're running in WSL; if so, use powershell.exe to add the certificate to the Windows trust store as well + if (IsRunningOnWslWithInterop()) + { + try + { + if (TrustCertificateInWindowsStore(certificate)) + { + Log.WslWindowsTrustSucceeded(); + } + else + { + Log.WslWindowsTrustFailed(); + sawTrustFailure = true; + } + } + catch (Exception ex) + { + Log.WslWindowsTrustException(ex.Message); + sawTrustFailure = true; + } + } + + return sawTrustFailure + ? TrustLevel.Partial + : TrustLevel.Full; + } + + protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate) + { + var sawUntrustFailure = false; + + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + if (TryFindCertificateInStore(store, certificate, out var matching)) + { + try + { + store.Remove(matching); + } + catch (Exception ex) + { + Log.UnixDotnetUntrustException(ex.Message); + sawUntrustFailure = true; + } + } + + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)!; + + // We don't attempt to remove the directory when it's empty - it's a standard location + // and will almost certainly be used in the future. + var certDir = GetOpenSslCertificateDirectory(homeDirectory); // May not exist + + var nickname = GetCertificateNickname(certificate); + var certPath = Path.Combine(certDir, nickname) + ".pem"; + + if (File.Exists(certPath)) + { + var openSslUntrustSucceeded = false; + + if (IsCommandAvailable(OpenSslCommand)) + { + if (TryDeleteCertificateFile(certPath) && TryRehashOpenSslCertificates(certDir)) + { + openSslUntrustSucceeded = true; + } + } + else + { + Log.UnixMissingOpenSslCommand(OpenSslCommand); + } + + if (openSslUntrustSucceeded) + { + Log.UnixOpenSslUntrustSucceeded(); + } + else + { + // The helpers log their own failure reasons - we just describe the consequences + Log.UnixOpenSslUntrustFailed(); + sawUntrustFailure = true; + } + } + else + { + Log.UnixOpenSslUntrustSkipped(certPath); + } + + var nssDbs = GetNssDbs(homeDirectory); + if (nssDbs.Count > 0) + { + var isCertUtilAvailable = IsCommandAvailable(CertUtilCommand); + if (!isCertUtilAvailable) + { + Log.UnixMissingCertUtilCommand(CertUtilCommand); + // We'll loop over the nssdbs anyway so they'll be listed + } + + foreach (var nssDb in nssDbs) + { + if (isCertUtilAvailable && TryRemoveCertificateFromNssDb(nickname, nssDb)) + { + Log.UnixNssDbUntrustSucceeded(nssDb.Path); + } + else + { + Log.UnixNssDbUntrustFailed(nssDb.Path); + sawUntrustFailure = true; + } + } + } + + if (sawUntrustFailure) + { + // It might be nice to include more specific error information in the exception message, but we've logged it anyway. + throw new InvalidOperationException($@"There was an error removing the certificate with thumbprint '{certificate.Thumbprint}'."); + } + } + + protected override IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation) + { + return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: false); + } + + protected override void CreateDirectoryWithPermissions(string directoryPath) + { +#pragma warning disable CA1416 // Validate platform compatibility (not supported on Windows) + var dirInfo = new DirectoryInfo(directoryPath); + if (dirInfo.Exists) + { + if ((dirInfo.UnixFileMode & ~DirectoryPermissions) != 0) + { + Log.DirectoryPermissionsNotSecure(dirInfo.FullName); + } + } + else + { + Directory.CreateDirectory(directoryPath, DirectoryPermissions); + } +#pragma warning restore CA1416 // Validate platform compatibility + } + + private static string GetChromiumNssDb(string homeDirectory) + { + return Path.Combine(homeDirectory, ".pki", "nssdb"); + } + + private static string GetChromiumSnapNssDb(string homeDirectory) + { + return Path.Combine(homeDirectory, "snap", "chromium", "current", ".pki", "nssdb"); + } + + private static string GetFirefoxDirectory(string homeDirectory) + { + return Path.Combine(homeDirectory, ".mozilla", "firefox"); + } + + private static string GetFirefoxSnapDirectory(string homeDirectory) + { + return Path.Combine(homeDirectory, "snap", "firefox", "common", ".mozilla", "firefox"); + } + + private bool IsCommandAvailable(string command) + { + _availableCommands ??= FindAvailableCommands(); + return _availableCommands.Contains(command); + } + + private static HashSet FindAvailableCommands() + { + var availableCommands = new HashSet(); + + // We need OpenSSL 1.1.1h or newer (to pick up https://github.com/openssl/openssl/pull/12357), + // but, given that all of v1 is EOL, it doesn't seem worthwhile to check the version. + var commands = new[] { OpenSslCommand, CertUtilCommand }; + + var searchPath = Environment.GetEnvironmentVariable("PATH"); + + if (searchPath is null) + { + return availableCommands; + } + + var searchFolders = searchPath.Split(Path.PathSeparator); + + foreach (var searchFolder in searchFolders) + { + foreach (var command in commands) + { + if (!availableCommands.Contains(command)) + { + try + { + if (File.Exists(Path.Combine(searchFolder, command))) + { + availableCommands.Add(command); + } + } + catch + { + // It's not interesting to report (e.g.) permission errors here. + } + } + } + + // Stop early if we've found all the required commands. + // They're usually all in the same folder (/bin or /usr/bin). + if (availableCommands.Count == commands.Length) + { + break; + } + } + + return availableCommands; + } + + private static string GetCertificateNickname(X509Certificate2 certificate) + { + return $"aspnetcore-localhost-{certificate.Thumbprint}"; + } + + /// + /// Detects if the current environment is Windows Subsystem for Linux (WSL) with interop enabled. + /// + /// True if running on WSL with interop; otherwise, false. + private static bool IsRunningOnWslWithInterop() + { + // WSL exposes special files that indicate WSL interop is enabled. + // Either WSLInterop or WSLInterop-late may be present depending on the WSL version and configuration. + if (File.Exists(WslInteropPath) || File.Exists(WslInteropLatePath)) + { + return true; + } + + // Additionally check for standard WSL environment variables as a fallback. + // WSL_INTEROP is set to the path of the interop socket. + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WSL_INTEROP"))) + { + return true; + } + + return false; + } + + /// + /// Attempts to trust the certificate in the Windows certificate store via PowerShell when running on WSL. + /// If the certificate already exists in the store, this is a no-op. + /// + /// The certificate to trust. + /// True if the certificate was successfully added to the Windows store; otherwise, false. + private static bool TrustCertificateInWindowsStore(X509Certificate2 certificate) + { + // Export the certificate as DER-encoded bytes (no private key needed for trust) + // and embed it directly in the PowerShell script as Base64 to avoid file path + // translation issues between WSL and Windows. + var certBytes = certificate.Export(X509ContentType.Cert); + var certBase64 = Convert.ToBase64String(certBytes); + + var escapedFriendlyName = WslFriendlyName.Replace("'", "''"); + var powershellScript = $@" + $certBytes = [Convert]::FromBase64String('{certBase64}') + $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,$certBytes) + $cert.FriendlyName = '{escapedFriendlyName}' + $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser') + $store.Open('ReadWrite') + $store.Add($cert) + $store.Close() + "; + + // Encode the PowerShell script to Base64 (UTF-16LE as required by PowerShell) + var encodedCommand = Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(powershellScript)); + + var startInfo = new ProcessStartInfo(PowerShellCommand, $"-NoProfile -NonInteractive -EncodedCommand {encodedCommand}") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + using var process = Process.Start(startInfo)!; + process.WaitForExit(); + return process.ExitCode == 0; + } + + /// + /// It is the caller's responsibility to ensure that is available. + /// + private bool IsCertificateInNssDb(string nickname, NssDb nssDb) + { + // -V will validate that a cert can be used for a given purpose, in this case, server verification. + // There is no corresponding -V check for the "Trusted CA" status required by Firefox, so we just check for existence. + // (The docs suggest that "-V -u A" should do this, but it seems to accept all certs.) + var operation = nssDb.IsFirefox ? "-L" : "-V -u V"; + + var startInfo = new ProcessStartInfo(CertUtilCommand, $"-d sql:{nssDb.Path} -n {nickname} {operation}") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + try + { + using var process = Process.Start(startInfo)!; + process.WaitForExit(); + return process.ExitCode == 0; + } + catch (Exception ex) + { + Log.UnixNssDbCheckException(nssDb.Path, ex.Message); + // This method is used to determine whether more trust is needed, so it's better to underestimate the amount of trust. + return false; + } + } + + /// + /// It is the caller's responsibility to ensure that is available. + /// + private bool TryAddCertificateToNssDb(string certificatePath, string nickname, NssDb nssDb) + { + // Firefox doesn't seem to respected the more correct "trusted peer" (P) usage, so we use "trusted CA" (C) instead. + var usage = nssDb.IsFirefox ? "C" : "P"; + + // This silently clobbers an existing entry, so there's no need to check for existence first. + var startInfo = new ProcessStartInfo(CertUtilCommand, $"-d sql:{nssDb.Path} -n {nickname} -A -i {certificatePath} -t \"{usage},,\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + try + { + using var process = Process.Start(startInfo)!; + process.WaitForExit(); + return process.ExitCode == 0; + } + catch (Exception ex) + { + Log.UnixNssDbAdditionException(nssDb.Path, ex.Message); + return false; + } + } + + /// + /// It is the caller's responsibility to ensure that is available. + /// + private bool TryRemoveCertificateFromNssDb(string nickname, NssDb nssDb) + { + var startInfo = new ProcessStartInfo(CertUtilCommand, $"-d sql:{nssDb.Path} -D -n {nickname}") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + try + { + using var process = Process.Start(startInfo)!; + process.WaitForExit(); + if (process.ExitCode == 0) + { + return true; + } + + // Maybe it wasn't in there because the overrides have change or trust only partially succeeded. + return !IsCertificateInNssDb(nickname, nssDb); + } + catch (Exception ex) + { + Log.UnixNssDbRemovalException(nssDb.Path, ex.Message); + return false; + } + } + + private IEnumerable GetFirefoxProfiles(string firefoxDirectory) + { + try + { + var profiles = Directory.GetDirectories(firefoxDirectory, "*.default", SearchOption.TopDirectoryOnly).Concat( + Directory.GetDirectories(firefoxDirectory, "*.default-*", SearchOption.TopDirectoryOnly)); // There can be one of these for each release channel + if (!profiles.Any()) + { + // This is noteworthy, given that we're in a firefox directory. + Log.UnixNoFirefoxProfilesFound(firefoxDirectory); + } + return profiles; + } + catch (Exception ex) + { + Log.UnixFirefoxProfileEnumerationException(firefoxDirectory, ex.Message); + return []; + } + } + + private string GetOpenSslCertificateDirectory(string homeDirectory) + { + var @override = Environment.GetEnvironmentVariable(OpenSslCertDirectoryOverrideVariableName); + if (!string.IsNullOrEmpty(@override)) + { + Log.UnixOpenSslCertificateDirectoryOverridePresent(OpenSslCertDirectoryOverrideVariableName); + return @override; + } + + return Path.Combine(homeDirectory, ".aspnet", "dev-certs", "trust"); + } + + private bool TryDeleteCertificateFile(string certPath) + { + try + { + File.Delete(certPath); + return true; + } + catch (Exception ex) + { + Log.UnixCertificateFileDeletionException(certPath, ex.Message); + return false; + } + } + + private bool TryGetNssDbOverrides(out IReadOnlyList overrides) + { + var nssDbOverride = Environment.GetEnvironmentVariable(NssDbOverrideVariableName); + if (string.IsNullOrEmpty(nssDbOverride)) + { + overrides = []; + return false; + } + + // Normally, we'd let the caller log this, since it's not really an exceptional condition, + // but it's not worth duplicating the code and the work. + Log.UnixNssDbOverridePresent(NssDbOverrideVariableName); + + var nssDbs = new List(); + + var paths = nssDbOverride.Split(Path.PathSeparator); // May be empty - the user may not want to add browser trust + foreach (var path in paths) + { + var nssDb = Path.GetFullPath(path); + if (!Directory.Exists(nssDb)) + { + Log.UnixNssDbDoesNotExist(nssDb, NssDbOverrideVariableName); + continue; + } + nssDbs.Add(nssDb); + } + + overrides = nssDbs; + return true; + } + + private List GetNssDbs(string homeDirectory) + { + var nssDbs = new List(); + + if (TryGetNssDbOverrides(out var nssDbOverrides)) + { + foreach (var nssDb in nssDbOverrides) + { + // Our Firefox approach is a hack, so we'd rather under-recognize it than over-recognize it. + var isFirefox = nssDb.Contains("/.mozilla/firefox/", StringComparison.Ordinal); + nssDbs.Add(new NssDb(nssDb, isFirefox)); + } + + return nssDbs; + } + + if (!Directory.Exists(homeDirectory)) + { + Log.UnixHomeDirectoryDoesNotExist(homeDirectory, Environment.UserName); + return nssDbs; + } + + // Chrome, Chromium, and Edge all use this directory + var chromiumNssDb = GetChromiumNssDb(homeDirectory); + if (Directory.Exists(chromiumNssDb)) + { + nssDbs.Add(new NssDb(chromiumNssDb, isFirefox: false)); + } + + // Chromium Snap, when launched under snap confinement, uses this directory + // (On Ubuntu, the GUI launcher uses confinement, but the terminal does not) + var chromiumSnapNssDb = GetChromiumSnapNssDb(homeDirectory); + if (Directory.Exists(chromiumSnapNssDb)) + { + nssDbs.Add(new NssDb(chromiumSnapNssDb, isFirefox: false)); + } + + var firefoxDir = GetFirefoxDirectory(homeDirectory); + if (Directory.Exists(firefoxDir)) + { + var profileDirs = GetFirefoxProfiles(firefoxDir); + foreach (var profileDir in profileDirs) + { + nssDbs.Add(new NssDb(profileDir, isFirefox: true)); + } + } + + var firefoxSnapDir = GetFirefoxSnapDirectory(homeDirectory); + if (Directory.Exists(firefoxSnapDir)) + { + var profileDirs = GetFirefoxProfiles(firefoxSnapDir); + foreach (var profileDir in profileDirs) + { + nssDbs.Add(new NssDb(profileDir, isFirefox: true)); + } + } + + return nssDbs; + } + + [GeneratedRegex("OPENSSLDIR:\\s*\"([^\"]+)\"")] + private static partial Regex OpenSslVersionRegex { get; } + + /// + /// It is the caller's responsibility to ensure that is available. + /// + private bool TryGetOpenSslDirectory([NotNullWhen(true)] out string? openSslDir) + { + openSslDir = null; + + try + { + var processInfo = new ProcessStartInfo(OpenSslCommand, $"version -d") + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + using var process = Process.Start(processInfo); + var stdout = process!.StandardOutput.ReadToEnd(); + + process.WaitForExit(); + if (process.ExitCode != 0) + { + Log.UnixOpenSslVersionFailed(); + return false; + } + + var match = OpenSslVersionRegex.Match(stdout); + if (!match.Success) + { + Log.UnixOpenSslVersionParsingFailed(); + return false; + } + + openSslDir = match.Groups[1].Value; + return true; + } + catch (Exception ex) + { + Log.UnixOpenSslVersionException(ex.Message); + return false; + } + } + + /// + /// It is the caller's responsibility to ensure that is available. + /// + private bool TryGetOpenSslHash(string certificatePath, [NotNullWhen(true)] out string? hash) + { + hash = null; + + try + { + // c_rehash actually does this twice: once with -subject_hash (equivalent to -hash) and again + // with -subject_hash_old. Old hashes are only needed for pre-1.0.0, so we skip that. + var processInfo = new ProcessStartInfo(OpenSslCommand, $"x509 -hash -noout -in {certificatePath}") + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + using var process = Process.Start(processInfo); + var stdout = process!.StandardOutput.ReadToEnd(); + + process.WaitForExit(); + if (process.ExitCode != 0) + { + Log.UnixOpenSslHashFailed(certificatePath); + return false; + } + + hash = stdout.Trim(); + return true; + } + catch (Exception ex) + { + Log.UnixOpenSslHashException(certificatePath, ex.Message); + return false; + } + } + + [GeneratedRegex("^[0-9a-f]+\\.[0-9]+$")] + private static partial Regex OpenSslHashFilenameRegex { get; } + + /// + /// We only ever use .pem, but someone will eventually put their own cert in this directory, + /// so we should handle the same extensions as c_rehash (other than .crl). + /// + [GeneratedRegex("\\.(pem|crt|cer)$")] + private static partial Regex OpenSslCertificateExtensionRegex { get; } + + /// + /// This is a simplified version of c_rehash from OpenSSL. Using the real one would require + /// installing the OpenSSL perl tools and perl itself, which might be annoying in a container. + /// + private bool TryRehashOpenSslCertificates(string certificateDirectory) + { + try + { + // First, delete all the existing symlinks, so we don't have to worry about fragmentation or leaks. + var certs = new List(); + + var dirInfo = new DirectoryInfo(certificateDirectory); + foreach (var file in dirInfo.EnumerateFiles()) + { + var isSymlink = (file.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; + if (isSymlink && OpenSslHashFilenameRegex.IsMatch(file.Name)) + { + file.Delete(); + } + else if (OpenSslCertificateExtensionRegex.IsMatch(file.Name)) + { + certs.Add(file); + } + } + + // Then, enumerate all certificates - there will usually be zero or one. + + // c_rehash doesn't create additional symlinks for certs with the same fingerprint, + // but we don't expect this to happen, so we favor slightly slower look-ups when it + // does, rather than slightly slower rehashing when it doesn't. + + foreach (var cert in certs) + { + if (!TryGetOpenSslHash(cert.FullName, out var hash)) + { + return false; + } + + var linkCreated = false; + for (var i = 0; i < MaxHashCollisions; i++) + { + var linkPath = Path.Combine(certificateDirectory, $"{hash}.{i}"); + if (!File.Exists(linkPath)) + { + // As in c_rehash, we link using a relative path. + File.CreateSymbolicLink(linkPath, cert.Name); + linkCreated = true; + break; + } + } + + if (!linkCreated) + { + Log.UnixOpenSslRehashTooManyHashes(cert.FullName, hash, MaxHashCollisions); + return false; + } + } + } + catch (Exception ex) + { + Log.UnixOpenSslRehashException(ex.Message); + return false; + } + + return true; + } + + private sealed class NssDb(string path, bool isFirefox) + { + public string Path => path; + public bool IsFirefox => isFirefox; + } +} diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs new file mode 100644 index 00000000000..b06626256a0 --- /dev/null +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs @@ -0,0 +1,166 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Security.Principal; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Certificates.Generation; + +[SupportedOSPlatform("windows")] +internal sealed class WindowsCertificateManager : CertificateManager +{ + private const int UserCancelledErrorCode = 1223; + + public WindowsCertificateManager(ILogger logger) : base(logger) + { + } + + // For testing purposes only + internal WindowsCertificateManager(string subject, int version) + : base(subject, version) + { + } + + internal override bool IsExportable(X509Certificate2 c) + { +#if XPLAT + // For the first run experience we don't need to know if the certificate can be exported. + return true; +#else + using var key = c.GetRSAPrivateKey(); + return (key is RSACryptoServiceProvider rsaPrivateKey && + rsaPrivateKey.CspKeyContainerInfo.Exportable) || + (key is RSACng cngPrivateKey && + cngPrivateKey.Key.ExportPolicy == CngExportPolicies.AllowExport); +#endif + } + + internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate) + { + return new CheckCertificateStateResult(true, null); + } + + internal override void CorrectCertificateState(X509Certificate2 candidate) + { + // Do nothing since we don't have anything to check here. + } + + protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation) + { + // On non OSX systems we need to export the certificate and import it so that the transient + // key that we generated gets persisted. + var export = certificate.Export(X509ContentType.Pkcs12, ""); + certificate.Dispose(); + certificate = X509CertificateLoader.LoadPkcs12(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); + Array.Clear(export, 0, export.Length); + certificate.FriendlyName = AspNetHttpsOidFriendlyName; + + using (var store = new X509Store(storeName, storeLocation)) + { + store.Open(OpenFlags.ReadWrite); + store.Add(certificate); + store.Close(); + }; + + return certificate; + } + + protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate) + { + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + if (TryFindCertificateInStore(store, certificate, out _)) + { + Log.WindowsCertificateAlreadyTrusted(); + return TrustLevel.Full; + } + + try + { + Log.WindowsAddCertificateToRootStore(); + + using var publicCertificate = X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert)); + publicCertificate.FriendlyName = certificate.FriendlyName; + store.Add(publicCertificate); + return TrustLevel.Full; + } + catch (CryptographicException exception) when (exception.HResult == UserCancelledErrorCode) + { + Log.WindowsCertificateTrustCanceled(); + throw new UserCancelledTrustException(); + } + } + + protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate) + { + Log.WindowsRemoveCertificateFromRootStoreStart(); + + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + if (TryFindCertificateInStore(store, certificate, out var matching)) + { + store.Remove(matching); + } + else + { + Log.WindowsRemoveCertificateFromRootStoreNotFound(); + } + + Log.WindowsRemoveCertificateFromRootStoreEnd(); + } + + public override TrustLevel GetTrustLevel(X509Certificate2 certificate) + { + var isTrusted = ListCertificates(StoreName.Root, StoreLocation.CurrentUser, isValid: true, requireExportable: false) + .Any(c => AreCertificatesEqual(c, certificate)); + return isTrusted ? TrustLevel.Full : TrustLevel.None; + } + + protected override IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation) + { + return ListCertificates(storeName, storeLocation, isValid: false); + } + + protected override void CreateDirectoryWithPermissions(string directoryPath) + { + var dirInfo = new DirectoryInfo(directoryPath); + + if (!dirInfo.Exists) + { + // We trust the default permissions on Windows enough not to apply custom ACLs. + // We'll warn below if things seem really off. + dirInfo.Create(); + } + + var currentUser = WindowsIdentity.GetCurrent(); + var currentUserSid = currentUser.User; + var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, domainSid: null); + var adminGroupSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, domainSid: null); + + var dirSecurity = dirInfo.GetAccessControl(); + var accessRules = dirSecurity.GetAccessRules(true, true, typeof(SecurityIdentifier)); + + foreach (FileSystemAccessRule rule in accessRules) + { + var idRef = rule.IdentityReference; + if (rule.AccessControlType == AccessControlType.Allow && + !idRef.Equals(currentUserSid) && + !idRef.Equals(systemSid) && + !idRef.Equals(adminGroupSid)) + { + // This is just a heuristic - determining whether the cumulative effect of the rules + // is to allow access to anyone other than the current user, system, or administrators + // is very complicated. We're not going to do anything but log, so an approximation + // is fine. + Log.DirectoryPermissionsNotSecure(dirInfo.FullName); + break; + } + } + } +} diff --git a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs new file mode 100644 index 00000000000..beb232f5346 --- /dev/null +++ b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs @@ -0,0 +1,103 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography.X509Certificates; +using Aspire.Cli.DotNet; +using Microsoft.AspNetCore.Certificates.Generation; + +namespace Aspire.Cli.Certificates; + +/// +/// Certificate tool runner that uses the native CertificateManager directly (no subprocess needed). +/// +internal sealed class NativeCertificateToolRunner(CertificateManager certificateManager) : ICertificateToolRunner +{ + public Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync( + DotNetCliRunnerInvocationOptions options, + CancellationToken cancellationToken) + { + var availableCertificates = certificateManager.ListCertificates( + StoreName.My, StoreLocation.CurrentUser, isValid: true); + + try + { + var now = DateTimeOffset.Now; + var certInfos = availableCertificates.Select(cert => + { + var status = certificateManager.CheckCertificateState(cert); + var trustLevel = status.Success + ? certificateManager.GetTrustLevel(cert).ToString() + : DevCertTrustLevel.None; + + return new DevCertInfo + { + Thumbprint = cert.Thumbprint, + Subject = cert.Subject, + SubjectAlternativeNames = GetSanExtension(cert), + Version = CertificateManager.GetCertificateVersion(cert), + ValidityNotBefore = cert.NotBefore, + ValidityNotAfter = cert.NotAfter, + IsHttpsDevelopmentCertificate = CertificateManager.IsHttpsDevelopmentCertificate(cert), + IsExportable = certificateManager.IsExportable(cert), + TrustLevel = trustLevel + }; + }).ToList(); + + var validCerts = certInfos + .Where(c => c.IsHttpsDevelopmentCertificate && c.ValidityNotBefore <= now && now <= c.ValidityNotAfter) + .OrderByDescending(c => c.Version) + .ToList(); + + var highestVersionedCert = validCerts.FirstOrDefault(); + + var result = new CertificateTrustResult + { + HasCertificates = validCerts.Count > 0, + TrustLevel = highestVersionedCert?.TrustLevel, + Certificates = certInfos + }; + + return Task.FromResult((0, (CertificateTrustResult?)result)); + } + finally + { + CertificateManager.DisposeCertificates(availableCertificates); + } + } + + public Task TrustHttpCertificateAsync( + DotNetCliRunnerInvocationOptions options, + CancellationToken cancellationToken) + { + var now = DateTimeOffset.Now; + var result = certificateManager.EnsureAspNetCoreHttpsDevelopmentCertificate( + now, now.Add(TimeSpan.FromDays(365)), + trust: true); + + return Task.FromResult(result switch + { + EnsureCertificateResult.Succeeded or + EnsureCertificateResult.ValidCertificatePresent or + EnsureCertificateResult.ExistingHttpsCertificateTrusted or + EnsureCertificateResult.NewHttpsCertificateTrusted => 0, + EnsureCertificateResult.UserCancelledTrustStep => 5, + _ => 4 // ErrorTrustingTheCertificate + }); + } + + private static string[]? GetSanExtension(X509Certificate2 cert) + { + var dnsNames = new List(); + foreach (var extension in cert.Extensions) + { + if (extension is X509SubjectAlternativeNameExtension sanExtension) + { + foreach (var dns in sanExtension.EnumerateDnsNames()) + { + dnsNames.Add(dns); + } + } + } + return dnsNames.Count > 0 ? dnsNames.ToArray() : null; + } +} diff --git a/src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs deleted file mode 100644 index 922a7eaef5b..00000000000 --- a/src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs +++ /dev/null @@ -1,155 +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; -using System.Text; -using System.Text.Json; -using Aspire.Cli.DotNet; -using Microsoft.Extensions.Logging; - -namespace Aspire.Cli.Certificates; - -/// -/// Certificate tool runner that uses the global dotnet SDK's dev-certs command. -/// -internal sealed class SdkCertificateToolRunner(ILogger logger) : ICertificateToolRunner -{ - public async Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync( - DotNetCliRunnerInvocationOptions options, - CancellationToken cancellationToken) - { - var outputBuilder = new StringBuilder(); - - var startInfo = new ProcessStartInfo("dotnet") - { - WorkingDirectory = Environment.CurrentDirectory, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - - startInfo.ArgumentList.Add("dev-certs"); - startInfo.ArgumentList.Add("https"); - startInfo.ArgumentList.Add("--check-trust-machine-readable"); - - using var process = new Process { StartInfo = startInfo }; - - process.OutputDataReceived += (sender, e) => - { - if (e.Data is not null) - { - outputBuilder.AppendLine(e.Data); - options.StandardOutputCallback?.Invoke(e.Data); - } - }; - - process.ErrorDataReceived += (sender, e) => - { - if (e.Data is not null) - { - options.StandardErrorCallback?.Invoke(e.Data); - } - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - await process.WaitForExitAsync(cancellationToken); - - var exitCode = process.ExitCode; - - // Parse the JSON output - try - { - var jsonOutput = outputBuilder.ToString().Trim(); - if (string.IsNullOrEmpty(jsonOutput)) - { - return (exitCode, new CertificateTrustResult - { - HasCertificates = false, - TrustLevel = null, - Certificates = [] - }); - } - - var certificates = JsonSerializer.Deserialize(jsonOutput, Aspire.Cli.JsonSourceGenerationContext.Default.ListDevCertInfo); - if (certificates is null || certificates.Count == 0) - { - return (exitCode, new CertificateTrustResult - { - HasCertificates = false, - TrustLevel = null, - Certificates = [] - }); - } - - // Find the highest versioned valid certificate - var now = DateTimeOffset.Now; - var validCertificates = certificates - .Where(c => c.IsHttpsDevelopmentCertificate && c.ValidityNotBefore <= now && now <= c.ValidityNotAfter) - .OrderByDescending(c => c.Version) - .ToList(); - - var highestVersionedCert = validCertificates.FirstOrDefault(); - var trustLevel = highestVersionedCert?.TrustLevel; - - return (exitCode, new CertificateTrustResult - { - HasCertificates = validCertificates.Count > 0, - TrustLevel = trustLevel, - Certificates = certificates - }); - } - catch (JsonException ex) - { - logger.LogDebug(ex, "Failed to parse dev-certs machine-readable output"); - return (exitCode, null); - } - } - - public async Task TrustHttpCertificateAsync( - DotNetCliRunnerInvocationOptions options, - CancellationToken cancellationToken) - { - var startInfo = new ProcessStartInfo("dotnet") - { - WorkingDirectory = Environment.CurrentDirectory, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - - startInfo.ArgumentList.Add("dev-certs"); - startInfo.ArgumentList.Add("https"); - startInfo.ArgumentList.Add("--trust"); - - using var process = new Process { StartInfo = startInfo }; - - process.OutputDataReceived += (sender, e) => - { - if (e.Data is not null) - { - options.StandardOutputCallback?.Invoke(e.Data); - } - }; - - process.ErrorDataReceived += (sender, e) => - { - if (e.Data is not null) - { - options.StandardErrorCallback?.Invoke(e.Data); - } - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - await process.WaitForExitAsync(cancellationToken); - - return process.ExitCode; - } -} diff --git a/src/Aspire.Cli/Layout/LayoutConfiguration.cs b/src/Aspire.Cli/Layout/LayoutConfiguration.cs index 1fc3e313000..08139cb02b0 100644 --- a/src/Aspire.Cli/Layout/LayoutConfiguration.cs +++ b/src/Aspire.Cli/Layout/LayoutConfiguration.cs @@ -12,18 +12,10 @@ public enum LayoutComponent { /// CLI executable. Cli, - /// .NET runtime. - Runtime, - /// Pre-built AppHost Server. - AppHostServer, - /// Aspire Dashboard. - Dashboard, /// Developer Control Plane. Dcp, - /// NuGet Helper tool. - NuGetHelper, - /// Dev-certs tool. - DevCerts + /// Unified managed binary (dashboard, server, nuget). + Managed } /// @@ -42,11 +34,6 @@ public sealed class LayoutConfiguration /// public string? Platform { get; set; } - /// - /// .NET runtime version included in the bundle (e.g., "10.0.0"). - /// - public string? RuntimeVersion { get; set; } - /// /// Root path of the layout. /// @@ -75,85 +62,32 @@ public sealed class LayoutConfiguration var relativePath = component switch { LayoutComponent.Cli => Components.Cli, - LayoutComponent.Runtime => Components.Runtime, - LayoutComponent.AppHostServer => Components.ApphostServer, - LayoutComponent.Dashboard => Components.Dashboard, LayoutComponent.Dcp => Components.Dcp, - LayoutComponent.NuGetHelper => Components.NugetHelper, - LayoutComponent.DevCerts => Components.DevCerts, + LayoutComponent.Managed => Components.Managed, _ => null }; return relativePath is not null ? Path.Combine(LayoutPath, relativePath) : null; } - /// - /// Gets the path to the dotnet muxer executable from the bundled runtime. - /// - public string? GetMuxerPath() - { - var runtimePath = GetComponentPath(LayoutComponent.Runtime); - if (runtimePath is null) - { - return null; - } - - var bundledPath = Path.Combine(runtimePath, BundleDiscovery.GetDotNetExecutableName()); - return File.Exists(bundledPath) ? bundledPath : null; - } - - /// - /// Gets the path to the dotnet executable. Alias for GetMuxerPath. - /// - public string? GetDotNetExePath() => GetMuxerPath(); - /// /// Gets the path to the DCP directory. /// public string? GetDcpPath() => GetComponentPath(LayoutComponent.Dcp); /// - /// Gets the path to the Dashboard directory. - /// - public string? GetDashboardPath() => GetComponentPath(LayoutComponent.Dashboard); - - /// - /// Gets the path to the AppHost Server executable. - /// - /// The path to aspire-server.exe. - public string? GetAppHostServerPath() - { - var serverPath = GetComponentPath(LayoutComponent.AppHostServer); - if (serverPath is null) - { - return null; - } - - return Path.Combine(serverPath, BundleDiscovery.GetExecutableFileName(BundleDiscovery.AppHostServerExecutableName)); - } - - /// - /// Gets the path to the NuGet Helper executable. + /// Gets the path to the aspire-managed executable. /// - /// The path to aspire-nuget.exe. - public string? GetNuGetHelperPath() + /// The path to aspire-managed(.exe). + public string? GetManagedPath() { - var helperPath = GetComponentPath(LayoutComponent.NuGetHelper); - if (helperPath is null) + var managedDir = GetComponentPath(LayoutComponent.Managed); + if (managedDir is null) { return null; } - return Path.Combine(helperPath, BundleDiscovery.GetExecutableFileName(BundleDiscovery.NuGetHelperExecutableName)); - } - - /// - /// Gets the path to the dev-certs DLL (requires dotnet muxer to run). - /// - public string? GetDevCertsPath() - { - var devCertsPath = GetComponentPath(LayoutComponent.DevCerts); - return devCertsPath is not null ? Path.Combine(devCertsPath, BundleDiscovery.GetDllFileName(BundleDiscovery.DevCertsExecutableName)) : null; + return Path.Combine(managedDir, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); } } @@ -167,33 +101,13 @@ public sealed class LayoutComponents /// public string? Cli { get; set; } = "aspire"; - /// - /// Path to .NET runtime directory. - /// - public string? Runtime { get; set; } = BundleDiscovery.RuntimeDirectoryName; - - /// - /// Path to pre-built AppHost Server. - /// - public string? ApphostServer { get; set; } = BundleDiscovery.AppHostServerDirectoryName; - - /// - /// Path to Aspire Dashboard. - /// - public string? Dashboard { get; set; } = BundleDiscovery.DashboardDirectoryName; - /// /// Path to Developer Control Plane. /// public string? Dcp { get; set; } = BundleDiscovery.DcpDirectoryName; /// - /// Path to NuGet Helper tool. - /// - public string? NugetHelper { get; set; } = BundleDiscovery.NuGetHelperDirectoryName; - - /// - /// Path to dev-certs tool. + /// Path to the unified managed binary directory. /// - public string? DevCerts { get; set; } = BundleDiscovery.DevCertsDirectoryName; + public string? Managed { get; set; } = BundleDiscovery.ManagedDirectoryName; } diff --git a/src/Aspire.Cli/Layout/LayoutDiscovery.cs b/src/Aspire.Cli/Layout/LayoutDiscovery.cs index 85673ef5486..2fe62c819a6 100644 --- a/src/Aspire.Cli/Layout/LayoutDiscovery.cs +++ b/src/Aspire.Cli/Layout/LayoutDiscovery.cs @@ -73,10 +73,8 @@ public LayoutDiscovery(ILogger logger) // Check environment variable overrides first var envPath = component switch { - LayoutComponent.Runtime => Environment.GetEnvironmentVariable(BundleDiscovery.RuntimePathEnvVar), LayoutComponent.Dcp => Environment.GetEnvironmentVariable(BundleDiscovery.DcpPathEnvVar), - LayoutComponent.Dashboard => Environment.GetEnvironmentVariable(BundleDiscovery.DashboardPathEnvVar), - LayoutComponent.AppHostServer => Environment.GetEnvironmentVariable(BundleDiscovery.AppHostServerPathEnvVar), + LayoutComponent.Managed => Environment.GetEnvironmentVariable(BundleDiscovery.ManagedPathEnvVar), _ => null }; @@ -114,7 +112,7 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) private LayoutConfiguration? TryLoadLayoutFromPath(string layoutPath) { _logger.LogDebug("TryLoadLayoutFromPath: {Path}", layoutPath); - + if (!Directory.Exists(layoutPath)) { _logger.LogDebug("Layout path does not exist: {Path}", layoutPath); @@ -122,7 +120,7 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) } _logger.LogDebug("Layout path exists, checking directory structure..."); - + // Log directory contents for debugging try { @@ -159,7 +157,7 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) // Check if CLI is in a bundle layout // First, check if components are siblings of the CLI (flat layout): - // {layout}/aspire + {layout}/runtime/ + {layout}/dashboard/ + ... + // {layout}/aspire + {layout}/managed/ + {layout}/dcp/ var layout = TryInferLayout(cliDir); if (layout is not null) { @@ -167,7 +165,7 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) } // Next, check the parent directory (bin/ layout where CLI is in a subdirectory): - // {layout}/bin/aspire + {layout}/runtime/ + {layout}/dashboard/ + ... + // {layout}/bin/aspire + {layout}/managed/ + {layout}/dcp/ var parentDir = Path.GetDirectoryName(cliDir); if (!string.IsNullOrEmpty(parentDir)) { @@ -184,33 +182,28 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) private LayoutConfiguration? TryInferLayout(string layoutPath) { - // Check for essential directories using BundleDiscovery constants - var runtimePath = Path.Combine(layoutPath, BundleDiscovery.RuntimeDirectoryName); - var dashboardPath = Path.Combine(layoutPath, BundleDiscovery.DashboardDirectoryName); + // Check for essential directories + var managedPath = Path.Combine(layoutPath, BundleDiscovery.ManagedDirectoryName); var dcpPath = Path.Combine(layoutPath, BundleDiscovery.DcpDirectoryName); - var serverPath = Path.Combine(layoutPath, BundleDiscovery.AppHostServerDirectoryName); _logger.LogDebug("TryInferLayout: Checking layout at {Path}", layoutPath); - _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.RuntimeDirectoryName, Directory.Exists(runtimePath) ? "exists" : "MISSING"); - _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.DashboardDirectoryName, Directory.Exists(dashboardPath) ? "exists" : "MISSING"); + _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.ManagedDirectoryName, Directory.Exists(managedPath) ? "exists" : "MISSING"); _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.DcpDirectoryName, Directory.Exists(dcpPath) ? "exists" : "MISSING"); - _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.AppHostServerDirectoryName, Directory.Exists(serverPath) ? "exists" : "MISSING"); - if (!Directory.Exists(runtimePath) || !Directory.Exists(dashboardPath) || - !Directory.Exists(dcpPath) || !Directory.Exists(serverPath)) + if (!Directory.Exists(managedPath) || !Directory.Exists(dcpPath)) { _logger.LogDebug("TryInferLayout: Layout rejected - missing required directories"); return null; } - // Check for muxer - var muxerName = BundleDiscovery.GetDotNetExecutableName(); - var muxerPath = Path.Combine(runtimePath, muxerName); - _logger.LogDebug(" runtime/{Muxer}: {Exists}", muxerName, File.Exists(muxerPath) ? "exists" : "MISSING"); - - if (!File.Exists(muxerPath)) + // Check for aspire-managed executable + var managedExeName = BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName); + var managedExePath = Path.Combine(managedPath, managedExeName); + _logger.LogDebug(" managed/{ManagedExe}: {Exists}", managedExeName, File.Exists(managedExePath) ? "exists" : "MISSING"); + + if (!File.Exists(managedExePath)) { - _logger.LogDebug("TryInferLayout: Layout rejected - muxer not found"); + _logger.LogDebug("TryInferLayout: Layout rejected - aspire-managed not found"); return null; } @@ -228,18 +221,14 @@ private LayoutConfiguration LogEnvironmentOverrides(LayoutConfiguration config) { // Environment variables for specific components take precedence // These will be checked at GetComponentPath time, but we note them here for logging - - if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(BundleDiscovery.RuntimePathEnvVar))) - { - _logger.LogDebug("Runtime path override from {EnvVar}", BundleDiscovery.RuntimePathEnvVar); - } + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(BundleDiscovery.DcpPathEnvVar))) { _logger.LogDebug("DCP path override from {EnvVar}", BundleDiscovery.DcpPathEnvVar); } - if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(BundleDiscovery.DashboardPathEnvVar))) + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(BundleDiscovery.ManagedPathEnvVar))) { - _logger.LogDebug("Dashboard path override from {EnvVar}", BundleDiscovery.DashboardPathEnvVar); + _logger.LogDebug("Managed path override from {EnvVar}", BundleDiscovery.ManagedPathEnvVar); } return config; @@ -247,23 +236,15 @@ private LayoutConfiguration LogEnvironmentOverrides(LayoutConfiguration config) private bool ValidateLayout(LayoutConfiguration layout) { - // Check that muxer exists (global dotnet in dev mode, bundled in production) - var muxerPath = layout.GetMuxerPath(); - if (muxerPath is null || !File.Exists(muxerPath)) - { - _logger.LogDebug("Layout validation failed: muxer not found at {Path}", muxerPath); - return false; - } - - // Check that AppHostServer exists - var serverPath = layout.GetAppHostServerPath(); - if (serverPath is null || !File.Exists(serverPath)) + // Check that aspire-managed exists + var managedPath = layout.GetManagedPath(); + if (managedPath is null || !File.Exists(managedPath)) { - _logger.LogDebug("Layout validation failed: AppHostServer not found at {Path}", serverPath); + _logger.LogDebug("Layout validation failed: aspire-managed not found at {Path}", managedPath); return false; } - // Require DCP and Dashboard for valid layouts + // Require DCP for valid layouts var dcpPath = layout.GetComponentPath(LayoutComponent.Dcp); if (dcpPath is null || !Directory.Exists(dcpPath)) { @@ -271,13 +252,6 @@ private bool ValidateLayout(LayoutConfiguration layout) return false; } - var dashboardPath = layout.GetComponentPath(LayoutComponent.Dashboard); - if (dashboardPath is null || !Directory.Exists(dashboardPath)) - { - _logger.LogDebug("Layout validation failed: Dashboard not found"); - return false; - } - return true; } } diff --git a/src/Aspire.Cli/Layout/LayoutProcessRunner.cs b/src/Aspire.Cli/Layout/LayoutProcessRunner.cs index 3b2f5e79192..19d8e1716b8 100644 --- a/src/Aspire.Cli/Layout/LayoutProcessRunner.cs +++ b/src/Aspire.Cli/Layout/LayoutProcessRunner.cs @@ -24,29 +24,22 @@ internal static class RuntimeIdentifierHelper } /// -/// Utilities for running processes using the layout's .NET runtime. -/// Supports both native executables and framework-dependent DLLs. +/// Utilities for running processes using layout tools. +/// All layout tools are self-contained executables — no muxer needed. /// internal static class LayoutProcessRunner { /// - /// Determines if a path refers to a DLL that needs dotnet to run. - /// - private static bool IsDll(string path) => path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase); - - /// - /// Runs a tool and captures output. Automatically detects if the tool - /// is a DLL (needs muxer) or native executable (runs directly). + /// Runs a tool and captures output. The tool is always run directly as a native executable. /// public static async Task<(int ExitCode, string Output, string Error)> RunAsync( - LayoutConfiguration layout, string toolPath, IEnumerable arguments, string? workingDirectory = null, IDictionary? environmentVariables = null, CancellationToken ct = default) { - using var process = CreateProcess(layout, toolPath, arguments, workingDirectory, environmentVariables, redirectOutput: true); + using var process = CreateProcess(toolPath, arguments, workingDirectory, environmentVariables, redirectOutput: true); process.Start(); @@ -63,49 +56,33 @@ internal static class LayoutProcessRunner /// Returns the Process object for the caller to manage. /// public static Process Start( - LayoutConfiguration layout, string toolPath, IEnumerable arguments, string? workingDirectory = null, IDictionary? environmentVariables = null, bool redirectOutput = false) { - var process = CreateProcess(layout, toolPath, arguments, workingDirectory, environmentVariables, redirectOutput); + var process = CreateProcess(toolPath, arguments, workingDirectory, environmentVariables, redirectOutput); process.Start(); return process; } /// /// Creates a configured Process for running a bundle tool. - /// For DLLs, uses the layout's muxer (dotnet). For executables, runs directly. + /// Tools are always self-contained executables — run directly. /// private static Process CreateProcess( - LayoutConfiguration layout, string toolPath, IEnumerable arguments, string? workingDirectory, IDictionary? environmentVariables, bool redirectOutput) { - var isDll = IsDll(toolPath); var process = new Process(); process.StartInfo.UseShellExecute = false; process.StartInfo.CreateNoWindow = true; - - if (isDll) - { - // DLLs need the muxer to run - var muxerPath = layout.GetMuxerPath() - ?? throw new InvalidOperationException("Layout muxer not found. Cannot run framework-dependent tool."); - process.StartInfo.FileName = muxerPath; - process.StartInfo.ArgumentList.Add(toolPath); - } - else - { - // Native executables run directly - process.StartInfo.FileName = toolPath; - } + process.StartInfo.FileName = toolPath; if (redirectOutput) { @@ -113,14 +90,6 @@ private static Process CreateProcess( process.StartInfo.RedirectStandardError = true; } - // Set DOTNET_ROOT to use the layout's runtime - var runtimePath = layout.GetComponentPath(LayoutComponent.Runtime); - if (runtimePath is not null) - { - process.StartInfo.Environment["DOTNET_ROOT"] = runtimePath; - process.StartInfo.Environment["DOTNET_MULTILEVEL_LOOKUP"] = "0"; - } - // Add custom environment variables if (environmentVariables is not null) { diff --git a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs index ce2a5e61136..7b21897deed 100644 --- a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs @@ -118,15 +118,16 @@ private async Task> SearchPackagesInternalAsync( throw new InvalidOperationException("Bundle layout not found. Cannot perform NuGet search in bundle mode."); } - var helperPath = layout.GetNuGetHelperPath(); - if (helperPath is null || !File.Exists(helperPath)) + var managedPath = layout.GetManagedPath(); + if (managedPath is null || !File.Exists(managedPath)) { - throw new InvalidOperationException("NuGet helper tool not found at expected location."); + throw new InvalidOperationException("aspire-managed not found in layout."); } - // Build arguments for NuGetHelper search command + // Build arguments for NuGet search command (via aspire-managed nuget subcommand) var args = new List { + "nuget", "search", "--query", query, "--take", "1000", @@ -155,14 +156,13 @@ private async Task> SearchPackagesInternalAsync( args.Add("--verbose"); } - _logger.LogDebug("Running NuGet search via NuGetHelper: {Query}", query); - _logger.LogDebug("NuGetHelper path: {HelperPath}", helperPath); - _logger.LogDebug("NuGetHelper args: {Args}", string.Join(" ", args)); + _logger.LogDebug("Running NuGet search via aspire-managed: {Query}", query); + _logger.LogDebug("aspire-managed path: {ManagedPath}", managedPath); + _logger.LogDebug("NuGet search args: {Args}", string.Join(" ", args)); _logger.LogDebug("Working directory: {WorkingDir}", workingDirectory.FullName); var (exitCode, output, error) = await LayoutProcessRunner.RunAsync( - layout, - helperPath, + managedPath, args, workingDirectory: workingDirectory.FullName, ct: cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/NuGet/BundleNuGetService.cs b/src/Aspire.Cli/NuGet/BundleNuGetService.cs index e54e612559e..4a1193fe051 100644 --- a/src/Aspire.Cli/NuGet/BundleNuGetService.cs +++ b/src/Aspire.Cli/NuGet/BundleNuGetService.cs @@ -60,10 +60,10 @@ public async Task RestorePackagesAsync( throw new InvalidOperationException("Bundle layout not found. Cannot perform NuGet restore in bundle mode."); } - var helperPath = layout.GetNuGetHelperPath(); - if (helperPath is null || !File.Exists(helperPath)) + var managedPath = layout.GetManagedPath(); + if (managedPath is null || !File.Exists(managedPath)) { - throw new InvalidOperationException($"NuGet helper tool not found."); + throw new InvalidOperationException("aspire-managed not found in layout."); } var packageList = packages.ToList(); @@ -89,8 +89,10 @@ public async Task RestorePackagesAsync( Directory.CreateDirectory(objDir); // Step 1: Restore packages + // Prepend "nuget" subcommand for aspire-managed dispatch var restoreArgs = new List { + "nuget", "restore", "--output", objDir, "--framework", targetFramework @@ -125,12 +127,11 @@ public async Task RestorePackagesAsync( } _logger.LogDebug("Restoring {Count} packages", packageList.Count); - _logger.LogDebug("NuGetHelper path: {HelperPath}", helperPath); - _logger.LogDebug("NuGetHelper args: {Args}", string.Join(" ", restoreArgs)); + _logger.LogDebug("aspire-managed path: {ManagedPath}", managedPath); + _logger.LogDebug("NuGet restore args: {Args}", string.Join(" ", restoreArgs)); var (exitCode, output, error) = await LayoutProcessRunner.RunAsync( - layout, - helperPath, + managedPath, restoreArgs, ct: ct); @@ -149,8 +150,10 @@ public async Task RestorePackagesAsync( } // Step 2: Create flat layout + // Prepend "nuget" subcommand for aspire-managed dispatch var layoutArgs = new List { + "nuget", "layout", "--assets", assetsPath, "--output", libsDir, @@ -164,11 +167,10 @@ public async Task RestorePackagesAsync( } _logger.LogDebug("Creating layout from {AssetsPath}", assetsPath); - _logger.LogDebug("Layout args: {Args}", string.Join(" ", layoutArgs)); + _logger.LogDebug("NuGet layout args: {Args}", string.Join(" ", layoutArgs)); (exitCode, output, error) = await LayoutProcessRunner.RunAsync( - layout, - helperPath, + managedPath, layoutArgs, ct: ct); diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 14d7798d23b..05a052f3177 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -16,6 +16,7 @@ using Aspire.Cli.Caching; using Aspire.Cli.Certificates; using Aspire.Cli.Commands; +using Microsoft.AspNetCore.Certificates.Generation; using Aspire.Cli.Commands.Sdk; using Aspire.Cli.Configuration; using Aspire.Cli.Diagnostics; @@ -252,22 +253,9 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTelemetryServices(); builder.Services.AddTransient(); - // Register certificate tool runner implementations - factory chooses based on embedded bundle - builder.Services.AddSingleton(sp => - { - var loggerFactory = sp.GetRequiredService(); - var bundleService = sp.GetRequiredService(); - - if (bundleService.IsBundle) - { - return new BundleCertificateToolRunner( - bundleService, - loggerFactory.CreateLogger()); - } - - // Fall back to SDK-based runner - return new SdkCertificateToolRunner(loggerFactory.CreateLogger()); - }); + // Register certificate tool runner - uses native CertificateManager directly (no subprocess needed) + builder.Services.AddSingleton(sp => CertificateManager.Create(sp.GetRequiredService>())); + builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddSingleton(); diff --git a/src/Aspire.Cli/Projects/AppHostServerProject.cs b/src/Aspire.Cli/Projects/AppHostServerProject.cs index c864bfb0835..8466bf77766 100644 --- a/src/Aspire.Cli/Projects/AppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/AppHostServerProject.cs @@ -76,7 +76,7 @@ public async Task CreateAsync(string appPath, Cancellatio var layout = await bundleService.EnsureExtractedAndGetLayoutAsync(cancellationToken); // Priority 3: Check if we have a bundle layout with a pre-built AppHost server - if (layout is not null && layout.GetAppHostServerPath() is string serverPath && File.Exists(serverPath)) + if (layout is not null && layout.GetManagedPath() is string serverPath && File.Exists(serverPath)) { return new PrebuiltAppHostServer( appPath, diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index ee528bedb4d..2a0c1c8c0d3 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -72,17 +72,17 @@ public PrebuiltAppHostServer( public string AppPath => _appPath; /// - /// Gets the path to the pre-built AppHost server (exe or DLL). + /// Gets the path to the aspire-managed executable (used as the server). /// public string GetServerPath() { - var serverPath = _layout.GetAppHostServerPath(); - if (serverPath is null || !File.Exists(serverPath)) + var managedPath = _layout.GetManagedPath(); + if (managedPath is null || !File.Exists(managedPath)) { - throw new InvalidOperationException("Pre-built AppHost server not found in layout."); + throw new InvalidOperationException("aspire-managed not found in layout."); } - return serverPath; + return managedPath; } /// @@ -220,11 +220,7 @@ public async Task PrepareAsync( { var serverPath = GetServerPath(); - // Get runtime path for DOTNET_ROOT - var runtimePath = _layout.GetDotNetExePath(); - var runtimeDir = runtimePath is not null ? Path.GetDirectoryName(runtimePath) : null; - - // Bundle always uses single-file executables - run directly + // aspire-managed is self-contained - run directly var startInfo = new ProcessStartInfo(serverPath) { WorkingDirectory = _workingDirectory, @@ -233,15 +229,8 @@ public async Task PrepareAsync( CreateNoWindow = true }; - // Set DOTNET_ROOT so the executable can find the runtime - if (runtimeDir is not null) - { - startInfo.Environment["DOTNET_ROOT"] = runtimeDir; - startInfo.Environment["DOTNET_MULTILEVEL_LOOKUP"] = "0"; - } - - // Add arguments to point to our appsettings.json - startInfo.ArgumentList.Add("--"); + // Insert "server" subcommand, then remaining args + startInfo.ArgumentList.Add("server"); startInfo.ArgumentList.Add("--contentRoot"); startInfo.ArgumentList.Add(_workingDirectory); @@ -259,12 +248,6 @@ public async Task PrepareAsync( startInfo.Environment["REMOTE_APP_HOST_PID"] = hostPid.ToString(System.Globalization.CultureInfo.InvariantCulture); startInfo.Environment[KnownConfigNames.CliProcessId] = hostPid.ToString(System.Globalization.CultureInfo.InvariantCulture); - // Also set ASPIRE_RUNTIME_PATH so DashboardEventHandlers knows which dotnet to use - if (runtimeDir is not null) - { - startInfo.Environment[BundleDiscovery.RuntimePathEnvVar] = runtimeDir; - } - // Pass the integration libs path so the server can resolve assemblies via AssemblyLoader if (_integrationLibsPath is not null) { @@ -283,12 +266,11 @@ public async Task PrepareAsync( startInfo.Environment[BundleDiscovery.DcpPathEnvVar] = dcpPath; } - var dashboardPath = _layout.GetDashboardPath(); - if (dashboardPath is not null) + // Set the dashboard path so the AppHost can locate and launch the dashboard binary + var managedPath = _layout.GetManagedPath(); + if (managedPath is not null) { - // Bundle uses single-file executables - var dashboardExe = Path.Combine(dashboardPath, BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)); - startInfo.Environment[BundleDiscovery.DashboardPathEnvVar] = dashboardExe; + startInfo.Environment[BundleDiscovery.DashboardPathEnvVar] = managedPath; } // Apply environment variables from apphost.run.json diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 6f20bb6a086..79bca25c326 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -288,35 +288,24 @@ private void AddDashboardResource(DistributedApplicationModel model) var fullyQualifiedDashboardPath = Path.GetFullPath(dashboardPath); var dashboardWorkingDirectory = Path.GetDirectoryName(fullyQualifiedDashboardPath); - // Create custom runtime config with AppHost's framework versions - var customRuntimeConfigPath = CreateCustomRuntimeConfig(fullyQualifiedDashboardPath); - - // Determine if this is a single-file executable or DLL-based deployment - // Single-file: run the exe directly with custom runtime config - // DLL-based: run via dotnet exec - var isSingleFileExe = IsSingleFileExecutable(fullyQualifiedDashboardPath); - ExecutableResource dashboardResource; - - if (isSingleFileExe) + + if (BundleDiscovery.IsAspireManagedBinary(fullyQualifiedDashboardPath)) { - // Single-file executable - run directly + // aspire-managed is self-contained, run directly with "dashboard" subcommand dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, fullyQualifiedDashboardPath, dashboardWorkingDirectory ?? ""); - - // Set DOTNET_ROOT so the single-file app can find the shared framework - var dotnetRoot = BundleDiscovery.GetDotNetRoot(); - if (!string.IsNullOrEmpty(dotnetRoot)) + + dashboardResource.Annotations.Add(new CommandLineArgsCallbackAnnotation(args => { - dashboardResource.Annotations.Add(new EnvironmentCallbackAnnotation(env => - { - env["DOTNET_ROOT"] = dotnetRoot; - env["DOTNET_MULTILEVEL_LOOKUP"] = "0"; - })); - } + args.Insert(0, "dashboard"); + })); } else { - // DLL-based deployment - find the DLL and run via dotnet exec + // Non-bundle: run via dotnet exec with custom runtime config + // Create custom runtime config with AppHost's framework versions + var customRuntimeConfigPath = CreateCustomRuntimeConfig(fullyQualifiedDashboardPath); + string dashboardDll; if (string.Equals(".dll", Path.GetExtension(fullyQualifiedDashboardPath), StringComparison.OrdinalIgnoreCase)) { @@ -338,8 +327,7 @@ private void AddDashboardResource(DistributedApplicationModel model) distributedApplicationLogger.LogError("Dashboard DLL not found: {Path}", dashboardDll); } - var dotnetExecutable = BundleDiscovery.GetDotNetExecutablePath(); - dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, dotnetExecutable, dashboardWorkingDirectory ?? ""); + dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, "dotnet", dashboardWorkingDirectory ?? ""); dashboardResource.Annotations.Add(new CommandLineArgsCallbackAnnotation(args => { @@ -926,51 +914,6 @@ public async ValueTask DisposeAsync() } } } - - /// - /// Determines if the given path is a single-file executable (no accompanying DLL). - /// - private static bool IsSingleFileExecutable(string path) - { - // Single-file apps are executables without a corresponding DLL. - // On Windows the file ends with .exe; on Unix there is no reliable - // extension (e.g. "Aspire.Dashboard" has a dot but is still an executable). - // The definitive check is: executable exists on disk and there is no - // matching .dll next to it. - - if (string.Equals(".dll", Path.GetExtension(path), StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (!File.Exists(path)) - { - return false; - } - - // On Unix, verify the file is executable - if (!OperatingSystem.IsWindows()) - { - var fileInfo = new FileInfo(path); - var mode = fileInfo.UnixFileMode; - if ((mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) == 0) - { - return false; - } - } - - // Check if there's a corresponding DLL — strip .exe on Windows, - // but on Unix the filename may contain dots (e.g. "Aspire.Dashboard"), - // so always derive the DLL name by appending .dll to the full filename. - var directory = Path.GetDirectoryName(path)!; - var fileName = Path.GetFileName(path); - var baseName = fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) - ? fileName[..^4] - : fileName; - var dllPath = Path.Combine(directory, $"{baseName}.dll"); - - return !File.Exists(dllPath); - } } internal sealed class DashboardLogMessage diff --git a/src/Aspire.Managed/Aspire.Managed.csproj b/src/Aspire.Managed/Aspire.Managed.csproj new file mode 100644 index 00000000000..2226596ef83 --- /dev/null +++ b/src/Aspire.Managed/Aspire.Managed.csproj @@ -0,0 +1,49 @@ + + + + Exe + net10.0 + enable + enable + aspire-managed + + + false + false + false + + $(NoWarn);CS1591 + + + true + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Aspire.Cli.NuGetHelper/Commands/LayoutCommand.cs b/src/Aspire.Managed/NuGet/Commands/LayoutCommand.cs similarity index 99% rename from src/Aspire.Cli.NuGetHelper/Commands/LayoutCommand.cs rename to src/Aspire.Managed/NuGet/Commands/LayoutCommand.cs index 8d4f98e85b2..ff8d2419818 100644 --- a/src/Aspire.Cli.NuGetHelper/Commands/LayoutCommand.cs +++ b/src/Aspire.Managed/NuGet/Commands/LayoutCommand.cs @@ -5,7 +5,7 @@ using System.Globalization; using NuGet.ProjectModel; -namespace Aspire.Cli.NuGetHelper.Commands; +namespace Aspire.Managed.NuGet.Commands; /// /// Layout command - creates a flat DLL layout from a project.assets.json file. diff --git a/src/Aspire.Cli.NuGetHelper/Commands/RestoreCommand.cs b/src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs similarity index 99% rename from src/Aspire.Cli.NuGetHelper/Commands/RestoreCommand.cs rename to src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs index 58ad8705ba0..0507b06af52 100644 --- a/src/Aspire.Cli.NuGetHelper/Commands/RestoreCommand.cs +++ b/src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs @@ -12,7 +12,7 @@ using NuGet.Protocol.Core.Types; using NuGet.Versioning; -namespace Aspire.Cli.NuGetHelper.Commands; +namespace Aspire.Managed.NuGet.Commands; /// /// Restore command - restores NuGet packages without requiring a .csproj file. diff --git a/src/Aspire.Cli.NuGetHelper/Commands/SearchCommand.cs b/src/Aspire.Managed/NuGet/Commands/SearchCommand.cs similarity index 99% rename from src/Aspire.Cli.NuGetHelper/Commands/SearchCommand.cs rename to src/Aspire.Managed/NuGet/Commands/SearchCommand.cs index d61272e5228..d738110ce88 100644 --- a/src/Aspire.Cli.NuGetHelper/Commands/SearchCommand.cs +++ b/src/Aspire.Managed/NuGet/Commands/SearchCommand.cs @@ -10,7 +10,7 @@ using NuGet.Protocol.Core.Types; using INuGetLogger = NuGet.Common.ILogger; -namespace Aspire.Cli.NuGetHelper.Commands; +namespace Aspire.Managed.NuGet.Commands; /// /// Search command - searches NuGet feeds for packages using NuGet.Protocol. diff --git a/src/Aspire.Cli.NuGetHelper/NuGetLogger.cs b/src/Aspire.Managed/NuGet/NuGetLogger.cs similarity index 98% rename from src/Aspire.Cli.NuGetHelper/NuGetLogger.cs rename to src/Aspire.Managed/NuGet/NuGetLogger.cs index ce28391a5f1..247e9f6907d 100644 --- a/src/Aspire.Cli.NuGetHelper/NuGetLogger.cs +++ b/src/Aspire.Managed/NuGet/NuGetLogger.cs @@ -5,7 +5,7 @@ using NuGetLogMessage = NuGet.Common.ILogMessage; using INuGetLogger = NuGet.Common.ILogger; -namespace Aspire.Cli.NuGetHelper; +namespace Aspire.Managed.NuGet; /// /// Console logger adapter for NuGet operations. diff --git a/src/Aspire.Managed/Program.cs b/src/Aspire.Managed/Program.cs new file mode 100644 index 00000000000..ff1e24f37d7 --- /dev/null +++ b/src/Aspire.Managed/Program.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard; +using Aspire.Managed.NuGet.Commands; +using System.CommandLine; + +return args switch +{ + ["dashboard", .. var rest] => RunDashboard(rest), + ["server", .. var rest] => await RunServer(rest).ConfigureAwait(false), + ["nuget", .. var rest] => await RunNuGet(rest).ConfigureAwait(false), + _ => ShowUsage() +}; + +static int RunDashboard(string[] args) +{ + var options = new WebApplicationOptions + { + Args = args, + ContentRootPath = AppContext.BaseDirectory + }; + + var app = new DashboardWebApplication(options: options); + return app.Run(); +} + +static async Task RunServer(string[] args) +{ + await Aspire.Hosting.RemoteHost.RemoteHostServer.RunAsync(args).ConfigureAwait(false); + return 0; +} + +static async Task RunNuGet(string[] args) +{ + var rootCommand = new RootCommand("Aspire NuGet Helper - Package operations for Aspire CLI bundle"); + rootCommand.Subcommands.Add(SearchCommand.Create()); + rootCommand.Subcommands.Add(RestoreCommand.Create()); + rootCommand.Subcommands.Add(LayoutCommand.Create()); + return await rootCommand.Parse(args).InvokeAsync().ConfigureAwait(false); +} + +static int ShowUsage() +{ + Console.Error.WriteLine($"Usage: {AppDomain.CurrentDomain.FriendlyName} [args...]"); + return 1; +} diff --git a/src/Shared/BundleDiscovery.cs b/src/Shared/BundleDiscovery.cs index 726b22d0e98..e72818683ac 100644 --- a/src/Shared/BundleDiscovery.cs +++ b/src/Shared/BundleDiscovery.cs @@ -32,18 +32,14 @@ internal static class BundleDiscovery /// /// Environment variable for overriding the Dashboard path. + /// Still used by DcpOptions/DashboardEventHandlers — value now points to aspire-managed exe. /// public const string DashboardPathEnvVar = "ASPIRE_DASHBOARD_PATH"; /// - /// Environment variable for overriding the .NET runtime path. + /// Environment variable for overriding the aspire-managed path. /// - public const string RuntimePathEnvVar = "ASPIRE_RUNTIME_PATH"; - - /// - /// Environment variable for overriding the AppHost Server path. - /// - public const string AppHostServerPathEnvVar = "ASPIRE_APPHOST_SERVER_PATH"; + public const string ManagedPathEnvVar = "ASPIRE_MANAGED_PATH"; /// /// Environment variable to force SDK mode (skip bundle detection). @@ -65,53 +61,18 @@ internal static class BundleDiscovery public const string DcpDirectoryName = "dcp"; /// - /// Directory name for Dashboard in the bundle layout. - /// - public const string DashboardDirectoryName = "dashboard"; - - /// - /// Directory name for .NET runtime in the bundle layout. - /// - public const string RuntimeDirectoryName = "runtime"; - - /// - /// Directory name for AppHost Server in the bundle layout. - /// - public const string AppHostServerDirectoryName = "aspire-server"; - - /// - /// Directory name for NuGet Helper tool in the bundle layout. + /// Directory name for the managed binary in the bundle layout. /// - public const string NuGetHelperDirectoryName = "tools/aspire-nuget"; - - /// - /// Directory name for dev-certs tool in the bundle layout. - /// - public const string DevCertsDirectoryName = "tools/dev-certs"; + public const string ManagedDirectoryName = "managed"; // ═══════════════════════════════════════════════════════════════════════ // EXECUTABLE NAMES (without path, just the file name) // ═══════════════════════════════════════════════════════════════════════ /// - /// Executable name for the AppHost Server. - /// - public const string AppHostServerExecutableName = "aspire-server"; - - /// - /// Executable name for the Dashboard. + /// Executable name for the unified managed binary. /// - public const string DashboardExecutableName = "Aspire.Dashboard"; - - /// - /// Executable name for the NuGet Helper tool. - /// - public const string NuGetHelperExecutableName = "aspire-nuget"; - - /// - /// Executable name for the dev-certs tool. - /// - public const string DevCertsExecutableName = "dotnet-dev-certs"; + public const string ManagedExecutableName = "aspire-managed"; // ═══════════════════════════════════════════════════════════════════════ // DISCOVERY METHODS @@ -156,28 +117,28 @@ public static bool TryDiscoverDcpFromDirectory( } /// - /// Attempts to discover Dashboard from a base directory. + /// Attempts to discover the aspire-managed binary from a base directory. /// /// The base directory to search from. - /// The full path to the Dashboard directory if found. - /// True if Dashboard was found, false otherwise. - public static bool TryDiscoverDashboardFromDirectory( + /// The full path to the aspire-managed executable if found. + /// True if aspire-managed was found, false otherwise. + public static bool TryDiscoverManagedFromDirectory( string baseDirectory, - out string? dashboardPath) + out string? managedPath) { - dashboardPath = null; + managedPath = null; if (string.IsNullOrEmpty(baseDirectory) || !Directory.Exists(baseDirectory)) { return false; } - var dashboardDir = Path.Combine(baseDirectory, DashboardDirectoryName); - var dashboardExe = Path.Combine(dashboardDir, GetExecutableFileName(DashboardExecutableName)); + var managedDir = Path.Combine(baseDirectory, ManagedDirectoryName); + var managedExe = Path.Combine(managedDir, GetExecutableFileName(ManagedExecutableName)); - if (File.Exists(dashboardExe)) + if (File.Exists(managedExe)) { - dashboardPath = dashboardDir; + managedPath = managedExe; return true; } @@ -207,12 +168,12 @@ public static bool TryDiscoverDcpFromEntryAssembly( } /// - /// Attempts to discover Dashboard relative to the entry assembly. + /// Attempts to discover aspire-managed relative to the entry assembly. /// This is used by Aspire.Hosting when no environment variables are set. /// - public static bool TryDiscoverDashboardFromEntryAssembly(out string? dashboardPath) + public static bool TryDiscoverManagedFromEntryAssembly(out string? managedPath) { - dashboardPath = null; + managedPath = null; var baseDir = GetEntryAssemblyDirectory(); if (baseDir is null) @@ -220,52 +181,7 @@ public static bool TryDiscoverDashboardFromEntryAssembly(out string? dashboardPa return false; } - return TryDiscoverDashboardFromDirectory(baseDir, out dashboardPath); - } - - /// - /// Attempts to discover .NET runtime from a base directory. - /// Checks for the expected bundle layout structure with dotnet executable. - /// - /// The base directory to search from. - /// The full path to the runtime directory if found. - /// True if runtime was found, false otherwise. - public static bool TryDiscoverRuntimeFromDirectory(string baseDirectory, out string? runtimePath) - { - runtimePath = null; - - if (string.IsNullOrEmpty(baseDirectory) || !Directory.Exists(baseDirectory)) - { - return false; - } - - var runtimeDir = Path.Combine(baseDirectory, RuntimeDirectoryName); - var dotnetPath = Path.Combine(runtimeDir, GetDotNetExecutableName()); - - if (File.Exists(dotnetPath)) - { - runtimePath = runtimeDir; - return true; - } - - return false; - } - - /// - /// Attempts to discover .NET runtime relative to the entry assembly. - /// This is used by Aspire.Hosting when no environment variables are set. - /// - public static bool TryDiscoverRuntimeFromEntryAssembly(out string? runtimePath) - { - runtimePath = null; - - var baseDir = GetEntryAssemblyDirectory(); - if (baseDir is null) - { - return false; - } - - return TryDiscoverRuntimeFromDirectory(baseDir, out runtimePath); + return TryDiscoverManagedFromDirectory(baseDir, out managedPath); } /// @@ -291,27 +207,11 @@ public static bool TryDiscoverDcpFromProcessPath( } /// - /// Attempts to discover Dashboard relative to the current process. - /// - public static bool TryDiscoverDashboardFromProcessPath(out string? dashboardPath) - { - dashboardPath = null; - - var baseDir = GetProcessDirectory(); - if (baseDir is null) - { - return false; - } - - return TryDiscoverDashboardFromDirectory(baseDir, out dashboardPath); - } - - /// - /// Attempts to discover .NET runtime relative to the current process. + /// Attempts to discover aspire-managed relative to the current process. /// - public static bool TryDiscoverRuntimeFromProcessPath(out string? runtimePath) + public static bool TryDiscoverManagedFromProcessPath(out string? managedPath) { - runtimePath = null; + managedPath = null; var baseDir = GetProcessDirectory(); if (baseDir is null) @@ -319,7 +219,7 @@ public static bool TryDiscoverRuntimeFromProcessPath(out string? runtimePath) return false; } - return TryDiscoverRuntimeFromDirectory(baseDir, out runtimePath); + return TryDiscoverManagedFromDirectory(baseDir, out managedPath); } // ═══════════════════════════════════════════════════════════════════════ @@ -343,18 +243,10 @@ public static string GetDcpExecutableName() return OperatingSystem.IsWindows() ? "dcp.exe" : "dcp"; } - /// - /// Gets the platform-specific dotnet executable name. - /// - public static string GetDotNetExecutableName() - { - return OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; - } - /// /// Gets the platform-specific executable name with extension. /// - /// The base executable name without extension (e.g., "aspire-server"). + /// The base executable name without extension (e.g., "aspire-managed"). /// The executable name with platform-appropriate extension. public static string GetExecutableFileName(string baseName) { @@ -372,58 +264,12 @@ public static string GetDllFileName(string baseName) } /// - /// Gets the full path to the dotnet executable from the bundled runtime, or "dotnet" if not available. - /// Resolution order: environment variable → disk discovery → PATH fallback. - /// - /// Full path to bundled dotnet executable, or "dotnet" to use PATH resolution. - public static string GetDotNetExecutablePath() - { - // 1. Check environment variable (set by CLI for guest apphosts) - var runtimePath = Environment.GetEnvironmentVariable(RuntimePathEnvVar); - if (!string.IsNullOrEmpty(runtimePath)) - { - var dotnetPath = Path.Combine(runtimePath, GetDotNetExecutableName()); - if (File.Exists(dotnetPath)) - { - return dotnetPath; - } - } - - // 2. Try disk discovery (for future installed bundle scenario) - if (TryDiscoverRuntimeFromEntryAssembly(out var discoveredRuntimePath) && discoveredRuntimePath is not null) - { - var dotnetPath = Path.Combine(discoveredRuntimePath, GetDotNetExecutableName()); - if (File.Exists(dotnetPath)) - { - return dotnetPath; - } - } - - // 3. Fall back to PATH-based resolution - return "dotnet"; - } - - /// - /// Gets the DOTNET_ROOT path for the bundled runtime. - /// This is the directory containing the dotnet executable and shared frameworks. + /// Determines if the given file path points to an aspire-managed binary. /// - /// The DOTNET_ROOT path if available, otherwise null. - public static string? GetDotNetRoot() + public static bool IsAspireManagedBinary(string path) { - // 1. Check environment variable (set by CLI for guest apphosts) - var runtimePath = Environment.GetEnvironmentVariable(RuntimePathEnvVar); - if (!string.IsNullOrEmpty(runtimePath) && Directory.Exists(runtimePath)) - { - return runtimePath; - } - - // 2. Try disk discovery (for future installed bundle scenario) - if (TryDiscoverRuntimeFromEntryAssembly(out var discoveredRuntimePath) && discoveredRuntimePath is not null) - { - return discoveredRuntimePath; - } - - return null; + var fileName = Path.GetFileNameWithoutExtension(path); + return string.Equals(fileName, ManagedExecutableName, StringComparison.OrdinalIgnoreCase); } /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index fa33ed999bc..cc2455000c8 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs @@ -550,21 +550,21 @@ internal static Hex1bTerminalInputSequenceBuilder InstallAspireBundleFromPullReq int prNumber, SequenceCounter counter) { - // The bundle script may not be on main yet, so we need to fetch it from the PR's branch. + // 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 bundle script from that SHA + // PowerShell: Get PR head SHA, then fetch and run install script from that SHA command = $"$ref = (gh api repos/dotnet/aspire/pulls/{prNumber} --jq '.head.sha'); " + - $"iex \"& {{ $(irm https://raw.githubusercontent.com/dotnet/aspire/$ref/eng/scripts/get-aspire-cli-bundle-pr.ps1) }} {prNumber}\""; + $"iex \"& {{ $(irm https://raw.githubusercontent.com/dotnet/aspire/$ref/eng/scripts/get-aspire-cli-pr.ps1) }} {prNumber}\""; } else { - // Bash: Get PR head SHA, then fetch and run bundle script from that SHA + // Bash: Get PR head SHA, then fetch and run install script from that SHA command = $"ref=$(gh api repos/dotnet/aspire/pulls/{prNumber} --jq '.head.sha') && " + - $"curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/$ref/eng/scripts/get-aspire-cli-bundle-pr.sh | bash -s -- {prNumber}"; + $"curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/$ref/eng/scripts/get-aspire-cli-pr.sh | bash -s -- {prNumber}"; } return builder diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs index 9f28ed4d74d..9046e7b7585 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs @@ -340,14 +340,11 @@ public async Task AddDashboardResource_CreatesExecutableResourceWithCustomRuntim var netCoreFramework = frameworks.First(f => f.GetProperty("name").GetString() == "Microsoft.NETCore.App"); var aspNetCoreFramework = frameworks.First(f => f.GetProperty("name").GetString() == "Microsoft.AspNetCore.App"); - // The versions should be updated to match the AppHost's target framework versions - // In the test environment, the AppHost targets .NET 8.0, so the versions should be "8.0.0" Assert.Equal("8.0.0", netCoreFramework.GetProperty("version").GetString()); Assert.Equal("8.0.0", aspNetCoreFramework.GetProperty("version").GetString()); } finally { - // Cleanup if (Directory.Exists(tempDir)) { Directory.Delete(tempDir, recursive: true); @@ -363,7 +360,6 @@ public async Task AddDashboardResource_WithExecutablePath_CreatesCorrectArgument var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); var configuration = new ConfigurationBuilder().Build(); - // Create a temporary test dashboard directory with exe, dll and runtimeconfig.json var tempDir = Path.GetTempFileName(); File.Delete(tempDir); Directory.CreateDirectory(tempDir); @@ -374,7 +370,6 @@ public async Task AddDashboardResource_WithExecutablePath_CreatesCorrectArgument var dashboardDll = Path.Combine(tempDir, "Aspire.Dashboard.dll"); var runtimeConfig = Path.Combine(tempDir, "Aspire.Dashboard.runtimeconfig.json"); - // Create mock files File.WriteAllText(dashboardExe, "mock exe content"); File.WriteAllText(dashboardDll, "mock dll content"); @@ -415,11 +410,10 @@ public async Task AddDashboardResource_WithExecutablePath_CreatesCorrectArgument Assert.Equal("exec", args[0]); Assert.Equal("--runtimeconfig", args[1]); Assert.True(File.Exists((string)args[2]), "Custom runtime config file should exist"); - Assert.Equal(dashboardDll, args[3]); // Should point to the DLL, not the EXE + Assert.Equal(dashboardDll, args[3]); } finally { - // Cleanup if (Directory.Exists(tempDir)) { Directory.Delete(tempDir, recursive: true); @@ -435,18 +429,16 @@ public async Task AddDashboardResource_WithUnixExecutablePath_CreatesCorrectArgu var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); var configuration = new ConfigurationBuilder().Build(); - // Create a temporary test dashboard directory with Unix executable (no extension), dll and runtimeconfig.json var tempDir = Path.GetTempFileName(); File.Delete(tempDir); Directory.CreateDirectory(tempDir); try { - var dashboardExe = Path.Combine(tempDir, "Aspire.Dashboard"); // No extension for Unix + var dashboardExe = Path.Combine(tempDir, "Aspire.Dashboard"); var dashboardDll = Path.Combine(tempDir, "Aspire.Dashboard.dll"); var runtimeConfig = Path.Combine(tempDir, "Aspire.Dashboard.runtimeconfig.json"); - // Create mock files File.WriteAllText(dashboardExe, "mock exe content"); File.WriteAllText(dashboardDll, "mock dll content"); @@ -487,11 +479,10 @@ public async Task AddDashboardResource_WithUnixExecutablePath_CreatesCorrectArgu Assert.Equal("exec", args[0]); Assert.Equal("--runtimeconfig", args[1]); Assert.True(File.Exists((string)args[2]), "Custom runtime config file should exist"); - Assert.Equal(dashboardDll, args[3]); // Should point to the DLL, not the EXE + Assert.Equal(dashboardDll, args[3]); } finally { - // Cleanup if (Directory.Exists(tempDir)) { Directory.Delete(tempDir, recursive: true); @@ -507,7 +498,6 @@ public async Task AddDashboardResource_WithDirectDllPath_CreatesCorrectArguments var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); var configuration = new ConfigurationBuilder().Build(); - // Create a temporary test dashboard directory with direct dll and runtimeconfig.json var tempDir = Path.GetTempFileName(); File.Delete(tempDir); Directory.CreateDirectory(tempDir); @@ -517,7 +507,6 @@ public async Task AddDashboardResource_WithDirectDllPath_CreatesCorrectArguments var dashboardDll = Path.Combine(tempDir, "Aspire.Dashboard.dll"); var runtimeConfig = Path.Combine(tempDir, "Aspire.Dashboard.runtimeconfig.json"); - // Create mock files File.WriteAllText(dashboardDll, "mock dll content"); var originalConfig = new @@ -557,11 +546,10 @@ public async Task AddDashboardResource_WithDirectDllPath_CreatesCorrectArguments Assert.Equal("exec", args[0]); Assert.Equal("--runtimeconfig", args[1]); Assert.True(File.Exists((string)args[2]), "Custom runtime config file should exist"); - Assert.Equal(dashboardDll, args[3]); // Should point to the same DLL, not modify it + Assert.Equal(dashboardDll, args[3]); } finally { - // Cleanup if (Directory.Exists(tempDir)) { Directory.Delete(tempDir, recursive: true); diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs index f04e285721a..ddf7ac4354e 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs @@ -51,7 +51,6 @@ public async Task DashboardIsAutomaticallyAddedAsHiddenResource(string showDashb Assert.NotNull(dashboard); Assert.Equal("aspire-dashboard", dashboard.Name); - // dotnet exec --runtimeconfig .dll Assert.Equal("dotnet", dashboard.Command); Assert.Equal(args[3], $"{dashboardPath}.dll"); Assert.True(initialSnapshot.InitialSnapshot.IsHidden); @@ -261,7 +260,7 @@ public async Task DashboardWithDllPathLaunchesDotnet() Assert.Equal("dotnet", dashboard.Command); Assert.Equal("exec", args[0]); Assert.Equal("--runtimeconfig", args[1]); - Assert.EndsWith(".json", args[2]); // Generated temp runtimeconfig.json path + Assert.EndsWith(".json", args[2]); Assert.Equal(dashboardPath, args[3]); } diff --git a/tools/CreateLayout/Program.cs b/tools/CreateLayout/Program.cs index dbddc286b66..b04ec44e7a1 100644 --- a/tools/CreateLayout/Program.cs +++ b/tools/CreateLayout/Program.cs @@ -38,28 +38,12 @@ public static async Task Main(string[] args) Required = true }; - var runtimeOption = new Option("--runtime", "-r") - { - Description = "Path to .NET runtime to include (alternative to --download-runtime)" - }; - var bundleVersionOption = new Option("--bundle-version") { Description = "Version string for the layout", DefaultValueFactory = _ => "0.0.0-dev" }; - var runtimeVersionOption = new Option("--runtime-version") - { - Description = ".NET SDK version to download", - Required = true - }; - - var downloadRuntimeOption = new Option("--download-runtime") - { - Description = "Download .NET and ASP.NET runtimes from Microsoft" - }; - var archiveOption = new Option("--archive") { Description = "Create archive (zip/tar.gz) after building" @@ -74,10 +58,7 @@ public static async Task Main(string[] args) rootCommand.Options.Add(outputOption); rootCommand.Options.Add(artifactsOption); rootCommand.Options.Add(ridOption); - rootCommand.Options.Add(runtimeOption); rootCommand.Options.Add(bundleVersionOption); - rootCommand.Options.Add(runtimeVersionOption); - rootCommand.Options.Add(downloadRuntimeOption); rootCommand.Options.Add(archiveOption); rootCommand.Options.Add(verboseOption); @@ -86,16 +67,13 @@ public static async Task Main(string[] args) var outputPath = parseResult.GetValue(outputOption)!; var artifactsPath = parseResult.GetValue(artifactsOption)!; var rid = parseResult.GetValue(ridOption)!; - var runtimePath = parseResult.GetValue(runtimeOption); var version = parseResult.GetValue(bundleVersionOption)!; - var runtimeVersion = parseResult.GetValue(runtimeVersionOption)!; - var downloadRuntime = parseResult.GetValue(downloadRuntimeOption); var createArchive = parseResult.GetValue(archiveOption); var verbose = parseResult.GetValue(verboseOption); try { - using var builder = new LayoutBuilder(outputPath, artifactsPath, runtimePath, rid, version, runtimeVersion, downloadRuntime, verbose); + using var builder = new LayoutBuilder(outputPath, artifactsPath, rid, version, verbose); await builder.BuildAsync().ConfigureAwait(false); if (createArchive) @@ -128,29 +106,22 @@ internal sealed class LayoutBuilder : IDisposable { private readonly string _outputPath; private readonly string _artifactsPath; - private readonly string? _runtimePath; private readonly string _rid; private readonly string _version; - private readonly string _runtimeVersion; - private readonly bool _downloadRuntime; private readonly bool _verbose; - private readonly HttpClient _httpClient = new(); - public LayoutBuilder(string outputPath, string artifactsPath, string? runtimePath, string rid, string version, string runtimeVersion, bool downloadRuntime, bool verbose) + public LayoutBuilder(string outputPath, string artifactsPath, string rid, string version, bool verbose) { _outputPath = Path.GetFullPath(outputPath); _artifactsPath = Path.GetFullPath(artifactsPath); - _runtimePath = runtimePath is not null ? Path.GetFullPath(runtimePath) : null; _rid = rid; _version = version; - _runtimeVersion = runtimeVersion; - _downloadRuntime = downloadRuntime; _verbose = verbose; } public void Dispose() { - _httpClient.Dispose(); + // Nothing to dispose } public async Task BuildAsync() @@ -166,381 +137,48 @@ public async Task BuildAsync() } Directory.CreateDirectory(_outputPath); - // Copy components (CLI is not included - the native AOT binary IS the CLI, - // and the bundle payload is embedded as a resource inside it) - await CopyRuntimeAsync().ConfigureAwait(false); - await CopyNuGetHelperAsync().ConfigureAwait(false); - await CopyAppHostServerAsync().ConfigureAwait(false); - await CopyDashboardAsync().ConfigureAwait(false); + // Copy components + CopyManaged(); await CopyDcpAsync().ConfigureAwait(false); - // Enable rollforward for all managed tools - EnableRollForwardForAllTools(); - Log("Layout build complete!"); } - private async Task CopyRuntimeAsync() + private void CopyManaged() { - Log("Copying .NET runtime..."); - - var runtimeDir = Path.Combine(_outputPath, "runtime"); - Directory.CreateDirectory(runtimeDir); + Log("Copying aspire-managed..."); - if (_runtimePath is not null && Directory.Exists(_runtimePath)) - { - CopyRuntimeFromPath(_runtimePath, runtimeDir); - Log($" Copied runtime from {_runtimePath}"); - } - else if (_downloadRuntime) + var managedPublishPath = FindPublishPath("Aspire.Managed"); + if (managedPublishPath is null) { - // Download runtime from Microsoft - await DownloadRuntimeAsync(runtimeDir).ConfigureAwait(false); + throw new InvalidOperationException("Aspire.Managed publish output not found."); } - else - { - // Try to find runtime in artifacts or use shared runtime - var sharedRuntime = FindSharedRuntime(); - if (sharedRuntime is not null) - { - CopyRuntimeFromPath(sharedRuntime, runtimeDir); - Log($" Copied shared runtime from {sharedRuntime}"); - } - else - { - Log(" WARNING: No runtime found. Layout will require runtime to be downloaded separately."); - Log(" Use --download-runtime to download the runtime from Microsoft."); - await File.WriteAllTextAsync( - Path.Combine(runtimeDir, "README.txt"), - "Place .NET runtime files here.\n").ConfigureAwait(false); - } - } - } - /// - /// Copy runtime from a source path, excluding unnecessary frameworks like WindowsDesktop.App. - /// - private void CopyRuntimeFromPath(string sourcePath, string destPath) - { - // Copy everything except the shared/Microsoft.WindowsDesktop.App directory - var sharedDir = Path.Combine(sourcePath, "shared"); - if (Directory.Exists(sharedDir)) - { - var destSharedDir = Path.Combine(destPath, "shared"); - Directory.CreateDirectory(destSharedDir); + var managedDir = Path.Combine(_outputPath, "managed"); + Directory.CreateDirectory(managedDir); - // Only copy NETCore.App and AspNetCore.App - skip WindowsDesktop.App to save space - var frameworksToCopy = new[] { "Microsoft.NETCore.App", "Microsoft.AspNetCore.App" }; - foreach (var framework in frameworksToCopy) - { - var srcFrameworkDir = Path.Combine(sharedDir, framework); - if (Directory.Exists(srcFrameworkDir)) - { - CopyDirectory(srcFrameworkDir, Path.Combine(destSharedDir, framework)); - } - } - } - - // Copy host directory - var hostDir = Path.Combine(sourcePath, "host"); - if (Directory.Exists(hostDir)) - { - CopyDirectory(hostDir, Path.Combine(destPath, "host")); - } - - // Copy dotnet executable and related files + // Copy only the aspire-managed executable and required assets (wwwroot for Dashboard). + // Skip other .exe files — they are native host stubs from referenced Exe projects + // that leak into the publish output but are not needed (everything is in aspire-managed.exe). var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase); - var dotnetExe = isWindows ? "dotnet.exe" : "dotnet"; - var dotnetPath = Path.Combine(sourcePath, dotnetExe); - if (File.Exists(dotnetPath)) - { - File.Copy(dotnetPath, Path.Combine(destPath, dotnetExe), overwrite: true); - } - - // Copy LICENSE and ThirdPartyNotices if present - foreach (var file in new[] { "LICENSE.txt", "ThirdPartyNotices.txt" }) - { - var srcFile = Path.Combine(sourcePath, file); - if (File.Exists(srcFile)) - { - File.Copy(srcFile, Path.Combine(destPath, file), overwrite: true); - } - } - } - - private async Task DownloadRuntimeAsync(string runtimeDir) - { - Log($" Downloading .NET SDK {_runtimeVersion} for {_rid}..."); - - var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase); - var archiveExt = isWindows ? "zip" : "tar.gz"; - - // Download the full SDK - it contains runtime, aspnetcore, and dev-certs tool - var sdkUrl = $"https://builds.dotnet.microsoft.com/dotnet/Sdk/{_runtimeVersion}/dotnet-sdk-{_runtimeVersion}-{_rid}.{archiveExt}"; - await DownloadAndExtractSdkAsync(sdkUrl, runtimeDir).ConfigureAwait(false); - - Log($" SDK components extracted successfully"); - } - - private async Task DownloadAndExtractSdkAsync(string url, string runtimeDir) - { - Log($" Downloading SDK from {url}..."); - - var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-sdk-{Guid.NewGuid():N}"); - Directory.CreateDirectory(tempDir); - - try - { - var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase); - var archiveExt = isWindows ? "zip" : "tar.gz"; - var archivePath = Path.Combine(tempDir, $"sdk.{archiveExt}"); - - // Download the archive - using (var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false)) - { - response.EnsureSuccessStatusCode(); - using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - using var fileStream = File.Create(archivePath); - await stream.CopyToAsync(fileStream).ConfigureAwait(false); - } - - Log($" Extracting SDK..."); - - // Extract the archive - var extractDir = Path.Combine(tempDir, "extracted"); - Directory.CreateDirectory(extractDir); - - if (isWindows) - { - ZipFile.ExtractToDirectory(archivePath, extractDir); - } - else - { - // Use tar to extract on Unix - var psi = new ProcessStartInfo - { - FileName = "tar", - Arguments = $"-xzf \"{archivePath}\" -C \"{extractDir}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - using var process = Process.Start(psi); - await process!.WaitForExitAsync().ConfigureAwait(false); - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"Failed to extract SDK: tar exited with code {process.ExitCode}"); - } - } - - // Extract runtime components: shared/, host/, dotnet executable - Log($" Extracting runtime components..."); - - // Copy only the shared frameworks we need (exclude WindowsDesktop.App to save space) - var sharedDir = Path.Combine(extractDir, "shared"); - if (Directory.Exists(sharedDir)) - { - var destSharedDir = Path.Combine(runtimeDir, "shared"); - Directory.CreateDirectory(destSharedDir); - - // Only copy NETCore.App and AspNetCore.App - skip WindowsDesktop.App - var frameworksToCopy = new[] { "Microsoft.NETCore.App", "Microsoft.AspNetCore.App" }; - foreach (var framework in frameworksToCopy) - { - var srcFrameworkDir = Path.Combine(sharedDir, framework); - if (Directory.Exists(srcFrameworkDir)) - { - CopyDirectory(srcFrameworkDir, Path.Combine(destSharedDir, framework)); - Log($" Copied {framework}"); - } - } - } - - // Copy host directory - var hostDir = Path.Combine(extractDir, "host"); - if (Directory.Exists(hostDir)) - { - CopyDirectory(hostDir, Path.Combine(runtimeDir, "host")); - } - - // Copy dotnet executable - var dotnetExe = isWindows ? "dotnet.exe" : "dotnet"; - var dotnetPath = Path.Combine(extractDir, dotnetExe); - if (File.Exists(dotnetPath)) - { - var destDotnet = Path.Combine(runtimeDir, dotnetExe); - File.Copy(dotnetPath, destDotnet, overwrite: true); - if (!isWindows) - { - await SetExecutableAsync(destDotnet).ConfigureAwait(false); - } - } - - // Copy LICENSE and ThirdPartyNotices - foreach (var file in new[] { "LICENSE.txt", "ThirdPartyNotices.txt" }) - { - var srcFile = Path.Combine(extractDir, file); - if (File.Exists(srcFile)) - { - File.Copy(srcFile, Path.Combine(runtimeDir, file), overwrite: true); - } - } - - // Extract dev-certs tool from SDK - Log($" Extracting dev-certs tool..."); - await ExtractDevCertsToolAsync(extractDir).ConfigureAwait(false); - - Log($" SDK extraction complete"); - } - finally - { - // Cleanup temp directory - try - { - Directory.Delete(tempDir, recursive: true); - } - catch - { - // Ignore cleanup errors - } - } - } - - private Task ExtractDevCertsToolAsync(string sdkExtractDir) - { - // Find the dev-certs tool in sdk//DotnetTools/dotnet-dev-certs/ - var sdkDir = Path.Combine(sdkExtractDir, "sdk"); - if (!Directory.Exists(sdkDir)) - { - Log($" WARNING: SDK directory not found, skipping dev-certs extraction"); - return Task.CompletedTask; - } - - // Find the SDK version directory (e.g., "10.0.102") - var sdkVersionDirs = Directory.GetDirectories(sdkDir); - if (sdkVersionDirs.Length == 0) - { - Log($" WARNING: No SDK version directory found, skipping dev-certs extraction"); - return Task.CompletedTask; - } - - // Use the first (should be only) SDK version directory - var sdkVersionDir = sdkVersionDirs[0]; - var dotnetToolsDir = Path.Combine(sdkVersionDir, "DotnetTools", "dotnet-dev-certs"); - - if (!Directory.Exists(dotnetToolsDir)) - { - Log($" WARNING: dotnet-dev-certs not found at {dotnetToolsDir}, skipping"); - return Task.CompletedTask; - } + var managedExeName = isWindows ? "aspire-managed.exe" : "aspire-managed"; - // Find the tool version directory (e.g., "10.0.2-servicing.25612.105") - var toolVersionDirs = Directory.GetDirectories(dotnetToolsDir); - if (toolVersionDirs.Length == 0) + var managedExePath = Path.Combine(managedPublishPath, managedExeName); + if (!File.Exists(managedExePath)) { - Log($" WARNING: No dev-certs version directory found, skipping"); - return Task.CompletedTask; + throw new InvalidOperationException($"aspire-managed executable not found at {managedExePath}"); } - // Find the tools/net10.0/any directory containing the actual DLLs - var toolVersionDir = toolVersionDirs[0]; - var toolsDir = Path.Combine(toolVersionDir, "tools"); + File.Copy(managedExePath, Path.Combine(managedDir, managedExeName), overwrite: true); - // Look for net10.0/any or similar pattern - string? devCertsSourceDir = null; - if (Directory.Exists(toolsDir)) + // Copy wwwroot (required for Dashboard static web assets) + var wwwrootPath = Path.Combine(managedPublishPath, "wwwroot"); + if (Directory.Exists(wwwrootPath)) { - foreach (var tfmDir in Directory.GetDirectories(toolsDir)) - { - var anyDir = Path.Combine(tfmDir, "any"); - if (Directory.Exists(anyDir) && File.Exists(Path.Combine(anyDir, "dotnet-dev-certs.dll"))) - { - devCertsSourceDir = anyDir; - break; - } - } + CopyDirectory(wwwrootPath, Path.Combine(managedDir, "wwwroot")); } - if (devCertsSourceDir is null) - { - Log($" WARNING: dev-certs DLLs not found, skipping"); - return Task.CompletedTask; - } - - // Copy to tools/dev-certs/ in the layout - var devCertsDestDir = Path.Combine(_outputPath, "tools", "dev-certs"); - Directory.CreateDirectory(devCertsDestDir); - - // Copy the essential files - foreach (var file in new[] { "dotnet-dev-certs.dll", "dotnet-dev-certs.deps.json", "dotnet-dev-certs.runtimeconfig.json" }) - { - var srcFile = Path.Combine(devCertsSourceDir, file); - if (File.Exists(srcFile)) - { - File.Copy(srcFile, Path.Combine(devCertsDestDir, file), overwrite: true); - } - } - - Log($" dev-certs tool extracted to tools/dev-certs/"); - return Task.CompletedTask; - } - - private Task CopyNuGetHelperAsync() - { - Log("Copying NuGet Helper..."); - - var helperPublishPath = FindPublishPath("Aspire.Cli.NuGetHelper"); - if (helperPublishPath is null) - { - throw new InvalidOperationException("NuGet Helper publish output not found."); - } - - var helperDir = Path.Combine(_outputPath, "tools", "aspire-nuget"); - Directory.CreateDirectory(helperDir); - - CopyDirectory(helperPublishPath, helperDir); - Log($" Copied NuGet Helper to tools/aspire-nuget"); - - return Task.CompletedTask; - } - - private Task CopyAppHostServerAsync() - { - Log("Copying AppHost Server..."); - - var serverPublishPath = FindPublishPath("Aspire.Hosting.RemoteHost"); - if (serverPublishPath is null) - { - throw new InvalidOperationException("AppHost Server (Aspire.Hosting.RemoteHost) publish output not found."); - } - - var serverDir = Path.Combine(_outputPath, "aspire-server"); - Directory.CreateDirectory(serverDir); - - CopyDirectory(serverPublishPath, serverDir); - Log($" Copied AppHost Server to aspire-server"); - - return Task.CompletedTask; - } - - private Task CopyDashboardAsync() - { - Log("Copying Dashboard..."); - - var dashboardPublishPath = FindPublishPath("Aspire.Dashboard"); - if (dashboardPublishPath is null) - { - Log(" WARNING: Dashboard publish output not found. Skipping."); - return Task.CompletedTask; - } - - var dashboardDir = Path.Combine(_outputPath, "dashboard"); - Directory.CreateDirectory(dashboardDir); - - CopyDirectory(dashboardPublishPath, dashboardDir); - Log($" Copied Dashboard to dashboard"); - - return Task.CompletedTask; + Log($" Copied aspire-managed to managed/"); } private Task CopyDcpAsync() @@ -634,25 +272,17 @@ public async Task CreateArchiveAsync() private string? FindPublishPath(string projectName) { // Look for publish output in standard locations - // Order matters - RID-specific single-file publish paths should come first + // Order: RID-specific publish paths first (Release then Debug) var searchPaths = new[] { - // Native AOT output (aspire CLI uses this) - Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", _rid, "native"), - // RID-specific single-file publish output (preferred) + // RID-specific self-contained publish output (preferred for Aspire.Managed) Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", _rid, "publish"), - Path.Combine(_artifactsPath, "bin", projectName, "Release", "net8.0", _rid, "publish"), - // Standard publish output - Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", "publish"), - // Arcade SDK output - Path.Combine(_artifactsPath, "bin", projectName, "Release", _rid), - Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0"), - // net8.0 for Dashboard (it targets net8.0) - Path.Combine(_artifactsPath, "bin", projectName, "Release", "net8.0", "publish"), - Path.Combine(_artifactsPath, "bin", projectName, "Release", "net8.0"), - // Debug fallback - Path.Combine(_artifactsPath, "bin", projectName, "Debug", "net10.0", _rid, "native"), Path.Combine(_artifactsPath, "bin", projectName, "Debug", "net10.0", _rid, "publish"), + // Native AOT output + Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", _rid, "native"), + Path.Combine(_artifactsPath, "bin", projectName, "Debug", "net10.0", _rid, "native"), + // Non-RID publish output + Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", "publish"), Path.Combine(_artifactsPath, "bin", projectName, "Debug", "net10.0", "publish"), }; @@ -667,29 +297,6 @@ public async Task CreateArchiveAsync() return null; } - private static string? FindSharedRuntime() - { - // Look for .NET runtime in common locations - var dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT"); - if (!string.IsNullOrEmpty(dotnetRoot)) - { - var sharedPath = Path.Combine(dotnetRoot, "shared", "Microsoft.NETCore.App"); - if (Directory.Exists(sharedPath)) - { - // Find the latest version - var versions = Directory.GetDirectories(sharedPath) - .OrderByDescending(d => d) - .FirstOrDefault(); - if (versions is not null) - { - return versions; - } - } - } - - return null; - } - private string? FindDcpPath() { // DCP is in NuGet packages as Microsoft.DeveloperControlPlane.{os}-{arch} @@ -775,57 +382,6 @@ private static void CopyDirectory(string source, string destination) } } - private static async Task SetExecutableAsync(string path) - { - var psi = new ProcessStartInfo - { - FileName = "chmod", - Arguments = $"+x \"{path}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using var process = Process.Start(psi); - if (process is not null) - { - await process.WaitForExitAsync().ConfigureAwait(false); - } - } - - private void EnableRollForwardForAllTools() - { - Log("Enabling RollForward=Major for all tools..."); - - // Find all runtimeconfig.json files in the bundle - var runtimeConfigFiles = Directory.GetFiles(_outputPath, "*.runtimeconfig.json", SearchOption.AllDirectories); - - foreach (var configFile in runtimeConfigFiles) - { - try - { - var json = File.ReadAllText(configFile); - using var doc = JsonDocument.Parse(json); - - // Check if rollForward is already set - if (doc.RootElement.TryGetProperty("runtimeOptions", out var runtimeOptions) && - !runtimeOptions.TryGetProperty("rollForward", out _)) - { - // Add rollForward: Major to the runtimeOptions - var updatedJson = json.Replace( - "\"runtimeOptions\": {", - "\"runtimeOptions\": {\n \"rollForward\": \"Major\","); - File.WriteAllText(configFile, updatedJson); - Log($" Updated: {Path.GetRelativePath(_outputPath, configFile)}"); - } - } - catch (Exception ex) - { - Log($" WARNING: Failed to update {configFile}: {ex.Message}"); - } - } - } - private void Log(string message) { if (_verbose || !message.StartsWith(" ")) diff --git a/tools/CreateLayout/README.md b/tools/CreateLayout/README.md index 2178e18df09..012061fe33c 100644 --- a/tools/CreateLayout/README.md +++ b/tools/CreateLayout/README.md @@ -20,9 +20,7 @@ Before running CreateLayout, you must: 1. Build the Aspire solution with the required components published 2. Have the following publish outputs available in the artifacts directory: - - `Aspire.Cli.NuGetHelper` → `artifacts/bin/Aspire.Cli.NuGetHelper/{config}/{tfm}/publish/` - - `Aspire.Hosting.RemoteHost` → `artifacts/bin/Aspire.Hosting.RemoteHost/{config}/{tfm}/publish/` - - `Aspire.Dashboard` → `artifacts/bin/Aspire.Dashboard/{config}/{tfm}/publish/` + - `Aspire.Managed` → `artifacts/bin/Aspire.Managed/{config}/{tfm}/publish/` The build scripts (`./build.sh -bundle` / `./build.cmd -bundle`) handle this automatically. From c3d88e22c4ca1b8de217befac32497aa9b4bc9d5 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Tue, 24 Feb 2026 09:34:40 -0800 Subject: [PATCH 162/256] =?UTF-8?q?Revert=20"Fix=20port=20mismatch=20for?= =?UTF-8?q?=20bait-and-switch=20resources=20in=20Kubernetes=20publish?= =?UTF-8?q?=E2=80=A6"=20(#14649)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 63ff050e346c0dcc45ab78a2c86d2aa760fc6bd7. --- .gitattributes | 2 - .../policies/milestoneAssignment.prClosed.yml | 8 +- .github/skills/startup-perf/SKILL.md | 193 --- .github/workflows/README.md | 23 - .github/workflows/backmerge-release.yml | 166 --- .github/workflows/ci.yml | 1 - .github/workflows/daily-repo-status.lock.yml | 1101 ----------------- .github/workflows/daily-repo-status.md | 131 -- AGENTS.md | 1 - docs/getting-perf-traces.md | 10 +- eng/Version.Details.xml | 28 +- eng/Versions.props | 8 +- eng/build.sh | 2 +- eng/common/SetupNugetSources.ps1 | 17 +- eng/common/SetupNugetSources.sh | 17 +- eng/common/build.ps1 | 2 + eng/common/build.sh | 7 +- eng/common/core-templates/job/job.yml | 8 + .../job/publish-build-assets.yml | 18 +- .../core-templates/job/source-build.yml | 8 +- .../core-templates/post-build/post-build.yml | 463 ++++--- .../core-templates/steps/generate-sbom.yml | 2 +- .../steps/install-microbuild-impl.yml | 34 + .../steps/install-microbuild.yml | 64 +- .../core-templates/steps/source-build.yml | 2 +- .../steps/source-index-stage1-publish.yml | 8 +- eng/common/darc-init.sh | 2 +- eng/common/dotnet-install.sh | 2 +- eng/common/dotnet.sh | 2 +- eng/common/internal-feed-operations.sh | 2 +- eng/common/native/install-dependencies.sh | 4 +- eng/common/post-build/redact-logs.ps1 | 3 +- .../templates/variables/pool-providers.yml | 2 +- eng/common/tools.ps1 | 17 +- eng/common/tools.sh | 4 - eng/restore-toolset.sh | 2 +- extension/loc/xlf/aspire-vscode.xlf | 3 - extension/package.nls.json | 1 - extension/src/commands/add.ts | 2 +- extension/src/commands/deploy.ts | 2 +- extension/src/commands/init.ts | 2 +- extension/src/commands/new.ts | 2 +- extension/src/commands/publish.ts | 2 +- extension/src/commands/update.ts | 2 +- .../AspireDebugConfigurationProvider.ts | 12 +- extension/src/debugger/AspireDebugSession.ts | 8 +- extension/src/extension.ts | 7 +- extension/src/loc/strings.ts | 1 - .../src/test/aspireTerminalProvider.test.ts | 76 +- extension/src/test/cliPath.test.ts | 211 ---- extension/src/utils/AspireTerminalProvider.ts | 17 +- extension/src/utils/cliPath.ts | 194 --- extension/src/utils/configInfoProvider.ts | 4 +- extension/src/utils/workspace.ts | 71 +- global.json | 6 +- .../KubernetesEnvironmentContext.cs | 2 +- .../Commands/DeployCommandTests.cs | 4 +- .../Commands/ExtensionInternalCommandTests.cs | 5 +- ...nments_Works_step=diagnostics.verified.txt | 100 +- .../DockerComposeTests.cs | 1 + .../KubernetesPublisherTests.cs | 42 - ...ForBaitAndSwitchResources#00.verified.yaml | 11 - ...ForBaitAndSwitchResources#01.verified.yaml | 10 - ...ForBaitAndSwitchResources#02.verified.yaml | 40 - ...ForBaitAndSwitchResources#03.verified.yaml | 20 - ...ForBaitAndSwitchResources#04.verified.yaml | 11 - ...ForBaitAndSwitchResources#05.verified.yaml | 40 - ...ForBaitAndSwitchResources#06.verified.yaml | 12 - .../DistributedApplicationPipelineTests.cs | 4 +- .../WithHttpCommandTests.cs | 2 + tools/perf/Measure-StartupPerformance.ps1 | 678 ---------- tools/perf/TraceAnalyzer/Program.cs | 80 -- tools/perf/TraceAnalyzer/TraceAnalyzer.csproj | 16 - 73 files changed, 576 insertions(+), 3489 deletions(-) delete mode 100644 .github/skills/startup-perf/SKILL.md delete mode 100644 .github/workflows/backmerge-release.yml delete mode 100644 .github/workflows/daily-repo-status.lock.yml delete mode 100644 .github/workflows/daily-repo-status.md create mode 100644 eng/common/core-templates/steps/install-microbuild-impl.yml delete mode 100644 extension/src/test/cliPath.test.ts delete mode 100644 extension/src/utils/cliPath.ts delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#00.verified.yaml delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#01.verified.yaml delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#02.verified.yaml delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#03.verified.yaml delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#04.verified.yaml delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#05.verified.yaml delete mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#06.verified.yaml delete mode 100644 tools/perf/Measure-StartupPerformance.ps1 delete mode 100644 tools/perf/TraceAnalyzer/Program.cs delete mode 100644 tools/perf/TraceAnalyzer/TraceAnalyzer.csproj diff --git a/.gitattributes b/.gitattributes index 4c262a83c4c..594552221cc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -60,5 +60,3 @@ # https://github.com/github/linguist/issues/1626#issuecomment-401442069 # this only affects the repo's language statistics *.h linguist-language=C - -.github/workflows/*.lock.yml linguist-generated=true merge=ours \ No newline at end of file diff --git a/.github/policies/milestoneAssignment.prClosed.yml b/.github/policies/milestoneAssignment.prClosed.yml index ad9aaad2f57..1ec03595d00 100644 --- a/.github/policies/milestoneAssignment.prClosed.yml +++ b/.github/policies/milestoneAssignment.prClosed.yml @@ -16,16 +16,16 @@ configuration: branch: main then: - addMilestone: - milestone: 13.3 + milestone: 13.2 description: '[Milestone Assignments] Assign Milestone to PRs merged to the `main` branch' - if: - payloadType: Pull_Request - isAction: action: Closed - targetsBranch: - branch: release/13.2 + branch: release/13.1 then: - removeMilestone - addMilestone: - milestone: 13.2 - description: '[Milestone Assignments] Assign Milestone to PRs merged to release/13.2 branch' + milestone: 13.1.1 + description: '[Milestone Assignments] Assign Milestone to PRs merged to release/13.1 branch' diff --git a/.github/skills/startup-perf/SKILL.md b/.github/skills/startup-perf/SKILL.md deleted file mode 100644 index 33ca4d3875f..00000000000 --- a/.github/skills/startup-perf/SKILL.md +++ /dev/null @@ -1,193 +0,0 @@ ---- -name: startup-perf -description: Measures Aspire application startup performance using dotnet-trace and the TraceAnalyzer tool. Use this when asked to measure impact of a code change on Aspire application startup performance. ---- - -# Aspire Startup Performance Measurement - -This skill provides patterns and practices for measuring .NET Aspire application startup performance using the `Measure-StartupPerformance.ps1` script and the companion `TraceAnalyzer` tool. - -## Overview - -The startup performance tooling collects `dotnet-trace` traces from an Aspire AppHost application and computes the startup duration from `AspireEventSource` events. Specifically, it measures the time between the `DcpModelCreationStart` (event ID 17) and `DcpModelCreationStop` (event ID 18) events emitted by the `Microsoft-Aspire-Hosting` EventSource provider. - -**Script Location**: `tools/perf/Measure-StartupPerformance.ps1` -**TraceAnalyzer Location**: `tools/perf/TraceAnalyzer/` -**Documentation**: `docs/getting-perf-traces.md` - -## Prerequisites - -- PowerShell 7+ -- `dotnet-trace` global tool (`dotnet tool install -g dotnet-trace`) -- .NET SDK (restored via `./restore.cmd` or `./restore.sh`) - -## Quick Start - -### Single Measurement - -```powershell -# From repository root — measures the default TestShop.AppHost -.\tools\perf\Measure-StartupPerformance.ps1 -``` - -### Multiple Iterations with Statistics - -```powershell -.\tools\perf\Measure-StartupPerformance.ps1 -Iterations 5 -``` - -### Custom Project - -```powershell -.\tools\perf\Measure-StartupPerformance.ps1 -ProjectPath "path\to\MyApp.AppHost.csproj" -Iterations 3 -``` - -### Preserve Traces for Manual Analysis - -```powershell -.\tools\perf\Measure-StartupPerformance.ps1 -Iterations 3 -PreserveTraces -TraceOutputDirectory "C:\traces" -``` - -### Verbose Output - -```powershell -.\tools\perf\Measure-StartupPerformance.ps1 -Verbose -``` - -## Parameters - -| Parameter | Default | Description | -|-----------|---------|-------------| -| `ProjectPath` | TestShop.AppHost | Path to the AppHost `.csproj` to measure | -| `Iterations` | 1 | Number of measurement runs (1–100) | -| `PreserveTraces` | `$false` | Keep `.nettrace` files after analysis | -| `TraceOutputDirectory` | temp folder | Directory for preserved trace files | -| `SkipBuild` | `$false` | Skip `dotnet build` before running | -| `TraceDurationSeconds` | 60 | Maximum trace collection time (1–86400) | -| `PauseBetweenIterationsSeconds` | 45 | Pause between iterations (0–3600) | -| `Verbose` | `$false` | Show detailed output | - -## How It Works - -The script follows this sequence: - -1. **Prerequisites check** — Verifies `dotnet-trace` is installed and the project exists. -2. **Build** — Builds the AppHost project in Release configuration (unless `-SkipBuild`). -3. **Build TraceAnalyzer** — Builds the companion `tools/perf/TraceAnalyzer` project. -4. **For each iteration:** - a. Locates the compiled executable (Arcade-style or traditional output paths). - b. Reads `launchSettings.json` for environment variables. - c. Launches the AppHost as a separate process. - d. Attaches `dotnet-trace` to the running process with the `Microsoft-Aspire-Hosting` provider. - e. Waits for the trace to complete (duration timeout or process exit). - f. Runs the TraceAnalyzer to extract the startup duration from the `.nettrace` file. - g. Cleans up processes. -5. **Reports results** — Prints per-iteration times and statistics (min, max, average, std dev). - -## TraceAnalyzer Tool - -The `tools/perf/TraceAnalyzer` is a small .NET console app that parses `.nettrace` files using the `Microsoft.Diagnostics.Tracing.TraceEvent` library. - -### What It Does - -- Opens the `.nettrace` file with `EventPipeEventSource` -- Listens for events from the `Microsoft-Aspire-Hosting` provider -- Extracts timestamps for `DcpModelCreationStart` (ID 17) and `DcpModelCreationStop` (ID 18) -- Outputs the duration in milliseconds (or `"null"` if events are not found) - -### Standalone Usage - -```bash -dotnet run --project tools/perf/TraceAnalyzer -c Release -- -``` - -## Understanding Output - -### Successful Run - -``` -================================================== - Aspire Startup Performance Measurement -================================================== - -Project: TestShop.AppHost -Iterations: 3 -... - -Iteration 1 ----------------------------------------- -Starting TestShop.AppHost... -Attaching trace collection to PID 12345... -Collecting performance trace... -Trace collection completed. -Analyzing trace: ... -Startup time: 1234.56 ms - -... - -================================================== - Results Summary -================================================== - -Iteration StartupTimeMs ---------- ------------- - 1 1234.56 - 2 1189.23 - 3 1201.45 - -Statistics: - Successful iterations: 3 / 3 - Minimum: 1189.23 ms - Maximum: 1234.56 ms - Average: 1208.41 ms - Std Dev: 18.92 ms -``` - -### Common Issues - -| Symptom | Cause | Fix | -|---------|-------|-----| -| `dotnet-trace is not installed` | Missing global tool | Run `dotnet tool install -g dotnet-trace` | -| `Could not find compiled executable` | Project not built | Remove `-SkipBuild` or build manually | -| `Could not find DcpModelCreation events` | Trace too short or events not emitted | Increase `-TraceDurationSeconds` | -| `Application exited immediately` | App crash on startup | Check app logs, ensure dependencies are available | -| `dotnet-trace exited with code != 0` | Trace collection error | Check verbose output; trace file may still be valid | - -## Comparing Before/After Performance - -To measure the impact of a code change: - -```powershell -# 1. Measure baseline (on main branch) -git checkout main -.\tools\perf\Measure-StartupPerformance.ps1 -Iterations 5 -PreserveTraces -TraceOutputDirectory "C:\traces\baseline" - -# 2. Measure with changes -git checkout my-feature-branch -.\tools\perf\Measure-StartupPerformance.ps1 -Iterations 5 -PreserveTraces -TraceOutputDirectory "C:\traces\feature" - -# 3. Compare the reported averages and std devs -``` - -Use enough iterations (5+) and a consistent pause between iterations for reliable comparisons. - -## Collecting Traces for Manual Analysis - -If you need to inspect trace files manually (e.g., in PerfView or Visual Studio): - -```powershell -.\tools\perf\Measure-StartupPerformance.ps1 -PreserveTraces -TraceOutputDirectory "C:\my-traces" -``` - -See `docs/getting-perf-traces.md` for guidance on analyzing traces with PerfView or `dotnet trace report`. - -## EventSource Provider Details - -The `Microsoft-Aspire-Hosting` EventSource emits events for key Aspire lifecycle milestones. The startup performance script focuses on: - -| Event ID | Event Name | Description | -|----------|------------|-------------| -| 17 | `DcpModelCreationStart` | Marks the beginning of DCP model creation | -| 18 | `DcpModelCreationStop` | Marks the completion of DCP model creation | - -The measured startup time is the wall-clock difference between these two events, representing the time to create all application services and supporting dependencies. diff --git a/.github/workflows/README.md b/.github/workflows/README.md index e802e904706..06975dcd71c 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -99,26 +99,3 @@ When you comment on a PR (not an issue), the workflow will automatically push ch ### Concurrency The workflow uses concurrency groups based on the issue/PR number to prevent race conditions when multiple commands are issued on the same issue. - -## Backmerge Release Workflow - -The `backmerge-release.yml` workflow automatically creates PRs to merge changes from `release/13.2` back into `main`. - -### Schedule - -Runs daily at 00:00 UTC (4pm PT during standard time, 5pm PT during daylight saving time). Can also be triggered manually via `workflow_dispatch`. - -### Behavior - -1. **Change Detection**: Checks if `release/13.2` has commits not in `main` -2. **PR Creation**: If changes exist, creates a PR to merge `release/13.2` → `main` -3. **Auto-merge**: Enables GitHub's auto-merge feature, so the PR merges automatically once approved -4. **Conflict Handling**: If merge conflicts occur, creates an issue instead of a PR - -### Assignees - -PRs and conflict issues are automatically assigned to @joperezr and @radical. - -### Manual Trigger - -To trigger manually, go to Actions → "Backmerge Release to Main" → "Run workflow". diff --git a/.github/workflows/backmerge-release.yml b/.github/workflows/backmerge-release.yml deleted file mode 100644 index 0e530c49dfc..00000000000 --- a/.github/workflows/backmerge-release.yml +++ /dev/null @@ -1,166 +0,0 @@ -name: Backmerge Release to Main - -on: - schedule: - - cron: '0 0 * * *' # Runs daily at 00:00 UTC (16:00 PST / 17:00 PDT) - workflow_dispatch: # Allow manual trigger - -permissions: - contents: write - pull-requests: write - issues: write - -jobs: - backmerge: - runs-on: ubuntu-latest - timeout-minutes: 15 - if: ${{ github.repository_owner == 'dotnet' }} - - steps: - - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 # Full history needed for merge - - - name: Check for changes to backmerge - id: check - run: | - git fetch origin main release/13.2 - BEHIND_COUNT=$(git rev-list --count origin/main..origin/release/13.2) - echo "behind_count=$BEHIND_COUNT" >> $GITHUB_OUTPUT - if [ "$BEHIND_COUNT" -gt 0 ]; then - echo "changes=true" >> $GITHUB_OUTPUT - echo "Found $BEHIND_COUNT commits in release/13.2 not in main" - else - echo "changes=false" >> $GITHUB_OUTPUT - echo "No changes to backmerge - release/13.2 is up-to-date with main" - fi - - - name: Attempt merge and create branch - if: steps.check.outputs.changes == 'true' - id: merge - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git checkout origin/main - git checkout -b backmerge/release-13.2-to-main - - # Attempt the merge - if git merge origin/release/13.2 --no-edit; then - echo "merge_success=true" >> $GITHUB_OUTPUT - git push origin backmerge/release-13.2-to-main --force - echo "Merge successful, branch pushed" - else - echo "merge_success=false" >> $GITHUB_OUTPUT - git merge --abort - echo "Merge conflicts detected" - fi - - - name: Create or update Pull Request - if: steps.check.outputs.changes == 'true' && steps.merge.outputs.merge_success == 'true' - id: create-pr - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Check if a PR already exists for this branch - EXISTING_PR=$(gh pr list --head backmerge/release-13.2-to-main --base main --json number --jq '.[0].number // empty') - - if [ -n "$EXISTING_PR" ]; then - echo "PR #$EXISTING_PR already exists, updating it" - echo "pull_request_number=$EXISTING_PR" >> $GITHUB_OUTPUT - else - PR_BODY="## Automated Backmerge - - This PR merges changes from \`release/13.2\` back into \`main\`. - - **Commits to merge:** ${{ steps.check.outputs.behind_count }} - - This PR was created automatically to keep \`main\` up-to-date with release branch changes. - Once approved, please merge using a **merge commit** (not squash or rebase). - - --- - *This PR was generated by the [backmerge-release](${{ github.server_url }}/${{ github.repository }}/actions/workflows/backmerge-release.yml) workflow.*" - - # Remove leading whitespace from heredoc-style body - PR_BODY=$(echo "$PR_BODY" | sed 's/^ //') - - PR_URL=$(gh pr create \ - --head backmerge/release-13.2-to-main \ - --base main \ - --title "[Automated] Backmerge release/13.2 to main" \ - --body "$PR_BODY" \ - --assignee joperezr,radical \ - --label area-engineering-systems) - - PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') - if [ -z "$PR_NUMBER" ]; then - echo "::error::Failed to extract PR number from: $PR_URL" - exit 1 - fi - echo "pull_request_number=$PR_NUMBER" >> $GITHUB_OUTPUT - echo "Created PR #$PR_NUMBER" - fi - - - name: Create issue for merge conflicts - if: steps.check.outputs.changes == 'true' && steps.merge.outputs.merge_success == 'false' - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - script: | - const workflowRunUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - - // Check if there's already an open issue for this - const existingIssues = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - labels: 'backmerge-conflict', - creator: 'github-actions[bot]' - }); - - if (existingIssues.data.length > 0) { - console.log(`Existing backmerge conflict issue found: #${existingIssues.data[0].number}`); - // Add a comment to the existing issue - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: existingIssues.data[0].number, - body: `⚠️ Merge conflicts still exist.\n\n**Workflow run:** ${workflowRunUrl}\n\nPlease resolve the conflicts manually.` - }); - return; - } - - // Create a new issue - const issueBody = [ - '## Backmerge Conflict', - '', - 'The automated backmerge from `release/13.2` to `main` failed due to merge conflicts.', - '', - '### What to do', - '', - '1. Checkout main and attempt the merge locally:', - ' ```bash', - ' git checkout main', - ' git pull origin main', - ' git merge origin/release/13.2', - ' ```', - '2. Resolve the conflicts', - '3. Push the merge commit or create a PR manually', - '', - '### Details', - '', - `**Workflow run:** ${workflowRunUrl}`, - '**Commits to merge:** ${{ steps.check.outputs.behind_count }}', - '', - '---', - `*This issue was created automatically by the [backmerge-release](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/workflows/backmerge-release.yml) workflow.*` - ].join('\n'); - - await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: '[Backmerge] Merge conflicts between release/13.2 and main', - body: issueBody, - assignees: ['joperezr', 'radical'], - labels: ['area-engineering-systems', 'backmerge-conflict'] - }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0901bf2f888..a0007e8030f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,6 @@ jobs: eng/pipelines/.* eng/test-configuration.json \.github/workflows/apply-test-attributes.yml - \.github/workflows/backmerge-release.yml \.github/workflows/backport.yml \.github/workflows/dogfood-comment.yml \.github/workflows/generate-api-diffs.yml diff --git a/.github/workflows/daily-repo-status.lock.yml b/.github/workflows/daily-repo-status.lock.yml deleted file mode 100644 index f226e7a052b..00000000000 --- a/.github/workflows/daily-repo-status.lock.yml +++ /dev/null @@ -1,1101 +0,0 @@ -# -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ -# | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ -# \_| |_/\__, |\___|_| |_|\__|_|\___| -# __/ | -# _ _ |___/ -# | | | | / _| | -# | | | | ___ _ __ _ __| |_| | _____ ____ -# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| -# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ -# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ -# -# This file was automatically generated by gh-aw (v0.45.5). DO NOT EDIT. -# -# To update this file, edit the corresponding .md file and run: -# gh aw compile -# Not all edits will cause changes to this file. -# -# For more information: https://github.github.com/gh-aw/introduction/overview/ -# -# Daily burndown report for the Aspire 13.2 milestone. Tracks progress -# on issues closed, new bugs found, notable changes merged into the -# release/13.2 branch, pending PR reviews, and discussions. Generates -# a 7-day burndown chart using cached daily snapshots. -# -# frontmatter-hash: 427ab537ab52b999a8cbb139515b504ba7359549cab995530c129ea037f08ef0 - -name: "13.2 Release Burndown Report" -"on": - schedule: - - cron: "42 9 * * *" - # Friendly format: daily around 9am (scattered) - workflow_dispatch: - -permissions: {} - -concurrency: - group: "gh-aw-${{ github.workflow }}" - -run-name: "13.2 Release Burndown Report" - -jobs: - activation: - runs-on: ubuntu-slim - permissions: - contents: read - outputs: - comment_id: "" - comment_repo: "" - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 - with: - destination: /opt/gh-aw/actions - - name: Checkout .github and .agents folders - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: | - .github - .agents - fetch-depth: 1 - persist-credentials: false - - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_WORKFLOW_FILE: "daily-repo-status.lock.yml" - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); - await main(); - - name: Create prompt with built-in context - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - run: | - bash /opt/gh-aw/actions/create_prompt_first.sh - cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT" - - GH_AW_PROMPT_EOF - cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT" - cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" - cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" - cat "/opt/gh-aw/prompts/cache_memory_prompt.md" >> "$GH_AW_PROMPT" - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - - GitHub API Access Instructions - - The gh CLI is NOT authenticated. Do NOT use gh commands for GitHub operations. - - - To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls. - - Temporary IDs: Some safe output tools support a temporary ID field (usually named temporary_id) so you can reference newly-created items elsewhere in the SAME agent output (for example, using #aw_abc1 in a later body). - - **IMPORTANT - temporary_id format rules:** - - If you DON'T need to reference the item later, OMIT the temporary_id field entirely (it will be auto-generated if needed) - - If you DO need cross-references/chaining, you MUST match this EXACT validation regex: /^aw_[A-Za-z0-9]{3,8}$/i - - Format: aw_ prefix followed by 3 to 8 alphanumeric characters (A-Z, a-z, 0-9, case-insensitive) - - Valid alphanumeric characters: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 - - INVALID examples: aw_ab (too short), aw_123456789 (too long), aw_test-id (contains hyphen), aw_id_123 (contains underscore) - - VALID examples: aw_abc, aw_abc1, aw_Test123, aw_A1B2C3D4, aw_12345678 - - To generate valid IDs: use 3-8 random alphanumeric characters or omit the field to let the system auto-generate - - Do NOT invent other aw_* formats — downstream steps will reject them with validation errors matching against /^aw_[A-Za-z0-9]{3,8}$/i. - - Discover available tools from the safeoutputs MCP server. - - **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped. - - **Note**: If you made no other safe output tool calls during this workflow execution, call the "noop" tool to provide a status message indicating completion or that no actions were needed. - - - - The following GitHub context information is available for this workflow: - {{#if __GH_AW_GITHUB_ACTOR__ }} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if __GH_AW_GITHUB_REPOSITORY__ }} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if __GH_AW_GITHUB_WORKSPACE__ }} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} - - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} - - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} - - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} - - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ - {{/if}} - {{#if __GH_AW_GITHUB_RUN_ID__ }} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" - {{#runtime-import .github/workflows/daily-repo-status.md}} - GH_AW_PROMPT_EOF - - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); - await main(); - - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_ALLOWED_EXTENSIONS: '' - GH_AW_CACHE_DESCRIPTION: '' - GH_AW_CACHE_DIR: '/tmp/gh-aw/cache-memory/' - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: ${{ needs.pre_activation.outputs.matched_command }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - - const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - - // Call the substitution function - return await substitutePlaceholders({ - file: process.env.GH_AW_PROMPT, - substitutions: { - GH_AW_ALLOWED_EXTENSIONS: process.env.GH_AW_ALLOWED_EXTENSIONS, - GH_AW_CACHE_DESCRIPTION: process.env.GH_AW_CACHE_DESCRIPTION, - GH_AW_CACHE_DIR: process.env.GH_AW_CACHE_DIR, - GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, - GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, - GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, - GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, - GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED, - GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_MATCHED_COMMAND - } - }); - - name: Validate prompt placeholders - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh - - name: Print prompt - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/print_prompt_summary.sh - - name: Upload prompt artifact - if: success() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: prompt - path: /tmp/gh-aw/aw-prompts/prompt.txt - retention-days: 1 - - agent: - needs: activation - runs-on: ubuntu-latest - permissions: - contents: read - discussions: read - issues: read - pull-requests: read - concurrency: - group: "gh-aw-copilot-${{ github.workflow }}" - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - GH_AW_ASSETS_ALLOWED_EXTS: "" - GH_AW_ASSETS_BRANCH: "" - GH_AW_ASSETS_MAX_SIZE_KB: 0 - GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs - GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl - GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json - GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json - GH_AW_WORKFLOW_ID_SANITIZED: dailyrepostatus - outputs: - checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} - has_patch: ${{ steps.collect_output.outputs.has_patch }} - model: ${{ steps.generate_aw_info.outputs.model }} - output: ${{ steps.collect_output.outputs.output }} - output_types: ${{ steps.collect_output.outputs.output_types }} - secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 - with: - destination: /opt/gh-aw/actions - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Create gh-aw temp directory - run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh - # Cache memory file share configuration from frontmatter processed below - - name: Create cache-memory directory - run: bash /opt/gh-aw/actions/create_cache_memory_dir.sh - - name: Restore cache-memory file share data - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} - path: /tmp/gh-aw/cache-memory - restore-keys: | - memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}- - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Checkout PR branch - id: checkout-pr - if: | - github.event.pull_request - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); - await main(); - - name: Generate agentic run info - id: generate_aw_info - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const fs = require('fs'); - - const awInfo = { - engine_id: "copilot", - engine_name: "GitHub Copilot CLI", - model: process.env.GH_AW_MODEL_AGENT_COPILOT || "", - version: "", - agent_version: "0.0.410", - cli_version: "v0.45.5", - workflow_name: "13.2 Release Burndown Report", - experimental: false, - supports_tools_allowlist: true, - run_id: context.runId, - run_number: context.runNumber, - run_attempt: process.env.GITHUB_RUN_ATTEMPT, - repository: context.repo.owner + '/' + context.repo.repo, - ref: context.ref, - sha: context.sha, - actor: context.actor, - event_name: context.eventName, - staged: false, - allowed_domains: ["defaults"], - firewall_enabled: true, - awf_version: "v0.19.1", - awmg_version: "v0.1.4", - steps: { - firewall: "squid" - }, - created_at: new Date().toISOString() - }; - - // Write to /tmp/gh-aw directory to avoid inclusion in PR - const tmpPath = '/tmp/gh-aw/aw_info.json'; - fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); - console.log('Generated aw_info.json at:', tmpPath); - console.log(JSON.stringify(awInfo, null, 2)); - - // Set model as output for reuse in other steps/jobs - core.setOutput('model', awInfo.model); - - name: Validate COPILOT_GITHUB_TOKEN secret - id: validate-secret - run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default - env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - - name: Install GitHub Copilot CLI - run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.410 - - name: Install awf binary - run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.19.1 - - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.19.1 ghcr.io/github/gh-aw-firewall/squid:0.19.1 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 node:lts-alpine - - name: Write Safe Outputs Config - run: | - mkdir -p /opt/gh-aw/safeoutputs - mkdir -p /tmp/gh-aw/safeoutputs - mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' - {"create_issue":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}} - GH_AW_SAFE_OUTPUTS_CONFIG_EOF - cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' - [ - { - "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[13.2-burndown] \". Labels [report burndown] will be automatically added.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "body": { - "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.", - "type": "string" - }, - "labels": { - "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.", - "items": { - "type": "string" - }, - "type": "array" - }, - "parent": { - "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123', 'aw_Test123') from a previously created issue in the same workflow run.", - "type": [ - "number", - "string" - ] - }, - "temporary_id": { - "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 3 to 8 alphanumeric characters (e.g., 'aw_abc1', 'aw_Test123'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", - "pattern": "^aw_[A-Za-z0-9]{3,8}$", - "type": "string" - }, - "title": { - "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.", - "type": "string" - } - }, - "required": [ - "title", - "body" - ], - "type": "object" - }, - "name": "create_issue" - }, - { - "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "alternatives": { - "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", - "type": "string" - }, - "reason": { - "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).", - "type": "string" - }, - "tool": { - "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.", - "type": "string" - } - }, - "required": [ - "reason" - ], - "type": "object" - }, - "name": "missing_tool" - }, - { - "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "message": { - "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').", - "type": "string" - } - }, - "required": [ - "message" - ], - "type": "object" - }, - "name": "noop" - }, - { - "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "alternatives": { - "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", - "type": "string" - }, - "context": { - "description": "Additional context about the missing data or where it should come from (max 256 characters).", - "type": "string" - }, - "data_type": { - "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.", - "type": "string" - }, - "reason": { - "description": "Explanation of why this data is needed to complete the task (max 256 characters).", - "type": "string" - } - }, - "required": [], - "type": "object" - }, - "name": "missing_data" - } - ] - GH_AW_SAFE_OUTPUTS_TOOLS_EOF - cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' - { - "create_issue": { - "defaultMax": 1, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "labels": { - "type": "array", - "itemType": "string", - "itemSanitize": true, - "itemMaxLength": 128 - }, - "parent": { - "issueOrPRNumber": true - }, - "repo": { - "type": "string", - "maxLength": 256 - }, - "temporary_id": { - "type": "string" - }, - "title": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 128 - } - } - }, - "missing_tool": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 512 - }, - "reason": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "tool": { - "type": "string", - "sanitize": true, - "maxLength": 128 - } - } - }, - "noop": { - "defaultMax": 1, - "fields": { - "message": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - } - } - } - } - GH_AW_SAFE_OUTPUTS_VALIDATION_EOF - - name: Generate Safe Outputs MCP Server Config - id: safe-outputs-config - run: | - # Generate a secure random API key (360 bits of entropy, 40+ chars) - # Mask immediately to prevent timing vulnerabilities - API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${API_KEY}" - - PORT=3001 - - # Set outputs for next steps - { - echo "safe_outputs_api_key=${API_KEY}" - echo "safe_outputs_port=${PORT}" - } >> "$GITHUB_OUTPUT" - - echo "Safe Outputs MCP server will run on port ${PORT}" - - - name: Start Safe Outputs MCP HTTP Server - id: safe-outputs-start - env: - DEBUG: '*' - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} - GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json - GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json - GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs - run: | - # Environment variables are set above to prevent template injection - export DEBUG - export GH_AW_SAFE_OUTPUTS_PORT - export GH_AW_SAFE_OUTPUTS_API_KEY - export GH_AW_SAFE_OUTPUTS_TOOLS_PATH - export GH_AW_SAFE_OUTPUTS_CONFIG_PATH - export GH_AW_MCP_LOG_DIR - - bash /opt/gh-aw/actions/start_safe_outputs_server.sh - - - name: Start MCP Gateway - id: start-mcp-gateway - env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - run: | - set -eo pipefail - mkdir -p /tmp/gh-aw/mcp-config - - # Export gateway environment variables for MCP config and gateway script - export MCP_GATEWAY_PORT="80" - export MCP_GATEWAY_DOMAIN="host.docker.internal" - MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${MCP_GATEWAY_API_KEY}" - export MCP_GATEWAY_API_KEY - export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" - mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" - export DEBUG="*" - - export GH_AW_ENGINE="copilot" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.4' - - mkdir -p /home/runner/.copilot - cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh - { - "mcpServers": { - "github": { - "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v0.30.3", - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", - "GITHUB_READ_ONLY": "1", - "GITHUB_TOOLSETS": "repos,issues,pull_requests,discussions,search" - } - }, - "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", - "headers": { - "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" - } - } - }, - "gateway": { - "port": $MCP_GATEWAY_PORT, - "domain": "${MCP_GATEWAY_DOMAIN}", - "apiKey": "${MCP_GATEWAY_API_KEY}", - "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" - } - } - GH_AW_MCP_CONFIG_EOF - - name: Generate workflow overview - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); - await generateWorkflowOverview(core); - - name: Download prompt artifact - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: prompt - path: /tmp/gh-aw/aw-prompts - - name: Clean git credentials - run: bash /opt/gh-aw/actions/clean_git_credentials.sh - - name: Execute GitHub Copilot CLI - id: agentic_execution - # Copilot CLI tool arguments (sorted): - # --allow-tool github - # --allow-tool safeoutputs - # --allow-tool shell(cat) - # --allow-tool shell(date) - # --allow-tool shell(echo) - # --allow-tool shell(grep) - # --allow-tool shell(head) - # --allow-tool shell(ls) - # --allow-tool shell(pwd) - # --allow-tool shell(sort) - # --allow-tool shell(tail) - # --allow-tool shell(uniq) - # --allow-tool shell(wc) - # --allow-tool shell(yq) - # --allow-tool write - timeout-minutes: 20 - run: | - set -o pipefail - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.19.1 --skip-pull \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --add-dir /tmp/gh-aw/cache-memory/ --allow-all-paths --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log - env: - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json - GH_AW_MODEL_AGENT_COPILOT: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} - GITHUB_WORKSPACE: ${{ github.workspace }} - XDG_CONFIG_HOME: /home/runner - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Copy Copilot session state files to logs - if: always() - continue-on-error: true - run: | - # Copy Copilot session state files to logs folder for artifact collection - # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them - SESSION_STATE_DIR="$HOME/.copilot/session-state" - LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - - if [ -d "$SESSION_STATE_DIR" ]; then - echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" - mkdir -p "$LOGS_DIR" - cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true - echo "Session state files copied successfully" - else - echo "No session-state directory found at $SESSION_STATE_DIR" - fi - - name: Stop MCP Gateway - if: always() - continue-on-error: true - env: - MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} - MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} - GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} - run: | - bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" - - name: Redact secrets in logs - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); - await main(); - env: - GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' - SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} - SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Upload Safe Outputs - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: safe-output - path: ${{ env.GH_AW_SAFE_OUTPUTS }} - if-no-files-found: warn - - name: Ingest agent output - id: collect_output - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_API_URL: ${{ github.api_url }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs'); - await main(); - - name: Upload sanitized agent output - if: always() && env.GH_AW_AGENT_OUTPUT - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: agent-output - path: ${{ env.GH_AW_AGENT_OUTPUT }} - if-no-files-found: warn - - name: Upload engine output files - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: agent_outputs - path: | - /tmp/gh-aw/sandbox/agent/logs/ - /tmp/gh-aw/redacted-urls.log - if-no-files-found: ignore - - name: Parse agent logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs'); - await main(); - - name: Parse MCP Gateway logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); - await main(); - - name: Print firewall logs - if: always() - continue-on-error: true - env: - AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs - run: | - # Fix permissions on firewall logs so they can be uploaded as artifacts - # AWF runs with sudo, creating files owned by root - sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true - # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) - if command -v awf &> /dev/null; then - awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" - else - echo 'AWF binary not installed, skipping firewall log summary' - fi - - name: Upload cache-memory data as artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - if: always() - with: - name: cache-memory - path: /tmp/gh-aw/cache-memory - - name: Upload agent artifacts - if: always() - continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: agent-artifacts - path: | - /tmp/gh-aw/aw-prompts/prompt.txt - /tmp/gh-aw/aw_info.json - /tmp/gh-aw/mcp-logs/ - /tmp/gh-aw/sandbox/firewall/logs/ - /tmp/gh-aw/agent-stdio.log - /tmp/gh-aw/agent/ - if-no-files-found: ignore - - conclusion: - needs: - - activation - - agent - - detection - - safe_outputs - - update_cache_memory - if: (always()) && (needs.agent.result != 'skipped') - runs-on: ubuntu-slim - permissions: - contents: read - issues: write - outputs: - noop_message: ${{ steps.noop.outputs.noop_message }} - tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} - total_count: ${{ steps.missing_tool.outputs.total_count }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 - with: - destination: /opt/gh-aw/actions - - name: Download agent output artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: agent-output - path: /tmp/gh-aw/safeoutputs/ - - name: Setup agent output environment variable - run: | - mkdir -p /tmp/gh-aw/safeoutputs/ - find "/tmp/gh-aw/safeoutputs/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Process No-Op Messages - id: noop - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_NOOP_MAX: 1 - GH_AW_WORKFLOW_NAME: "13.2 Release Burndown Report" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/noop.cjs'); - await main(); - - name: Record Missing Tool - id: missing_tool - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "13.2 Release Burndown Report" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/missing_tool.cjs'); - await main(); - - name: Handle Agent Failure - id: handle_agent_failure - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "13.2 Release Burndown Report" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_WORKFLOW_ID: "daily-repo-status" - GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }} - GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs'); - await main(); - - name: Handle No-Op Message - id: handle_noop_message - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "13.2 Release Burndown Report" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} - GH_AW_NOOP_REPORT_AS_ISSUE: "true" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); - await main(); - - detection: - needs: agent - if: needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true' - runs-on: ubuntu-latest - permissions: {} - concurrency: - group: "gh-aw-copilot-${{ github.workflow }}" - timeout-minutes: 10 - outputs: - success: ${{ steps.parse_results.outputs.success }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 - with: - destination: /opt/gh-aw/actions - - name: Download agent artifacts - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: agent-artifacts - path: /tmp/gh-aw/threat-detection/ - - name: Download agent output artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: agent-output - path: /tmp/gh-aw/threat-detection/ - - name: Echo agent output types - env: - AGENT_OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} - run: | - echo "Agent output-types: $AGENT_OUTPUT_TYPES" - - name: Setup threat detection - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - WORKFLOW_NAME: "13.2 Release Burndown Report" - WORKFLOW_DESCRIPTION: "Daily burndown report for the Aspire 13.2 milestone. Tracks progress\non issues closed, new bugs found, notable changes merged into the\nrelease/13.2 branch, pending PR reviews, and discussions. Generates\na 7-day burndown chart using cached daily snapshots." - HAS_PATCH: ${{ needs.agent.outputs.has_patch }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); - await main(); - - name: Ensure threat-detection directory and log - run: | - mkdir -p /tmp/gh-aw/threat-detection - touch /tmp/gh-aw/threat-detection/detection.log - - name: Validate COPILOT_GITHUB_TOKEN secret - id: validate-secret - run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default - env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - - name: Install GitHub Copilot CLI - run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.410 - - name: Execute GitHub Copilot CLI - id: agentic_execution - # Copilot CLI tool arguments (sorted): - # --allow-tool shell(cat) - # --allow-tool shell(grep) - # --allow-tool shell(head) - # --allow-tool shell(jq) - # --allow-tool shell(ls) - # --allow-tool shell(tail) - # --allow-tool shell(wc) - timeout-minutes: 20 - run: | - set -o pipefail - COPILOT_CLI_INSTRUCTION="$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" - mkdir -p /tmp/ - mkdir -p /tmp/gh-aw/ - mkdir -p /tmp/gh-aw/agent/ - mkdir -p /tmp/gh-aw/sandbox/agent/logs/ - copilot --add-dir /tmp/ --add-dir /tmp/gh-aw/ --add-dir /tmp/gh-aw/agent/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-tool 'shell(cat)' --allow-tool 'shell(grep)' --allow-tool 'shell(head)' --allow-tool 'shell(jq)' --allow-tool 'shell(ls)' --allow-tool 'shell(tail)' --allow-tool 'shell(wc)' --share /tmp/gh-aw/sandbox/agent/logs/conversation.md --prompt "$COPILOT_CLI_INSTRUCTION"${GH_AW_MODEL_DETECTION_COPILOT:+ --model "$GH_AW_MODEL_DETECTION_COPILOT"} 2>&1 | tee /tmp/gh-aw/threat-detection/detection.log - env: - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - GH_AW_MODEL_DETECTION_COPILOT: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} - GITHUB_WORKSPACE: ${{ github.workspace }} - XDG_CONFIG_HOME: /home/runner - - name: Parse threat detection results - id: parse_results - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); - - name: Upload threat detection log - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: threat-detection.log - path: /tmp/gh-aw/threat-detection/detection.log - if-no-files-found: ignore - - safe_outputs: - needs: - - agent - - detection - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.detection.outputs.success == 'true') - runs-on: ubuntu-slim - permissions: - contents: read - issues: write - timeout-minutes: 15 - env: - GH_AW_ENGINE_ID: "copilot" - GH_AW_WORKFLOW_ID: "daily-repo-status" - GH_AW_WORKFLOW_NAME: "13.2 Release Burndown Report" - outputs: - create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} - create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} - process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} - process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 - with: - destination: /opt/gh-aw/actions - - name: Download agent output artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - with: - name: agent-output - path: /tmp/gh-aw/safeoutputs/ - - name: Setup agent output environment variable - run: | - mkdir -p /tmp/gh-aw/safeoutputs/ - find "/tmp/gh-aw/safeoutputs/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Process Safe Outputs - id: process_safe_outputs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"close_older_issues\":true,\"labels\":[\"report\",\"burndown\"],\"max\":1,\"title_prefix\":\"[13.2-burndown] \"},\"missing_data\":{},\"missing_tool\":{}}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); - await main(); - - update_cache_memory: - needs: - - agent - - detection - if: always() && needs.detection.outputs.success == 'true' - runs-on: ubuntu-latest - permissions: {} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@852cb06ad52958b402ed982b69957ffc57ca0619 # v0.45.5 - with: - destination: /opt/gh-aw/actions - - name: Download cache-memory artifact (default) - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 - continue-on-error: true - with: - name: cache-memory - path: /tmp/gh-aw/cache-memory - - name: Save cache-memory to cache (default) - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - key: memory-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} - path: /tmp/gh-aw/cache-memory - diff --git a/.github/workflows/daily-repo-status.md b/.github/workflows/daily-repo-status.md deleted file mode 100644 index 6291aed99d5..00000000000 --- a/.github/workflows/daily-repo-status.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -description: | - Daily burndown report for the Aspire 13.2 milestone. Tracks progress - on issues closed, new bugs found, notable changes merged into the - release/13.2 branch, pending PR reviews, and discussions. Generates - a 7-day burndown chart using cached daily snapshots. - -on: - schedule: daily around 9am - workflow_dispatch: - -permissions: - contents: read - issues: read - pull-requests: read - discussions: read - -network: defaults - -tools: - github: - toolsets: [repos, issues, pull_requests, discussions, search] - lockdown: false - cache-memory: - bash: ["echo", "date", "cat", "wc"] - -safe-outputs: - create-issue: - title-prefix: "[13.2-burndown] " - labels: [report, burndown] - close-older-issues: true ---- - -# 13.2 Release Burndown Report - -Create a daily burndown report for the **Aspire 13.2 milestone** as a GitHub issue. -The primary goal of this report is to help the team track progress towards the 13.2 release. - -## Data gathering - -Collect the following data using the GitHub tools. All time-based queries should look at the **last 24 hours** unless stated otherwise. - -### 1. Milestone snapshot - -- Find the milestone named **13.2** in this repository. -- Count the **total open issues** and **total closed issues** in the milestone, **excluding pull requests**. Use an issues-only filter (for example, a search query like `is:issue milestone:"13.2" state:open` / `state:closed`) so the counts are consistent across tools. -- Store today's snapshot (date, open count, closed count) using the **cache-memory** tool with the key `burndown-13.2-snapshot`. - - The value for this key **must** be a JSON array of objects with the exact shape: - `[{ "date": "YYYY-MM-DD", "open": , "closed": }, ...]` - - When writing today's data: - 1. Read the existing cache value (if any) and parse it as JSON. If the cache is empty or invalid, start from an empty array. - 2. If an entry for today's date already exists, **replace** it instead of adding a duplicate. - 3. If no entry exists, append a new object. - 4. Sort by date ascending and trim to the **most recent 7 entries**. - 5. Serialize back to JSON and overwrite the cache value. - -### 2. Issues closed in the last 24 hours (13.2 milestone) - -- Search for issues in this repository that were **closed in the last 24 hours** and belong to the **13.2 milestone**. -- For each issue, note the issue number, title, and who closed it. - -### 3. New issues added to 13.2 milestone in the last 24 hours - -- Search for issues in this repository that were **opened in the last 24 hours** and are assigned to the **13.2 milestone**. -- Highlight any that are labeled as `bug` — these are newly discovered bugs for the release. - -### 4. Notable changes merged into release/13.2 - -- Look at pull requests **merged in the last 24 hours** whose **base branch is `release/13.2`**. -- Summarize the most impactful or interesting changes (group by area if possible). - -### 5. PRs pending review targeting release/13.2 - -- Find **open pull requests** with base branch `release/13.2` that are **awaiting reviews** (have no approving reviews yet, or have review requests pending). -- List them with PR number, title, author, and how long they've been open. - -### 6. Discussions related to 13.2 - -- Search discussions in this repository that mention "13.2" or the milestone, especially any **recent activity in the last 24 hours**. -- Briefly summarize any relevant discussion threads. - -### 7. General triage needs (secondary) - -- Briefly note any **new issues opened in the last 24 hours that have no milestone assigned** and may need triage. -- Keep this section short — the focus is on 13.2. - -## Burndown chart - -Using the historical data stored via **cache-memory** (key: `burndown-13.2-snapshot`), generate a **Mermaid xychart** showing the number of **open issues** in the 13.2 milestone over the last 7 days (or however many data points are available). - -Use this format so it renders natively in the GitHub issue: - -~~~ -```mermaid -xychart-beta - title "13.2 Milestone Burndown (Open Issues)" - x-axis [Feb 13, Feb 14, Feb 15, ...] - y-axis "Open Issues" 0 --> MAX - line [N1, N2, N3, ...] -``` -~~~ - -If fewer than 2 data points are available, note that the chart will become richer over the coming days as more snapshots are collected, and still show whatever data is available. - -## Report structure - -Create a GitHub issue with the following sections in this order: - -1. **📊 Burndown Chart** — The Mermaid chart (or a note that data is still being collected) -2. **📈 Milestone Progress** — Total open vs closed, percentage complete, net change today -3. **✅ Issues Closed Today** — Table or list of issues closed in the 13.2 milestone -4. **🐛 New Bugs Found** — Any new bug issues added to the 13.2 milestone -5. **🚀 Notable Changes Merged** — Summary of impactful PRs merged to release/13.2 -6. **👀 PRs Awaiting Review** — Open PRs targeting release/13.2 that need reviewer attention -7. **💬 Discussions** — Relevant 13.2 discussion activity -8. **📋 Triage Queue** — Brief list of un-milestoned issues that need attention (keep short) - -## Style - -- Be concise and data-driven — this is a status report, not a blog post -- Use tables for lists of issues and PRs where appropriate -- Use emojis for section headers to make scanning easy -- If there was no activity in a section, say so briefly (e.g., "No new bugs found today 🎉") -- End with a one-line motivational note for the team - -## Process - -1. Gather all the data described above -2. Read historical burndown data from cache-memory and store today's snapshot -3. Generate the burndown chart -4. Create a new GitHub issue with all sections populated diff --git a/AGENTS.md b/AGENTS.md index cb6596c3d31..cb4d5711eb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -355,7 +355,6 @@ The following specialized skills are available in `.github/skills/`: - **test-management**: Quarantines or disables flaky/problematic tests using the QuarantineTools utility - **connection-properties**: Expert for creating and improving Connection Properties in Aspire resources - **dependency-update**: Guides dependency version updates by checking nuget.org, triggering the dotnet-migrate-package Azure DevOps pipeline, and monitoring runs -- **startup-perf**: Measures Aspire application startup performance using dotnet-trace and the TraceAnalyzer tool ## Pattern-Based Instructions diff --git a/docs/getting-perf-traces.md b/docs/getting-perf-traces.md index 94a5a14a0d5..a669c591ee0 100644 --- a/docs/getting-perf-traces.md +++ b/docs/getting-perf-traces.md @@ -28,16 +28,8 @@ Once you are ready, hit "Start Collection" button and run your scenario. When done with the scenario, hit "Stop Collection". Wait for PerfView to finish merging and analyzing data (the "working" status bar stops flashing). -### Verify that PerfView trace contains Aspire data +### Verify that the trace contains Aspire data This is an optional step, but if you are wondering if your trace has been captured properly, you can check the following: 1. Open the trace (usually named PerfViewData.etl, if you haven't changed the name) and double click Events view. Verify you have a bunch of events from the Microsoft-Aspire-Hosting provider. - -## Profiling scripts - -The `tools/perf` folder in the repository contains scripts that help quickly assess the impact of code changes on key performance scenarios. Currently available scripts are: - -| Script | Description | -| --- | --------- | -| `Measure-StartupPerformance.ps1` | Measures startup time for a specific Aspire project. More specifically, the script measures the time to get all application services and supporting dependencies CREATED; the application is not necessarily responsive after measured time. | diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 6e938847991..1cdf20f5f5e 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -179,33 +179,33 @@ - + https://github.com/dotnet/arcade - 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d + 27e190e2a8053738859c082e2f70df62e01ff524 - + https://github.com/dotnet/arcade - 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d + 27e190e2a8053738859c082e2f70df62e01ff524 - + https://github.com/dotnet/arcade - 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d + 27e190e2a8053738859c082e2f70df62e01ff524 - + https://github.com/dotnet/arcade - 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d + 27e190e2a8053738859c082e2f70df62e01ff524 - + https://github.com/dotnet/arcade - 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d + 27e190e2a8053738859c082e2f70df62e01ff524 - + https://github.com/dotnet/arcade - 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d + 27e190e2a8053738859c082e2f70df62e01ff524 - + https://github.com/dotnet/arcade - 4bf37ce670528cf2aef4d9b1cd892554b1b02d9d + 27e190e2a8053738859c082e2f70df62e01ff524 diff --git a/eng/Versions.props b/eng/Versions.props index 9bf00bbdc0c..d6fac17c504 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -2,7 +2,7 @@ 13 - 3 + 2 0 $(MajorVersion).$(MinorVersion).$(PatchVersion) preview.1 @@ -36,9 +36,9 @@ 0.22.6 0.22.6 - 10.0.0-beta.26110.1 - 10.0.0-beta.26110.1 - 10.0.0-beta.26110.1 + 11.0.0-beta.25610.3 + 11.0.0-beta.25610.3 + 11.0.0-beta.25610.3 10.0.2 10.2.0 diff --git a/eng/build.sh b/eng/build.sh index f23a40c25b4..862bd0abce3 100755 --- a/eng/build.sh +++ b/eng/build.sh @@ -150,7 +150,7 @@ while [[ $# > 0 ]]; do ;; -mauirestore) - export restore_maui=true + extraargs="$extraargs -restoreMaui" shift 1 ;; diff --git a/eng/common/SetupNugetSources.ps1 b/eng/common/SetupNugetSources.ps1 index 65ed3a8adef..fc8d618014e 100644 --- a/eng/common/SetupNugetSources.ps1 +++ b/eng/common/SetupNugetSources.ps1 @@ -1,7 +1,6 @@ # This script adds internal feeds required to build commits that depend on internal package sources. For instance, -# dotnet6-internal would be added automatically if dotnet6 was found in the nuget.config file. Similarly, -# dotnet-eng-internal and dotnet-tools-internal are added if dotnet-eng and dotnet-tools are present. -# In addition, this script also enables disabled internal Maestro (darc-int*) feeds. +# dotnet6-internal would be added automatically if dotnet6 was found in the nuget.config file. In addition also enables +# disabled internal Maestro (darc-int*) feeds. # # Optionally, this script also adds a credential entry for each of the internal feeds if supplied. # @@ -174,16 +173,4 @@ foreach ($dotnetVersion in $dotnetVersions) { } } -# Check for dotnet-eng and add dotnet-eng-internal if present -$dotnetEngSource = $sources.SelectSingleNode("add[@key='dotnet-eng']") -if ($dotnetEngSource -ne $null) { - AddOrEnablePackageSource -Sources $sources -DisabledPackageSources $disabledSources -SourceName "dotnet-eng-internal" -SourceEndPoint "https://pkgs.dev.azure.com/dnceng/internal/_packaging/dotnet-eng-internal/nuget/$feedSuffix" -Creds $creds -Username $userName -pwd $Password -} - -# Check for dotnet-tools and add dotnet-tools-internal if present -$dotnetToolsSource = $sources.SelectSingleNode("add[@key='dotnet-tools']") -if ($dotnetToolsSource -ne $null) { - AddOrEnablePackageSource -Sources $sources -DisabledPackageSources $disabledSources -SourceName "dotnet-tools-internal" -SourceEndPoint "https://pkgs.dev.azure.com/dnceng/internal/_packaging/dotnet-tools-internal/nuget/$feedSuffix" -Creds $creds -Username $userName -pwd $Password -} - $doc.Save($filename) diff --git a/eng/common/SetupNugetSources.sh b/eng/common/SetupNugetSources.sh index b2163abbe71..b97cc536379 100755 --- a/eng/common/SetupNugetSources.sh +++ b/eng/common/SetupNugetSources.sh @@ -1,9 +1,8 @@ #!/usr/bin/env bash # This script adds internal feeds required to build commits that depend on internal package sources. For instance, -# dotnet6-internal would be added automatically if dotnet6 was found in the nuget.config file. Similarly, -# dotnet-eng-internal and dotnet-tools-internal are added if dotnet-eng and dotnet-tools are present. -# In addition, this script also enables disabled internal Maestro (darc-int*) feeds. +# dotnet6-internal would be added automatically if dotnet6 was found in the nuget.config file. In addition also enables +# disabled internal Maestro (darc-int*) feeds. # # Optionally, this script also adds a credential entry for each of the internal feeds if supplied. # @@ -174,18 +173,6 @@ for DotNetVersion in ${DotNetVersions[@]} ; do fi done -# Check for dotnet-eng and add dotnet-eng-internal if present -grep -i " /dev/null -if [ "$?" == "0" ]; then - AddOrEnablePackageSource "dotnet-eng-internal" "https://pkgs.dev.azure.com/dnceng/internal/_packaging/dotnet-eng-internal/nuget/$FeedSuffix" -fi - -# Check for dotnet-tools and add dotnet-tools-internal if present -grep -i " /dev/null -if [ "$?" == "0" ]; then - AddOrEnablePackageSource "dotnet-tools-internal" "https://pkgs.dev.azure.com/dnceng/internal/_packaging/dotnet-tools-internal/nuget/$FeedSuffix" -fi - # I want things split line by line PrevIFS=$IFS IFS=$'\n' diff --git a/eng/common/build.ps1 b/eng/common/build.ps1 index 8cfee107e7a..c10aba98ac6 100644 --- a/eng/common/build.ps1 +++ b/eng/common/build.ps1 @@ -30,6 +30,7 @@ Param( [string] $runtimeSourceFeedKey = '', [switch] $excludePrereleaseVS, [switch] $nativeToolsOnMachine, + [switch] $restoreMaui, [switch] $help, [Parameter(ValueFromRemainingArguments=$true)][String[]]$properties ) @@ -76,6 +77,7 @@ function Print-Usage() { Write-Host " -nodeReuse Sets nodereuse msbuild parameter ('true' or 'false')" Write-Host " -buildCheck Sets /check msbuild parameter" Write-Host " -fromVMR Set when building from within the VMR" + Write-Host " -restoreMaui Restore the MAUI workload after restore (only on Windows/macOS)" Write-Host "" Write-Host "Command line arguments not listed above are passed thru to msbuild." diff --git a/eng/common/build.sh b/eng/common/build.sh index 9767bb411a4..09d1f8e6d9c 100755 --- a/eng/common/build.sh +++ b/eng/common/build.sh @@ -44,6 +44,7 @@ usage() echo " --warnAsError Sets warnaserror msbuild parameter ('true' or 'false')" echo " --buildCheck Sets /check msbuild parameter" echo " --fromVMR Set when building from within the VMR" + echo " --restoreMaui Restore the MAUI workload after restore (only on macOS)" echo "" echo "Command line arguments not listed above are passed thru to msbuild." echo "Arguments can also be passed in with a single hyphen." @@ -76,6 +77,7 @@ sign=false public=false ci=false clean=false +restore_maui=false warn_as_error=true node_reuse=true @@ -92,7 +94,7 @@ runtime_source_feed='' runtime_source_feed_key='' properties=() -while [[ $# > 0 ]]; do +while [[ $# -gt 0 ]]; do opt="$(echo "${1/#--/-}" | tr "[:upper:]" "[:lower:]")" case "$opt" in -help|-h) @@ -183,6 +185,9 @@ while [[ $# > 0 ]]; do -buildcheck) build_check=true ;; + -restoremaui|-restore-maui) + restore_maui=true + ;; -runtimesourcefeed) runtime_source_feed=$2 shift diff --git a/eng/common/core-templates/job/job.yml b/eng/common/core-templates/job/job.yml index 5ce51840619..748c4f07a64 100644 --- a/eng/common/core-templates/job/job.yml +++ b/eng/common/core-templates/job/job.yml @@ -19,6 +19,8 @@ parameters: # publishing defaults artifacts: '' enableMicrobuild: false + enablePreviewMicrobuild: false + microbuildPluginVersion: 'latest' enableMicrobuildForMacAndLinux: false microbuildUseESRP: true enablePublishBuildArtifacts: false @@ -71,6 +73,8 @@ jobs: templateContext: ${{ parameters.templateContext }} variables: + - name: AllowPtrToDetectTestRunRetryFiles + value: true - ${{ if ne(parameters.enableTelemetry, 'false') }}: - name: DOTNET_CLI_TELEMETRY_PROFILE value: '$(Build.Repository.Uri)' @@ -128,6 +132,8 @@ jobs: - template: /eng/common/core-templates/steps/install-microbuild.yml parameters: enableMicrobuild: ${{ parameters.enableMicrobuild }} + enablePreviewMicrobuild: ${{ parameters.enablePreviewMicrobuild }} + microbuildPluginVersion: ${{ parameters.microbuildPluginVersion }} enableMicrobuildForMacAndLinux: ${{ parameters.enableMicrobuildForMacAndLinux }} microbuildUseESRP: ${{ parameters.microbuildUseESRP }} continueOnError: ${{ parameters.continueOnError }} @@ -153,6 +159,8 @@ jobs: - template: /eng/common/core-templates/steps/cleanup-microbuild.yml parameters: enableMicrobuild: ${{ parameters.enableMicrobuild }} + enablePreviewMicrobuild: ${{ parameters.enablePreviewMicrobuild }} + microbuildPluginVersion: ${{ parameters.microbuildPluginVersion }} enableMicrobuildForMacAndLinux: ${{ parameters.enableMicrobuildForMacAndLinux }} continueOnError: ${{ parameters.continueOnError }} diff --git a/eng/common/core-templates/job/publish-build-assets.yml b/eng/common/core-templates/job/publish-build-assets.yml index b955fac6e13..8b5c635fe80 100644 --- a/eng/common/core-templates/job/publish-build-assets.yml +++ b/eng/common/core-templates/job/publish-build-assets.yml @@ -80,7 +80,7 @@ jobs: # If it's not devdiv, it's dnceng ${{ if ne(variables['System.TeamProject'], 'DevDiv') }}: name: NetCore1ESPool-Publishing-Internal - image: windows.vs2022.amd64 + image: windows.vs2019.amd64 os: windows steps: - ${{ if eq(parameters.is1ESPipeline, '') }}: @@ -91,8 +91,8 @@ jobs: fetchDepth: 3 clean: true - - ${{ if eq(parameters.isAssetlessBuild, 'false') }}: - - ${{ if eq(parameters.publishingVersion, 3) }}: + - ${{ if eq(parameters.isAssetlessBuild, 'false') }}: + - ${{ if eq(parameters.publishingVersion, 3) }}: - task: DownloadPipelineArtifact@2 displayName: Download Asset Manifests inputs: @@ -117,7 +117,7 @@ jobs: flattenFolders: true condition: ${{ parameters.condition }} continueOnError: ${{ parameters.continueOnError }} - + - task: NuGetAuthenticate@1 # Populate internal runtime variables. @@ -125,7 +125,7 @@ jobs: ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: parameters: legacyCredential: $(dn-bot-dnceng-artifact-feeds-rw) - + - template: /eng/common/templates/steps/enable-internal-runtimes.yml - task: AzureCLI@2 @@ -145,7 +145,7 @@ jobs: condition: ${{ parameters.condition }} continueOnError: ${{ parameters.continueOnError }} - + - task: powershell@2 displayName: Create ReleaseConfigs Artifact inputs: @@ -173,7 +173,7 @@ jobs: artifactName: AssetManifests displayName: 'Publish Merged Manifest' retryCountOnTaskFailure: 10 # for any logs being locked - sbomEnabled: false # we don't need SBOM for logs + sbomEnabled: false # we don't need SBOM for logs - template: /eng/common/core-templates/steps/publish-build-artifacts.yml parameters: @@ -190,7 +190,7 @@ jobs: BARBuildId: ${{ parameters.BARBuildId }} PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} is1ESPipeline: ${{ parameters.is1ESPipeline }} - + # Darc is targeting 8.0, so make sure it's installed - task: UseDotNet@2 inputs: @@ -218,4 +218,4 @@ jobs: - template: /eng/common/core-templates/steps/publish-logs.yml parameters: is1ESPipeline: ${{ parameters.is1ESPipeline }} - JobLabel: 'Publish_Artifacts_Logs' + JobLabel: 'Publish_Artifacts_Logs' diff --git a/eng/common/core-templates/job/source-build.yml b/eng/common/core-templates/job/source-build.yml index 1997c2ae00d..9d820f97421 100644 --- a/eng/common/core-templates/job/source-build.yml +++ b/eng/common/core-templates/job/source-build.yml @@ -60,19 +60,19 @@ jobs: pool: ${{ if eq(variables['System.TeamProject'], 'public') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore-Svc-Public' ), False, 'NetCore-Public')] - demands: ImageOverride -equals build.azurelinux.3.amd64.open + demands: ImageOverride -equals build.ubuntu.2204.amd64 ${{ if eq(variables['System.TeamProject'], 'internal') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore1ESPool-Svc-Internal'), False, 'NetCore1ESPool-Internal')] - image: build.azurelinux.3.amd64 + image: 1es-azurelinux-3 os: linux ${{ else }}: pool: ${{ if eq(variables['System.TeamProject'], 'public') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore-Svc-Public' ), False, 'NetCore-Public')] - demands: ImageOverride -equals build.azurelinux.3.amd64.open + demands: ImageOverride -equals Build.Ubuntu.2204.Amd64.Open ${{ if eq(variables['System.TeamProject'], 'internal') }}: name: $[replace(replace(eq(contains(coalesce(variables['System.PullRequest.TargetBranch'], variables['Build.SourceBranch'], 'refs/heads/main'), 'release'), 'true'), True, 'NetCore1ESPool-Svc-Internal'), False, 'NetCore1ESPool-Internal')] - demands: ImageOverride -equals build.azurelinux.3.amd64 + demands: ImageOverride -equals Build.Ubuntu.2204.Amd64 ${{ if ne(parameters.platform.pool, '') }}: pool: ${{ parameters.platform.pool }} diff --git a/eng/common/core-templates/post-build/post-build.yml b/eng/common/core-templates/post-build/post-build.yml index b942a79ef02..06864cd1feb 100644 --- a/eng/common/core-templates/post-build/post-build.yml +++ b/eng/common/core-templates/post-build/post-build.yml @@ -1,106 +1,106 @@ parameters: - # Which publishing infra should be used. THIS SHOULD MATCH THE VERSION ON THE BUILD MANIFEST. - # Publishing V1 is no longer supported - # Publishing V2 is no longer supported - # Publishing V3 is the default - - name: publishingInfraVersion - displayName: Which version of publishing should be used to promote the build definition? - type: number - default: 3 - values: - - 3 - - - name: BARBuildId - displayName: BAR Build Id - type: number - default: 0 - - - name: PromoteToChannelIds - displayName: Channel to promote BARBuildId to - type: string - default: '' - - - name: enableSourceLinkValidation - displayName: Enable SourceLink validation - type: boolean - default: false - - - name: enableSigningValidation - displayName: Enable signing validation - type: boolean - default: true - - - name: enableSymbolValidation - displayName: Enable symbol validation - type: boolean - default: false - - - name: enableNugetValidation - displayName: Enable NuGet validation - type: boolean - default: true - - - name: publishInstallersAndChecksums - displayName: Publish installers and checksums - type: boolean - default: true - - - name: requireDefaultChannels - displayName: Fail the build if there are no default channel(s) registrations for the current build - type: boolean - default: false - - - name: SDLValidationParameters - type: object - default: - enable: false - publishGdn: false - continueOnError: false - params: '' - artifactNames: '' - downloadArtifacts: true - - - name: isAssetlessBuild - type: boolean - displayName: Is Assetless Build - default: false - - # These parameters let the user customize the call to sdk-task.ps1 for publishing - # symbols & general artifacts as well as for signing validation - - name: symbolPublishingAdditionalParameters - displayName: Symbol publishing additional parameters - type: string - default: '' - - - name: artifactsPublishingAdditionalParameters - displayName: Artifact publishing additional parameters - type: string - default: '' - - - name: signingValidationAdditionalParameters - displayName: Signing validation additional parameters - type: string - default: '' - - # Which stages should finish execution before post-build stages start - - name: validateDependsOn - type: object - default: - - build - - - name: publishDependsOn - type: object - default: - - Validate - - # Optional: Call asset publishing rather than running in a separate stage - - name: publishAssetsImmediately - type: boolean - default: false - - - name: is1ESPipeline - type: boolean - default: false +# Which publishing infra should be used. THIS SHOULD MATCH THE VERSION ON THE BUILD MANIFEST. +# Publishing V1 is no longer supported +# Publishing V2 is no longer supported +# Publishing V3 is the default +- name: publishingInfraVersion + displayName: Which version of publishing should be used to promote the build definition? + type: number + default: 3 + values: + - 3 + +- name: BARBuildId + displayName: BAR Build Id + type: number + default: 0 + +- name: PromoteToChannelIds + displayName: Channel to promote BARBuildId to + type: string + default: '' + +- name: enableSourceLinkValidation + displayName: Enable SourceLink validation + type: boolean + default: false + +- name: enableSigningValidation + displayName: Enable signing validation + type: boolean + default: true + +- name: enableSymbolValidation + displayName: Enable symbol validation + type: boolean + default: false + +- name: enableNugetValidation + displayName: Enable NuGet validation + type: boolean + default: true + +- name: publishInstallersAndChecksums + displayName: Publish installers and checksums + type: boolean + default: true + +- name: requireDefaultChannels + displayName: Fail the build if there are no default channel(s) registrations for the current build + type: boolean + default: false + +- name: SDLValidationParameters + type: object + default: + enable: false + publishGdn: false + continueOnError: false + params: '' + artifactNames: '' + downloadArtifacts: true + +- name: isAssetlessBuild + type: boolean + displayName: Is Assetless Build + default: false + +# These parameters let the user customize the call to sdk-task.ps1 for publishing +# symbols & general artifacts as well as for signing validation +- name: symbolPublishingAdditionalParameters + displayName: Symbol publishing additional parameters + type: string + default: '' + +- name: artifactsPublishingAdditionalParameters + displayName: Artifact publishing additional parameters + type: string + default: '' + +- name: signingValidationAdditionalParameters + displayName: Signing validation additional parameters + type: string + default: '' + +# Which stages should finish execution before post-build stages start +- name: validateDependsOn + type: object + default: + - build + +- name: publishDependsOn + type: object + default: + - Validate + +# Optional: Call asset publishing rather than running in a separate stage +- name: publishAssetsImmediately + type: boolean + default: false + +- name: is1ESPipeline + type: boolean + default: false stages: - ${{ if or(eq( parameters.enableNugetValidation, 'true'), eq(parameters.enableSigningValidation, 'true'), eq(parameters.enableSourceLinkValidation, 'true'), eq(parameters.SDLValidationParameters.enable, 'true')) }}: @@ -108,10 +108,10 @@ stages: dependsOn: ${{ parameters.validateDependsOn }} displayName: Validate Build Assets variables: - - template: /eng/common/core-templates/post-build/common-variables.yml - - template: /eng/common/core-templates/variables/pool-providers.yml - parameters: - is1ESPipeline: ${{ parameters.is1ESPipeline }} + - template: /eng/common/core-templates/post-build/common-variables.yml + - template: /eng/common/core-templates/variables/pool-providers.yml + parameters: + is1ESPipeline: ${{ parameters.is1ESPipeline }} jobs: - job: displayName: NuGet Validation @@ -134,28 +134,28 @@ stages: demands: ImageOverride -equals windows.vs2026preview.scout.amd64 steps: - - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml - parameters: - BARBuildId: ${{ parameters.BARBuildId }} - PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} - is1ESPipeline: ${{ parameters.is1ESPipeline }} - - - task: DownloadBuildArtifacts@0 - displayName: Download Package Artifacts - inputs: - buildType: specific - buildVersionToDownload: specific - project: $(AzDOProjectName) - pipeline: $(AzDOPipelineId) - buildId: $(AzDOBuildId) - artifactName: PackageArtifacts - checkDownloadedFiles: true - - - task: PowerShell@2 - displayName: Validate - inputs: - filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/nuget-validation.ps1 - arguments: -PackagesPath $(Build.ArtifactStagingDirectory)/PackageArtifacts/ + - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml + parameters: + BARBuildId: ${{ parameters.BARBuildId }} + PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} + is1ESPipeline: ${{ parameters.is1ESPipeline }} + + - task: DownloadBuildArtifacts@0 + displayName: Download Package Artifacts + inputs: + buildType: specific + buildVersionToDownload: specific + project: $(AzDOProjectName) + pipeline: $(AzDOPipelineId) + buildId: $(AzDOBuildId) + artifactName: PackageArtifacts + checkDownloadedFiles: true + + - task: PowerShell@2 + displayName: Validate + inputs: + filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/nuget-validation.ps1 + arguments: -PackagesPath $(Build.ArtifactStagingDirectory)/PackageArtifacts/ - job: displayName: Signing Validation @@ -169,7 +169,7 @@ stages: os: windows # If it's not devdiv, it's dnceng ${{ else }}: - ${{ if eq(parameters.is1ESPipeline, true) }}: + ${{ if eq(parameters.is1ESPipeline, true) }}: name: $(DncEngInternalBuildPool) image: 1es-windows-2022 os: windows @@ -177,46 +177,46 @@ stages: name: $(DncEngInternalBuildPool) demands: ImageOverride -equals windows.vs2026preview.scout.amd64 steps: - - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml - parameters: - BARBuildId: ${{ parameters.BARBuildId }} - PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} - is1ESPipeline: ${{ parameters.is1ESPipeline }} - - - task: DownloadBuildArtifacts@0 - displayName: Download Package Artifacts - inputs: - buildType: specific - buildVersionToDownload: specific - project: $(AzDOProjectName) - pipeline: $(AzDOPipelineId) - buildId: $(AzDOBuildId) - artifactName: PackageArtifacts - checkDownloadedFiles: true - - # This is necessary whenever we want to publish/restore to an AzDO private feed - # Since sdk-task.ps1 tries to restore packages we need to do this authentication here - # otherwise it'll complain about accessing a private feed. - - task: NuGetAuthenticate@1 - displayName: 'Authenticate to AzDO Feeds' - - # Signing validation will optionally work with the buildmanifest file which is downloaded from - # Azure DevOps above. - - task: PowerShell@2 - displayName: Validate - inputs: - filePath: eng\common\sdk-task.ps1 - arguments: -task SigningValidation -restore -msbuildEngine vs - /p:PackageBasePath='$(Build.ArtifactStagingDirectory)/PackageArtifacts' - /p:SignCheckExclusionsFile='$(System.DefaultWorkingDirectory)/eng/SignCheckExclusionsFile.txt' - ${{ parameters.signingValidationAdditionalParameters }} - - - template: /eng/common/core-templates/steps/publish-logs.yml - parameters: - is1ESPipeline: ${{ parameters.is1ESPipeline }} - StageLabel: 'Validation' - JobLabel: 'Signing' - BinlogToolVersion: $(BinlogToolVersion) + - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml + parameters: + BARBuildId: ${{ parameters.BARBuildId }} + PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} + is1ESPipeline: ${{ parameters.is1ESPipeline }} + + - task: DownloadBuildArtifacts@0 + displayName: Download Package Artifacts + inputs: + buildType: specific + buildVersionToDownload: specific + project: $(AzDOProjectName) + pipeline: $(AzDOPipelineId) + buildId: $(AzDOBuildId) + artifactName: PackageArtifacts + checkDownloadedFiles: true + + # This is necessary whenever we want to publish/restore to an AzDO private feed + # Since sdk-task.ps1 tries to restore packages we need to do this authentication here + # otherwise it'll complain about accessing a private feed. + - task: NuGetAuthenticate@1 + displayName: 'Authenticate to AzDO Feeds' + + # Signing validation will optionally work with the buildmanifest file which is downloaded from + # Azure DevOps above. + - task: PowerShell@2 + displayName: Validate + inputs: + filePath: eng\common\sdk-task.ps1 + arguments: -task SigningValidation -restore -msbuildEngine vs + /p:PackageBasePath='$(Build.ArtifactStagingDirectory)/PackageArtifacts' + /p:SignCheckExclusionsFile='$(System.DefaultWorkingDirectory)/eng/SignCheckExclusionsFile.txt' + ${{ parameters.signingValidationAdditionalParameters }} + + - template: /eng/common/core-templates/steps/publish-logs.yml + parameters: + is1ESPipeline: ${{ parameters.is1ESPipeline }} + StageLabel: 'Validation' + JobLabel: 'Signing' + BinlogToolVersion: $(BinlogToolVersion) - job: displayName: SourceLink Validation @@ -230,7 +230,7 @@ stages: os: windows # If it's not devdiv, it's dnceng ${{ else }}: - ${{ if eq(parameters.is1ESPipeline, true) }}: + ${{ if eq(parameters.is1ESPipeline, true) }}: name: $(DncEngInternalBuildPool) image: 1es-windows-2022 os: windows @@ -238,33 +238,33 @@ stages: name: $(DncEngInternalBuildPool) demands: ImageOverride -equals windows.vs2026preview.scout.amd64 steps: - - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml - parameters: - BARBuildId: ${{ parameters.BARBuildId }} - PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} - is1ESPipeline: ${{ parameters.is1ESPipeline }} - - - task: DownloadBuildArtifacts@0 - displayName: Download Blob Artifacts - inputs: - buildType: specific - buildVersionToDownload: specific - project: $(AzDOProjectName) - pipeline: $(AzDOPipelineId) - buildId: $(AzDOBuildId) - artifactName: BlobArtifacts - checkDownloadedFiles: true - - - task: PowerShell@2 - displayName: Validate - inputs: - filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/sourcelink-validation.ps1 - arguments: -InputPath $(Build.ArtifactStagingDirectory)/BlobArtifacts/ - -ExtractPath $(Agent.BuildDirectory)/Extract/ - -GHRepoName $(Build.Repository.Name) - -GHCommit $(Build.SourceVersion) - -SourcelinkCliVersion $(SourceLinkCLIVersion) - continueOnError: true + - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml + parameters: + BARBuildId: ${{ parameters.BARBuildId }} + PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} + is1ESPipeline: ${{ parameters.is1ESPipeline }} + + - task: DownloadBuildArtifacts@0 + displayName: Download Blob Artifacts + inputs: + buildType: specific + buildVersionToDownload: specific + project: $(AzDOProjectName) + pipeline: $(AzDOPipelineId) + buildId: $(AzDOBuildId) + artifactName: BlobArtifacts + checkDownloadedFiles: true + + - task: PowerShell@2 + displayName: Validate + inputs: + filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/sourcelink-validation.ps1 + arguments: -InputPath $(Build.ArtifactStagingDirectory)/BlobArtifacts/ + -ExtractPath $(Agent.BuildDirectory)/Extract/ + -GHRepoName $(Build.Repository.Name) + -GHCommit $(Build.SourceVersion) + -SourcelinkCliVersion $(SourceLinkCLIVersion) + continueOnError: true - ${{ if ne(parameters.publishAssetsImmediately, 'true') }}: - stage: publish_using_darc @@ -274,10 +274,10 @@ stages: dependsOn: ${{ parameters.validateDependsOn }} displayName: Publish using Darc variables: - - template: /eng/common/core-templates/post-build/common-variables.yml - - template: /eng/common/core-templates/variables/pool-providers.yml - parameters: - is1ESPipeline: ${{ parameters.is1ESPipeline }} + - template: /eng/common/core-templates/post-build/common-variables.yml + - template: /eng/common/core-templates/variables/pool-providers.yml + parameters: + is1ESPipeline: ${{ parameters.is1ESPipeline }} jobs: - job: displayName: Publish Using Darc @@ -291,42 +291,41 @@ stages: os: windows # If it's not devdiv, it's dnceng ${{ else }}: - ${{ if eq(parameters.is1ESPipeline, true) }}: + ${{ if eq(parameters.is1ESPipeline, true) }}: name: NetCore1ESPool-Publishing-Internal - image: windows.vs2022.amd64 + image: windows.vs2019.amd64 os: windows ${{ else }}: name: NetCore1ESPool-Publishing-Internal - demands: ImageOverride -equals windows.vs2022.amd64 + demands: ImageOverride -equals windows.vs2019.amd64 steps: - - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml - parameters: - BARBuildId: ${{ parameters.BARBuildId }} - PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} - is1ESPipeline: ${{ parameters.is1ESPipeline }} - - - task: NuGetAuthenticate@1 - - # Populate internal runtime variables. - - template: /eng/common/templates/steps/enable-internal-sources.yml - parameters: - legacyCredential: $(dn-bot-dnceng-artifact-feeds-rw) - - - template: /eng/common/templates/steps/enable-internal-runtimes.yml - - # Darc is targeting 8.0, so make sure it's installed - - task: UseDotNet@2 - inputs: - version: 8.0.x - - - task: AzureCLI@2 - displayName: Publish Using Darc - inputs: - azureSubscription: "Darc: Maestro Production" - scriptType: ps - scriptLocation: scriptPath - scriptPath: $(System.DefaultWorkingDirectory)/eng/common/post-build/publish-using-darc.ps1 - arguments: > + - template: /eng/common/core-templates/post-build/setup-maestro-vars.yml + parameters: + BARBuildId: ${{ parameters.BARBuildId }} + PromoteToChannelIds: ${{ parameters.PromoteToChannelIds }} + is1ESPipeline: ${{ parameters.is1ESPipeline }} + + - task: NuGetAuthenticate@1 + + # Populate internal runtime variables. + - template: /eng/common/templates/steps/enable-internal-sources.yml + parameters: + legacyCredential: $(dn-bot-dnceng-artifact-feeds-rw) + + - template: /eng/common/templates/steps/enable-internal-runtimes.yml + + - task: UseDotNet@2 + inputs: + version: 8.0.x + + - task: AzureCLI@2 + displayName: Publish Using Darc + inputs: + azureSubscription: "Darc: Maestro Production" + scriptType: ps + scriptLocation: scriptPath + scriptPath: $(System.DefaultWorkingDirectory)/eng/common/post-build/publish-using-darc.ps1 + arguments: > -BuildId $(BARBuildId) -PublishingInfraVersion ${{ parameters.publishingInfraVersion }} -AzdoToken '$(System.AccessToken)' diff --git a/eng/common/core-templates/steps/generate-sbom.yml b/eng/common/core-templates/steps/generate-sbom.yml index c05f6502797..003f7eae0fa 100644 --- a/eng/common/core-templates/steps/generate-sbom.yml +++ b/eng/common/core-templates/steps/generate-sbom.yml @@ -5,7 +5,7 @@ # IgnoreDirectories - Directories to ignore for SBOM generation. This will be passed through to the CG component detector. parameters: - PackageVersion: 10.0.0 + PackageVersion: 11.0.0 BuildDropPath: '$(System.DefaultWorkingDirectory)/artifacts' PackageName: '.NET' ManifestDirPath: $(Build.ArtifactStagingDirectory)/sbom diff --git a/eng/common/core-templates/steps/install-microbuild-impl.yml b/eng/common/core-templates/steps/install-microbuild-impl.yml new file mode 100644 index 00000000000..b9e0143ee92 --- /dev/null +++ b/eng/common/core-templates/steps/install-microbuild-impl.yml @@ -0,0 +1,34 @@ +parameters: + - name: microbuildTaskInputs + type: object + default: {} + + - name: microbuildEnv + type: object + default: {} + + - name: enablePreviewMicrobuild + type: boolean + default: false + + - name: condition + type: string + + - name: continueOnError + type: boolean + +steps: +- ${{ if eq(parameters.enablePreviewMicrobuild, 'true') }}: + - task: MicroBuildSigningPluginPreview@4 + displayName: Install Preview MicroBuild plugin + inputs: ${{ parameters.microbuildTaskInputs }} + env: ${{ parameters.microbuildEnv }} + continueOnError: ${{ parameters.continueOnError }} + condition: ${{ parameters.condition }} +- ${{ else }}: + - task: MicroBuildSigningPlugin@4 + displayName: Install MicroBuild plugin + inputs: ${{ parameters.microbuildTaskInputs }} + env: ${{ parameters.microbuildEnv }} + continueOnError: ${{ parameters.continueOnError }} + condition: ${{ parameters.condition }} diff --git a/eng/common/core-templates/steps/install-microbuild.yml b/eng/common/core-templates/steps/install-microbuild.yml index 553fce66b94..4f4b56ed2a6 100644 --- a/eng/common/core-templates/steps/install-microbuild.yml +++ b/eng/common/core-templates/steps/install-microbuild.yml @@ -4,6 +4,8 @@ parameters: # Enable install tasks for MicroBuild on Mac and Linux # Will be ignored if 'enableMicrobuild' is false or 'Agent.Os' is 'Windows_NT' enableMicrobuildForMacAndLinux: false + # Enable preview version of MB signing plugin + enablePreviewMicrobuild: false # Determines whether the ESRP service connection information should be passed to the signing plugin. # This overlaps with _SignType to some degree. We only need the service connection for real signing. # It's important that the service connection not be passed to the MicroBuildSigningPlugin task in this place. @@ -13,6 +15,8 @@ parameters: microbuildUseESRP: true # Microbuild installation directory microBuildOutputFolder: $(Agent.TempDirectory)/MicroBuild + # Microbuild version + microbuildPluginVersion: 'latest' continueOnError: false @@ -69,42 +73,46 @@ steps: # YAML expansion, and Windows vs. Linux/Mac uses different service connections. However, # we can avoid including the MB install step if not enabled at all. This avoids a bunch of # extra pipeline authorizations, since most pipelines do not sign on non-Windows. - - task: MicroBuildSigningPlugin@4 - displayName: Install MicroBuild plugin (Windows) - inputs: - signType: $(_SignType) - zipSources: false - feedSource: https://dnceng.pkgs.visualstudio.com/_packaging/MicroBuildToolset/nuget/v3/index.json - ${{ if eq(parameters.microbuildUseESRP, true) }}: - ConnectedServiceName: 'MicroBuild Signing Task (DevDiv)' - ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: - ConnectedPMEServiceName: 6cc74545-d7b9-4050-9dfa-ebefcc8961ea - ${{ else }}: - ConnectedPMEServiceName: 248d384a-b39b-46e3-8ad5-c2c210d5e7ca - env: - TeamName: $(_TeamName) - MicroBuildOutputFolderOverride: ${{ parameters.microBuildOutputFolder }} - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - continueOnError: ${{ parameters.continueOnError }} - condition: and(succeeded(), eq(variables['Agent.Os'], 'Windows_NT'), in(variables['_SignType'], 'real', 'test')) - - - ${{ if eq(parameters.enableMicrobuildForMacAndLinux, true) }}: - - task: MicroBuildSigningPlugin@4 - displayName: Install MicroBuild plugin (non-Windows) - inputs: + - template: /eng/common/core-templates/steps/install-microbuild-impl.yml@self + parameters: + enablePreviewMicrobuild: ${{ parameters.enablePreviewMicrobuild }} + microbuildTaskInputs: signType: $(_SignType) zipSources: false feedSource: https://dnceng.pkgs.visualstudio.com/_packaging/MicroBuildToolset/nuget/v3/index.json - workingDirectory: ${{ parameters.microBuildOutputFolder }} + version: ${{ parameters.microbuildPluginVersion }} ${{ if eq(parameters.microbuildUseESRP, true) }}: ConnectedServiceName: 'MicroBuild Signing Task (DevDiv)' ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: - ConnectedPMEServiceName: beb8cb23-b303-4c95-ab26-9e44bc958d39 + ConnectedPMEServiceName: 6cc74545-d7b9-4050-9dfa-ebefcc8961ea ${{ else }}: - ConnectedPMEServiceName: c24de2a5-cc7a-493d-95e4-8e5ff5cad2bc - env: + ConnectedPMEServiceName: 248d384a-b39b-46e3-8ad5-c2c210d5e7ca + microbuildEnv: TeamName: $(_TeamName) MicroBuildOutputFolderOverride: ${{ parameters.microBuildOutputFolder }} SYSTEM_ACCESSTOKEN: $(System.AccessToken) continueOnError: ${{ parameters.continueOnError }} - condition: and(succeeded(), ne(variables['Agent.Os'], 'Windows_NT'), eq(variables['_SignType'], 'real')) + condition: and(succeeded(), eq(variables['Agent.Os'], 'Windows_NT'), in(variables['_SignType'], 'real', 'test')) + + - ${{ if eq(parameters.enableMicrobuildForMacAndLinux, true) }}: + - template: /eng/common/core-templates/steps/install-microbuild-impl.yml@self + parameters: + enablePreviewMicrobuild: ${{ parameters.enablePreviewMicrobuild }} + microbuildTaskInputs: + signType: $(_SignType) + zipSources: false + feedSource: https://dnceng.pkgs.visualstudio.com/_packaging/MicroBuildToolset/nuget/v3/index.json + version: ${{ parameters.microbuildPluginVersion }} + workingDirectory: ${{ parameters.microBuildOutputFolder }} + ${{ if eq(parameters.microbuildUseESRP, true) }}: + ConnectedServiceName: 'MicroBuild Signing Task (DevDiv)' + ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: + ConnectedPMEServiceName: beb8cb23-b303-4c95-ab26-9e44bc958d39 + ${{ else }}: + ConnectedPMEServiceName: c24de2a5-cc7a-493d-95e4-8e5ff5cad2bc + microbuildEnv: + TeamName: $(_TeamName) + MicroBuildOutputFolderOverride: ${{ parameters.microBuildOutputFolder }} + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + continueOnError: ${{ parameters.continueOnError }} + condition: and(succeeded(), ne(variables['Agent.Os'], 'Windows_NT'), eq(variables['_SignType'], 'real')) diff --git a/eng/common/core-templates/steps/source-build.yml b/eng/common/core-templates/steps/source-build.yml index b9c86c18ae4..acf16ed3496 100644 --- a/eng/common/core-templates/steps/source-build.yml +++ b/eng/common/core-templates/steps/source-build.yml @@ -24,7 +24,7 @@ steps: # in the default public locations. internalRuntimeDownloadArgs= if [ '$(dotnetbuilds-internal-container-read-token-base64)' != '$''(dotnetbuilds-internal-container-read-token-base64)' ]; then - internalRuntimeDownloadArgs='/p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) --runtimesourcefeed https://ci.dot.net/internal --runtimesourcefeedkey '$(dotnetbuilds-internal-container-read-token-base64)'' + internalRuntimeDownloadArgs='/p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64) --runtimesourcefeed https://ci.dot.net/internal --runtimesourcefeedkey $(dotnetbuilds-internal-container-read-token-base64)' fi buildConfig=Release diff --git a/eng/common/core-templates/steps/source-index-stage1-publish.yml b/eng/common/core-templates/steps/source-index-stage1-publish.yml index e9a694afa58..ac019e2d033 100644 --- a/eng/common/core-templates/steps/source-index-stage1-publish.yml +++ b/eng/common/core-templates/steps/source-index-stage1-publish.yml @@ -1,6 +1,6 @@ parameters: - sourceIndexUploadPackageVersion: 2.0.0-20250818.1 - sourceIndexProcessBinlogPackageVersion: 1.0.1-20250818.1 + sourceIndexUploadPackageVersion: 2.0.0-20250906.1 + sourceIndexProcessBinlogPackageVersion: 1.0.1-20250906.1 sourceIndexPackageSource: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json binlogPath: artifacts/log/Debug/Build.binlog @@ -14,8 +14,8 @@ steps: workingDirectory: $(Agent.TempDirectory) - script: | - $(Agent.TempDirectory)/dotnet/dotnet tool install BinLogToSln --version ${{parameters.sourceIndexProcessBinlogPackageVersion}} --add-source ${{parameters.SourceIndexPackageSource}} --tool-path $(Agent.TempDirectory)/.source-index/tools - $(Agent.TempDirectory)/dotnet/dotnet tool install UploadIndexStage1 --version ${{parameters.sourceIndexUploadPackageVersion}} --add-source ${{parameters.SourceIndexPackageSource}} --tool-path $(Agent.TempDirectory)/.source-index/tools + $(Agent.TempDirectory)/dotnet/dotnet tool install BinLogToSln --version ${{parameters.sourceIndexProcessBinlogPackageVersion}} --source ${{parameters.SourceIndexPackageSource}} --tool-path $(Agent.TempDirectory)/.source-index/tools + $(Agent.TempDirectory)/dotnet/dotnet tool install UploadIndexStage1 --version ${{parameters.sourceIndexUploadPackageVersion}} --source ${{parameters.SourceIndexPackageSource}} --tool-path $(Agent.TempDirectory)/.source-index/tools displayName: "Source Index: Download netsourceindex Tools" # Set working directory to temp directory so 'dotnet' doesn't try to use global.json and use the repo's sdk. workingDirectory: $(Agent.TempDirectory) diff --git a/eng/common/darc-init.sh b/eng/common/darc-init.sh index e889f439b8d..9f5ad6b763b 100755 --- a/eng/common/darc-init.sh +++ b/eng/common/darc-init.sh @@ -5,7 +5,7 @@ darcVersion='' versionEndpoint='https://maestro.dot.net/api/assets/darc-version?api-version=2020-02-20' verbosity='minimal' -while [[ $# > 0 ]]; do +while [[ $# -gt 0 ]]; do opt="$(echo "$1" | tr "[:upper:]" "[:lower:]")" case "$opt" in --darcversion) diff --git a/eng/common/dotnet-install.sh b/eng/common/dotnet-install.sh index 7b9d97e3bd4..61f302bb677 100755 --- a/eng/common/dotnet-install.sh +++ b/eng/common/dotnet-install.sh @@ -18,7 +18,7 @@ architecture='' runtime='dotnet' runtimeSourceFeed='' runtimeSourceFeedKey='' -while [[ $# > 0 ]]; do +while [[ $# -gt 0 ]]; do opt="$(echo "$1" | tr "[:upper:]" "[:lower:]")" case "$opt" in -version|-v) diff --git a/eng/common/dotnet.sh b/eng/common/dotnet.sh index 2ef68235675..f6d24871c1d 100644 --- a/eng/common/dotnet.sh +++ b/eng/common/dotnet.sh @@ -19,7 +19,7 @@ source $scriptroot/tools.sh InitializeDotNetCli true # install # Invoke acquired SDK with args if they are provided -if [[ $# > 0 ]]; then +if [[ $# -gt 0 ]]; then __dotnetDir=${_InitializeDotNetCli} dotnetPath=${__dotnetDir}/dotnet ${dotnetPath} "$@" diff --git a/eng/common/internal-feed-operations.sh b/eng/common/internal-feed-operations.sh index 9378223ba09..6299e7effd4 100755 --- a/eng/common/internal-feed-operations.sh +++ b/eng/common/internal-feed-operations.sh @@ -100,7 +100,7 @@ operation='' authToken='' repoName='' -while [[ $# > 0 ]]; do +while [[ $# -gt 0 ]]; do opt="$(echo "$1" | tr "[:upper:]" "[:lower:]")" case "$opt" in --operation) diff --git a/eng/common/native/install-dependencies.sh b/eng/common/native/install-dependencies.sh index 477a44f335b..64b87d0bcc3 100644 --- a/eng/common/native/install-dependencies.sh +++ b/eng/common/native/install-dependencies.sh @@ -27,9 +27,11 @@ case "$os" in libssl-dev libkrb5-dev pigz cpio localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 - elif [ "$ID" = "fedora" ] || [ "$ID" = "rhel" ] || [ "$ID" = "azurelinux" ]; then + elif [ "$ID" = "fedora" ] || [ "$ID" = "rhel" ] || [ "$ID" = "azurelinux" ] || [ "$ID" = "centos"]; then pkg_mgr="$(command -v tdnf 2>/dev/null || command -v dnf)" $pkg_mgr install -y cmake llvm lld lldb clang python curl libicu-devel openssl-devel krb5-devel lttng-ust-devel pigz cpio + elif [ "$ID" = "amzn" ]; then + dnf install -y cmake llvm lld lldb clang python libicu-devel openssl-devel krb5-devel lttng-ust-devel pigz cpio elif [ "$ID" = "alpine" ]; then apk add build-base cmake bash curl clang llvm-dev lld lldb krb5-dev lttng-ust-dev icu-dev openssl-dev pigz cpio else diff --git a/eng/common/post-build/redact-logs.ps1 b/eng/common/post-build/redact-logs.ps1 index 472d5bb562c..fc0218a013d 100644 --- a/eng/common/post-build/redact-logs.ps1 +++ b/eng/common/post-build/redact-logs.ps1 @@ -9,7 +9,8 @@ param( [Parameter(Mandatory=$false)][string] $TokensFilePath, [Parameter(ValueFromRemainingArguments=$true)][String[]]$TokensToRedact, [Parameter(Mandatory=$false)][string] $runtimeSourceFeed, - [Parameter(Mandatory=$false)][string] $runtimeSourceFeedKey) + [Parameter(Mandatory=$false)][string] $runtimeSourceFeedKey +) try { $ErrorActionPreference = 'Stop' diff --git a/eng/common/templates/variables/pool-providers.yml b/eng/common/templates/variables/pool-providers.yml index 18693ea120d..e0b19c14a07 100644 --- a/eng/common/templates/variables/pool-providers.yml +++ b/eng/common/templates/variables/pool-providers.yml @@ -23,7 +23,7 @@ # # pool: # name: $(DncEngInternalBuildPool) -# demands: ImageOverride -equals windows.vs2022.amd64 +# demands: ImageOverride -equals windows.vs2019.amd64 variables: - ${{ if eq(variables['System.TeamProject'], 'internal') }}: - template: /eng/common/templates-official/variables/pool-providers.yml diff --git a/eng/common/tools.ps1 b/eng/common/tools.ps1 index 049fe6db994..e8e9f7615f1 100644 --- a/eng/common/tools.ps1 +++ b/eng/common/tools.ps1 @@ -157,9 +157,6 @@ function InitializeDotNetCli([bool]$install, [bool]$createSdkLocationFile) { return $global:_DotNetInstallDir } - # Don't resolve runtime, shared framework, or SDK from other locations to ensure build determinism - $env:DOTNET_MULTILEVEL_LOOKUP=0 - # Disable first run since we do not need all ASP.NET packages restored. $env:DOTNET_NOLOGO=1 @@ -225,7 +222,6 @@ function InitializeDotNetCli([bool]$install, [bool]$createSdkLocationFile) { # Make Sure that our bootstrapped dotnet cli is available in future steps of the Azure Pipelines build Write-PipelinePrependPath -Path $dotnetRoot - Write-PipelineSetVariable -Name 'DOTNET_MULTILEVEL_LOOKUP' -Value '0' Write-PipelineSetVariable -Name 'DOTNET_NOLOGO' -Value '1' return $global:_DotNetInstallDir = $dotnetRoot @@ -560,26 +556,19 @@ function LocateVisualStudio([object]$vsRequirements = $null){ }) } - if (!$vsRequirements) { - if (Get-Member -InputObject $GlobalJson.tools -Name 'vs' -ErrorAction SilentlyContinue) { - $vsRequirements = $GlobalJson.tools.vs - } else { - $vsRequirements = $null - } - } - + if (!$vsRequirements) { $vsRequirements = $GlobalJson.tools.vs } $args = @('-latest', '-format', 'json', '-requires', 'Microsoft.Component.MSBuild', '-products', '*') if (!$excludePrereleaseVS) { $args += '-prerelease' } - if ($vsRequirements -and (Get-Member -InputObject $vsRequirements -Name 'version' -ErrorAction SilentlyContinue)) { + if (Get-Member -InputObject $vsRequirements -Name 'version') { $args += '-version' $args += $vsRequirements.version } - if ($vsRequirements -and (Get-Member -InputObject $vsRequirements -Name 'components' -ErrorAction SilentlyContinue)) { + if (Get-Member -InputObject $vsRequirements -Name 'components') { foreach ($component in $vsRequirements.components) { $args += '-requires' $args += $component diff --git a/eng/common/tools.sh b/eng/common/tools.sh index c1841c9dfd0..6c121300ac7 100755 --- a/eng/common/tools.sh +++ b/eng/common/tools.sh @@ -115,9 +115,6 @@ function InitializeDotNetCli { local install=$1 - # Don't resolve runtime, shared framework, or SDK from other locations to ensure build determinism - export DOTNET_MULTILEVEL_LOOKUP=0 - # Disable first run since we want to control all package sources export DOTNET_NOLOGO=1 @@ -166,7 +163,6 @@ function InitializeDotNetCli { # build steps from using anything other than what we've downloaded. Write-PipelinePrependPath -path "$dotnet_root" - Write-PipelineSetVariable -name "DOTNET_MULTILEVEL_LOOKUP" -value "0" Write-PipelineSetVariable -name "DOTNET_NOLOGO" -value "1" # return value diff --git a/eng/restore-toolset.sh b/eng/restore-toolset.sh index cdcf18f1d19..8a7bb526c06 100644 --- a/eng/restore-toolset.sh +++ b/eng/restore-toolset.sh @@ -3,7 +3,7 @@ # Install MAUI workload if -restoreMaui was passed # Only on macOS (MAUI doesn't support Linux, Windows uses .cmd) -if [[ "${restore_maui:-false}" == true ]]; then +if [[ "$restore_maui" == true ]]; then # Check if we're on macOS if [[ "$(uname -s)" == "Darwin" ]]; then echo "" diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index bbe98c2cc15..73088ed32fb 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -10,9 +10,6 @@ Aspire CLI Version: {0}. - - Aspire CLI found at {0}. The extension will use this path. - Aspire CLI is not available on PATH. Please install it and restart VS Code. diff --git a/extension/package.nls.json b/extension/package.nls.json index 03c1794715e..75e0719f912 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -93,7 +93,6 @@ "aspire-vscode.strings.lookingForDevkitBuildTask": "C# Dev Kit is installed, looking for C# Dev Kit build task...", "aspire-vscode.strings.csharpDevKitNotInstalled": "C# Dev Kit is not installed, building using dotnet CLI...", "aspire-vscode.strings.cliNotAvailable": "Aspire CLI is not available on PATH. Please install it and restart VS Code.", - "aspire-vscode.strings.cliFoundAtDefaultPath": "Aspire CLI found at {0}. The extension will use this path.", "aspire-vscode.strings.openCliInstallInstructions": "See CLI installation instructions", "aspire-vscode.strings.dismissLabel": "Dismiss" } diff --git a/extension/src/commands/add.ts b/extension/src/commands/add.ts index e1e158d7b4b..5d8bd3307a7 100644 --- a/extension/src/commands/add.ts +++ b/extension/src/commands/add.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function addCommand(terminalProvider: AspireTerminalProvider) { - await terminalProvider.sendAspireCommandToAspireTerminal('add'); + terminalProvider.sendAspireCommandToAspireTerminal('add'); } diff --git a/extension/src/commands/deploy.ts b/extension/src/commands/deploy.ts index 057d419f6ca..a40590e1891 100644 --- a/extension/src/commands/deploy.ts +++ b/extension/src/commands/deploy.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function deployCommand(terminalProvider: AspireTerminalProvider) { - await terminalProvider.sendAspireCommandToAspireTerminal('deploy'); + terminalProvider.sendAspireCommandToAspireTerminal('deploy'); } diff --git a/extension/src/commands/init.ts b/extension/src/commands/init.ts index 3d6c60e25d9..642bfa23aa3 100644 --- a/extension/src/commands/init.ts +++ b/extension/src/commands/init.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from "../utils/AspireTerminalProvider"; export async function initCommand(terminalProvider: AspireTerminalProvider) { - await terminalProvider.sendAspireCommandToAspireTerminal('init'); + terminalProvider.sendAspireCommandToAspireTerminal('init'); }; \ No newline at end of file diff --git a/extension/src/commands/new.ts b/extension/src/commands/new.ts index ab2936e0af3..d8a26eab433 100644 --- a/extension/src/commands/new.ts +++ b/extension/src/commands/new.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from "../utils/AspireTerminalProvider"; export async function newCommand(terminalProvider: AspireTerminalProvider) { - await terminalProvider.sendAspireCommandToAspireTerminal('new'); + terminalProvider.sendAspireCommandToAspireTerminal('new'); }; diff --git a/extension/src/commands/publish.ts b/extension/src/commands/publish.ts index 276ea03a7a8..181d590337a 100644 --- a/extension/src/commands/publish.ts +++ b/extension/src/commands/publish.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function publishCommand(terminalProvider: AspireTerminalProvider) { - await terminalProvider.sendAspireCommandToAspireTerminal('publish'); + terminalProvider.sendAspireCommandToAspireTerminal('publish'); } diff --git a/extension/src/commands/update.ts b/extension/src/commands/update.ts index 23e8070920e..31ab5b9f89e 100644 --- a/extension/src/commands/update.ts +++ b/extension/src/commands/update.ts @@ -1,5 +1,5 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; export async function updateCommand(terminalProvider: AspireTerminalProvider) { - await terminalProvider.sendAspireCommandToAspireTerminal('update'); + terminalProvider.sendAspireCommandToAspireTerminal('update'); } diff --git a/extension/src/debugger/AspireDebugConfigurationProvider.ts b/extension/src/debugger/AspireDebugConfigurationProvider.ts index 643db6ed958..ba4c8d98c14 100644 --- a/extension/src/debugger/AspireDebugConfigurationProvider.ts +++ b/extension/src/debugger/AspireDebugConfigurationProvider.ts @@ -1,8 +1,15 @@ import * as vscode from 'vscode'; import { defaultConfigurationName } from '../loc/strings'; +import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; import { checkCliAvailableOrRedirect } from '../utils/workspace'; export class AspireDebugConfigurationProvider implements vscode.DebugConfigurationProvider { + private _terminalProvider: AspireTerminalProvider; + + constructor(terminalProvider: AspireTerminalProvider) { + this._terminalProvider = terminalProvider; + } + async provideDebugConfigurations(folder: vscode.WorkspaceFolder | undefined, token?: vscode.CancellationToken): Promise { if (folder === undefined) { return []; @@ -21,8 +28,9 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise { // Check if CLI is available before starting debug session - const result = await checkCliAvailableOrRedirect(); - if (!result.available) { + const cliPath = this._terminalProvider.getAspireCliExecutablePath(); + const isCliAvailable = await checkCliAvailableOrRedirect(cliPath); + if (!isCliAvailable) { return undefined; // Cancel the debug session } diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts index 293beade0d7..bc35aceeb6c 100644 --- a/extension/src/debugger/AspireDebugSession.ts +++ b/extension/src/debugger/AspireDebugSession.ts @@ -93,14 +93,14 @@ export class AspireDebugSession implements vscode.DebugAdapter { if (isDirectory(appHostPath)) { this.sendMessageWithEmoji("📁", launchingWithDirectory(appHostPath)); - void this.spawnRunCommand(args, appHostPath, noDebug); + this.spawnRunCommand(args, appHostPath, noDebug); } else { this.sendMessageWithEmoji("📂", launchingWithAppHost(appHostPath)); const workspaceFolder = path.dirname(appHostPath); args.push('--project', appHostPath); - void this.spawnRunCommand(args, workspaceFolder, noDebug); + this.spawnRunCommand(args, workspaceFolder, noDebug); } } else if (message.command === 'disconnect' || message.command === 'terminate') { @@ -133,7 +133,7 @@ export class AspireDebugSession implements vscode.DebugAdapter { } } - async spawnRunCommand(args: string[], workingDirectory: string | undefined, noDebug: boolean) { + spawnRunCommand(args: string[], workingDirectory: string | undefined, noDebug: boolean) { const disposable = this._rpcServer.onNewConnection((client: ICliRpcClient) => { if (client.debugSessionId === this.debugSessionId) { this._rpcClient = client; @@ -143,7 +143,7 @@ export class AspireDebugSession implements vscode.DebugAdapter { spawnCliProcess( this._terminalProvider, - await this._terminalProvider.getAspireCliExecutablePath(), + this._terminalProvider.getAspireCliExecutablePath(), args, { stdoutCallback: (data) => { diff --git a/extension/src/extension.ts b/extension/src/extension.ts index de001575696..f2e2c44f8eb 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -67,7 +67,7 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(cliAddCommandRegistration, cliNewCommandRegistration, cliInitCommandRegistration, cliDeployCommandRegistration, cliPublishCommandRegistration, openTerminalCommandRegistration, configureLaunchJsonCommandRegistration); context.subscriptions.push(cliUpdateCommandRegistration, settingsCommandRegistration, openLocalSettingsCommandRegistration, openGlobalSettingsCommandRegistration, runAppHostCommandRegistration, debugAppHostCommandRegistration); - const debugConfigProvider = new AspireDebugConfigurationProvider(); + const debugConfigProvider = new AspireDebugConfigurationProvider(terminalProvider); context.subscriptions.push( vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Dynamic) ); @@ -114,8 +114,9 @@ async function tryExecuteCommand(commandName: string, terminalProvider: AspireTe const cliCheckExcludedCommands: string[] = ["aspire-vscode.settings", "aspire-vscode.configureLaunchJson"]; if (!cliCheckExcludedCommands.includes(commandName)) { - const result = await checkCliAvailableOrRedirect(); - if (!result.available) { + const cliPath = terminalProvider.getAspireCliExecutablePath(); + const isCliAvailable = await checkCliAvailableOrRedirect(cliPath); + if (!isCliAvailable) { return; } } diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 1b02e953ff7..484ca92ec30 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -71,4 +71,3 @@ export const csharpDevKitNotInstalled = vscode.l10n.t('C# Dev Kit is not install export const dismissLabel = vscode.l10n.t('Dismiss'); export const openCliInstallInstructions = vscode.l10n.t('See CLI installation instructions'); export const cliNotAvailable = vscode.l10n.t('Aspire CLI is not available on PATH. Please install it and restart VS Code.'); -export const cliFoundAtDefaultPath = (path: string) => vscode.l10n.t('Aspire CLI found at {0}. The extension will use this path.', path); diff --git a/extension/src/test/aspireTerminalProvider.test.ts b/extension/src/test/aspireTerminalProvider.test.ts index fa139b51715..dc70ca4c3fb 100644 --- a/extension/src/test/aspireTerminalProvider.test.ts +++ b/extension/src/test/aspireTerminalProvider.test.ts @@ -2,58 +2,94 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import * as sinon from 'sinon'; import { AspireTerminalProvider } from '../utils/AspireTerminalProvider'; -import * as cliPathModule from '../utils/cliPath'; suite('AspireTerminalProvider tests', () => { let terminalProvider: AspireTerminalProvider; - let resolveCliPathStub: sinon.SinonStub; + let configStub: sinon.SinonStub; let subscriptions: vscode.Disposable[]; setup(() => { subscriptions = []; terminalProvider = new AspireTerminalProvider(subscriptions); - resolveCliPathStub = sinon.stub(cliPathModule, 'resolveCliPath'); + configStub = sinon.stub(vscode.workspace, 'getConfiguration'); }); teardown(() => { - resolveCliPathStub.restore(); + configStub.restore(); subscriptions.forEach(s => s.dispose()); }); suite('getAspireCliExecutablePath', () => { - test('returns "aspire" when CLI is on PATH', async () => { - resolveCliPathStub.resolves({ cliPath: 'aspire', available: true, source: 'path' }); + test('returns "aspire" when no custom path is configured', () => { + configStub.returns({ + get: sinon.stub().returns('') + }); - const result = await terminalProvider.getAspireCliExecutablePath(); + const result = terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, 'aspire'); }); - test('returns resolved path when CLI found at default install location', async () => { - resolveCliPathStub.resolves({ cliPath: '/home/user/.aspire/bin/aspire', available: true, source: 'default-install' }); + test('returns custom path when configured', () => { + configStub.returns({ + get: sinon.stub().returns('/usr/local/bin/aspire') + }); - const result = await terminalProvider.getAspireCliExecutablePath(); - assert.strictEqual(result, '/home/user/.aspire/bin/aspire'); + const result = terminalProvider.getAspireCliExecutablePath(); + assert.strictEqual(result, '/usr/local/bin/aspire'); }); - test('returns configured custom path', async () => { - resolveCliPathStub.resolves({ cliPath: '/usr/local/bin/aspire', available: true, source: 'configured' }); + test('returns custom path with spaces', () => { + configStub.returns({ + get: sinon.stub().returns('/my path/with spaces/aspire') + }); + + const result = terminalProvider.getAspireCliExecutablePath(); + assert.strictEqual(result, '/my path/with spaces/aspire'); + }); - const result = await terminalProvider.getAspireCliExecutablePath(); + test('trims whitespace from configured path', () => { + configStub.returns({ + get: sinon.stub().returns(' /usr/local/bin/aspire ') + }); + + const result = terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, '/usr/local/bin/aspire'); }); - test('returns "aspire" when CLI is not found', async () => { - resolveCliPathStub.resolves({ cliPath: 'aspire', available: false, source: 'not-found' }); + test('returns "aspire" when configured path is only whitespace', () => { + configStub.returns({ + get: sinon.stub().returns(' ') + }); - const result = await terminalProvider.getAspireCliExecutablePath(); + const result = terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, 'aspire'); }); - test('handles Windows-style paths', async () => { - resolveCliPathStub.resolves({ cliPath: 'C:\\Program Files\\Aspire\\aspire.exe', available: true, source: 'configured' }); + test('handles Windows-style paths', () => { + configStub.returns({ + get: sinon.stub().returns('C:\\Program Files\\Aspire\\aspire.exe') + }); - const result = await terminalProvider.getAspireCliExecutablePath(); + const result = terminalProvider.getAspireCliExecutablePath(); assert.strictEqual(result, 'C:\\Program Files\\Aspire\\aspire.exe'); }); + + test('handles Windows-style paths without spaces', () => { + configStub.returns({ + get: sinon.stub().returns('C:\\aspire\\aspire.exe') + }); + + const result = terminalProvider.getAspireCliExecutablePath(); + assert.strictEqual(result, 'C:\\aspire\\aspire.exe'); + }); + + test('handles paths with special characters', () => { + configStub.returns({ + get: sinon.stub().returns('/path/with$dollar/aspire') + }); + + const result = terminalProvider.getAspireCliExecutablePath(); + assert.strictEqual(result, '/path/with$dollar/aspire'); + }); }); }); diff --git a/extension/src/test/cliPath.test.ts b/extension/src/test/cliPath.test.ts deleted file mode 100644 index e70519b3ebe..00000000000 --- a/extension/src/test/cliPath.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import * as assert from 'assert'; -import * as sinon from 'sinon'; -import * as os from 'os'; -import * as path from 'path'; -import { getDefaultCliInstallPaths, resolveCliPath, CliPathDependencies } from '../utils/cliPath'; - -const bundlePath = '/home/user/.aspire/bin/aspire'; -const globalToolPath = '/home/user/.dotnet/tools/aspire'; -const defaultPaths = [bundlePath, globalToolPath]; - -function createMockDeps(overrides: Partial = {}): CliPathDependencies { - return { - getConfiguredPath: () => '', - getDefaultPaths: () => defaultPaths, - isOnPath: async () => false, - findAtDefaultPath: async () => undefined, - tryExecute: async () => false, - setConfiguredPath: async () => {}, - ...overrides, - }; -} - -suite('utils/cliPath tests', () => { - - suite('getDefaultCliInstallPaths', () => { - test('returns bundle path (~/.aspire/bin) as first entry', () => { - const paths = getDefaultCliInstallPaths(); - const homeDir = os.homedir(); - - assert.ok(paths.length >= 2, 'Should return at least 2 default paths'); - assert.ok(paths[0].startsWith(path.join(homeDir, '.aspire', 'bin')), `First path should be bundle install: ${paths[0]}`); - }); - - test('returns global tool path (~/.dotnet/tools) as second entry', () => { - const paths = getDefaultCliInstallPaths(); - const homeDir = os.homedir(); - - assert.ok(paths[1].startsWith(path.join(homeDir, '.dotnet', 'tools')), `Second path should be global tool: ${paths[1]}`); - }); - - test('uses correct executable name for current platform', () => { - const paths = getDefaultCliInstallPaths(); - - for (const p of paths) { - const basename = path.basename(p); - if (process.platform === 'win32') { - assert.strictEqual(basename, 'aspire.exe'); - } else { - assert.strictEqual(basename, 'aspire'); - } - } - }); - }); - - suite('resolveCliPath', () => { - test('falls back to default install path when CLI is not on PATH', async () => { - const setConfiguredPath = sinon.stub().resolves(); - - const deps = createMockDeps({ - isOnPath: async () => false, - findAtDefaultPath: async () => bundlePath, - setConfiguredPath, - }); - - const result = await resolveCliPath(deps); - - assert.strictEqual(result.available, true); - assert.strictEqual(result.source, 'default-install'); - assert.strictEqual(result.cliPath, bundlePath); - assert.ok(setConfiguredPath.calledOnceWith(bundlePath), 'should update the VS Code setting to the found path'); - }); - - test('updates VS Code setting when CLI found at default path but not on PATH', async () => { - const setConfiguredPath = sinon.stub().resolves(); - - const deps = createMockDeps({ - getConfiguredPath: () => '', - isOnPath: async () => false, - findAtDefaultPath: async () => bundlePath, - setConfiguredPath, - }); - - await resolveCliPath(deps); - - assert.ok(setConfiguredPath.calledOnce, 'setConfiguredPath should be called once'); - assert.strictEqual(setConfiguredPath.firstCall.args[0], bundlePath, 'should set the path to the found install location'); - }); - - test('prefers PATH over default install path', async () => { - const setConfiguredPath = sinon.stub().resolves(); - - const deps = createMockDeps({ - isOnPath: async () => true, - findAtDefaultPath: async () => bundlePath, - setConfiguredPath, - }); - - const result = await resolveCliPath(deps); - - assert.strictEqual(result.available, true); - assert.strictEqual(result.source, 'path'); - assert.strictEqual(result.cliPath, 'aspire'); - assert.ok(setConfiguredPath.notCalled, 'should not update settings when CLI is on PATH'); - }); - - test('clears setting when CLI is on PATH and setting was previously set to a default path', async () => { - const setConfiguredPath = sinon.stub().resolves(); - - const deps = createMockDeps({ - getConfiguredPath: () => bundlePath, - isOnPath: async () => true, - setConfiguredPath, - }); - - const result = await resolveCliPath(deps); - - assert.strictEqual(result.source, 'path'); - assert.ok(setConfiguredPath.calledOnceWith(''), 'should clear the setting'); - }); - - test('clears setting when CLI is on PATH and setting was previously set to global tool path', async () => { - const setConfiguredPath = sinon.stub().resolves(); - - const deps = createMockDeps({ - getConfiguredPath: () => globalToolPath, - isOnPath: async () => true, - setConfiguredPath, - }); - - const result = await resolveCliPath(deps); - - assert.strictEqual(result.source, 'path'); - assert.ok(setConfiguredPath.calledOnceWith(''), 'should clear the setting'); - }); - - test('returns not-found when CLI is not on PATH and not at any default path', async () => { - const deps = createMockDeps({ - isOnPath: async () => false, - findAtDefaultPath: async () => undefined, - }); - - const result = await resolveCliPath(deps); - - assert.strictEqual(result.available, false); - assert.strictEqual(result.source, 'not-found'); - }); - - test('uses custom configured path when valid and not a default', async () => { - const customPath = '/custom/path/aspire'; - - const deps = createMockDeps({ - getConfiguredPath: () => customPath, - tryExecute: async (p) => p === customPath, - }); - - const result = await resolveCliPath(deps); - - assert.strictEqual(result.available, true); - assert.strictEqual(result.source, 'configured'); - assert.strictEqual(result.cliPath, customPath); - }); - - test('falls through to PATH check when custom configured path is invalid', async () => { - const deps = createMockDeps({ - getConfiguredPath: () => '/bad/path/aspire', - tryExecute: async () => false, - isOnPath: async () => true, - }); - - const result = await resolveCliPath(deps); - - assert.strictEqual(result.source, 'path'); - assert.strictEqual(result.available, true); - }); - - test('falls through to default path when custom configured path is invalid and not on PATH', async () => { - const setConfiguredPath = sinon.stub().resolves(); - - const deps = createMockDeps({ - getConfiguredPath: () => '/bad/path/aspire', - tryExecute: async () => false, - isOnPath: async () => false, - findAtDefaultPath: async () => bundlePath, - setConfiguredPath, - }); - - const result = await resolveCliPath(deps); - - assert.strictEqual(result.source, 'default-install'); - assert.strictEqual(result.cliPath, bundlePath); - assert.ok(setConfiguredPath.calledOnceWith(bundlePath)); - }); - - test('does not update setting when already set to the found default path', async () => { - const setConfiguredPath = sinon.stub().resolves(); - - const deps = createMockDeps({ - getConfiguredPath: () => bundlePath, - isOnPath: async () => false, - findAtDefaultPath: async () => bundlePath, - setConfiguredPath, - }); - - const result = await resolveCliPath(deps); - - assert.strictEqual(result.source, 'default-install'); - assert.ok(setConfiguredPath.notCalled, 'should not re-set the path if it already matches'); - }); - }); -}); - diff --git a/extension/src/utils/AspireTerminalProvider.ts b/extension/src/utils/AspireTerminalProvider.ts index 95ed6bf5426..35762287729 100644 --- a/extension/src/utils/AspireTerminalProvider.ts +++ b/extension/src/utils/AspireTerminalProvider.ts @@ -5,7 +5,6 @@ import { RpcServerConnectionInfo } from '../server/AspireRpcServer'; import { DcpServerConnectionInfo } from '../dcp/types'; import { getRunSessionInfo, getSupportedCapabilities } from '../capabilities'; import { EnvironmentVariables } from './environment'; -import { resolveCliPath } from './cliPath'; import path from 'path'; export const enum AnsiColors { @@ -58,8 +57,8 @@ export class AspireTerminalProvider implements vscode.Disposable { this._dcpServerConnectionInfo = value; } - async sendAspireCommandToAspireTerminal(subcommand: string, showTerminal: boolean = true) { - const cliPath = await this.getAspireCliExecutablePath(); + sendAspireCommandToAspireTerminal(subcommand: string, showTerminal: boolean = true) { + const cliPath = this.getAspireCliExecutablePath(); // On Windows, use & to execute paths, especially those with special characters // On Unix, just use the path directly @@ -201,9 +200,15 @@ export class AspireTerminalProvider implements vscode.Disposable { } - async getAspireCliExecutablePath(): Promise { - const result = await resolveCliPath(); - return result.cliPath; + getAspireCliExecutablePath(): string { + const aspireCliPath = vscode.workspace.getConfiguration('aspire').get('aspireCliExecutablePath', ''); + if (aspireCliPath && aspireCliPath.trim().length > 0) { + extensionLogOutputChannel.debug(`Using user-configured Aspire CLI path: ${aspireCliPath}`); + return aspireCliPath.trim(); + } + + extensionLogOutputChannel.debug('No user-configured Aspire CLI path found'); + return "aspire"; } isCliDebugLoggingEnabled(): boolean { diff --git a/extension/src/utils/cliPath.ts b/extension/src/utils/cliPath.ts deleted file mode 100644 index 6290ac6d945..00000000000 --- a/extension/src/utils/cliPath.ts +++ /dev/null @@ -1,194 +0,0 @@ -import * as vscode from 'vscode'; -import { execFile } from 'child_process'; -import { promisify } from 'util'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { extensionLogOutputChannel } from './logging'; - -const execFileAsync = promisify(execFile); -const fsAccessAsync = promisify(fs.access); - -/** - * Gets the default installation paths for the Aspire CLI, in priority order. - * - * The CLI can be installed in two ways: - * 1. Bundle install (recommended): ~/.aspire/bin/aspire - * 2. .NET global tool: ~/.dotnet/tools/aspire - * - * @returns An array of default CLI paths to check, ordered by priority - */ -export function getDefaultCliInstallPaths(): string[] { - const homeDir = os.homedir(); - const exeName = process.platform === 'win32' ? 'aspire.exe' : 'aspire'; - - return [ - // Bundle install (recommended): ~/.aspire/bin/aspire - path.join(homeDir, '.aspire', 'bin', exeName), - // .NET global tool: ~/.dotnet/tools/aspire - path.join(homeDir, '.dotnet', 'tools', exeName), - ]; -} - -/** - * Checks if a file exists and is accessible. - */ -async function fileExists(filePath: string): Promise { - try { - await fsAccessAsync(filePath, fs.constants.F_OK); - return true; - } - catch { - return false; - } -} - -/** - * Tries to execute the CLI at the given path to verify it works. - */ -async function tryExecuteCli(cliPath: string): Promise { - try { - await execFileAsync(cliPath, ['--version'], { timeout: 5000 }); - return true; - } - catch { - return false; - } -} - -/** - * Checks if the Aspire CLI is available on the system PATH. - */ -export async function isCliOnPath(): Promise { - return await tryExecuteCli('aspire'); -} - -/** - * Finds the first default installation path where the Aspire CLI exists and is executable. - * - * @returns The path where CLI was found, or undefined if not found at any default location - */ -export async function findCliAtDefaultPath(): Promise { - for (const defaultPath of getDefaultCliInstallPaths()) { - if (await fileExists(defaultPath) && await tryExecuteCli(defaultPath)) { - return defaultPath; - } - } - - return undefined; -} - -/** - * Gets the VS Code configuration setting for the Aspire CLI path. - */ -export function getConfiguredCliPath(): string { - return vscode.workspace.getConfiguration('aspire').get('aspireCliExecutablePath', '').trim(); -} - -/** - * Updates the VS Code configuration setting for the Aspire CLI path. - * Uses ConfigurationTarget.Global to set it at the user level. - */ -export async function setConfiguredCliPath(cliPath: string): Promise { - extensionLogOutputChannel.info(`Setting aspire.aspireCliExecutablePath to: ${cliPath || '(empty)'}`); - await vscode.workspace.getConfiguration('aspire').update( - 'aspireCliExecutablePath', - cliPath || undefined, // Use undefined to remove the setting - vscode.ConfigurationTarget.Global - ); -} - -/** - * Result of checking CLI availability. - */ -export interface CliPathResolutionResult { - /** The resolved CLI path to use */ - cliPath: string; - /** Whether the CLI is available */ - available: boolean; - /** Where the CLI was found */ - source: 'path' | 'default-install' | 'configured' | 'not-found'; -} - -/** - * Dependencies for resolveCliPath that can be overridden for testing. - */ -export interface CliPathDependencies { - getConfiguredPath: () => string; - getDefaultPaths: () => string[]; - isOnPath: () => Promise; - findAtDefaultPath: () => Promise; - tryExecute: (cliPath: string) => Promise; - setConfiguredPath: (cliPath: string) => Promise; -} - -const defaultDependencies: CliPathDependencies = { - getConfiguredPath: getConfiguredCliPath, - getDefaultPaths: getDefaultCliInstallPaths, - isOnPath: isCliOnPath, - findAtDefaultPath: findCliAtDefaultPath, - tryExecute: tryExecuteCli, - setConfiguredPath: setConfiguredCliPath, -}; - -/** - * Resolves the Aspire CLI path, checking multiple locations in order: - * 1. User-configured path in VS Code settings - * 2. System PATH - * 3. Default installation directories (~/.aspire/bin, ~/.dotnet/tools) - * - * If the CLI is found at a default installation path but not on PATH, - * the VS Code setting is updated to use that path. - * - * If the CLI is on PATH and a setting was previously auto-configured to a default path, - * the setting is cleared to prefer PATH. - */ -export async function resolveCliPath(deps: CliPathDependencies = defaultDependencies): Promise { - const configuredPath = deps.getConfiguredPath(); - const defaultPaths = deps.getDefaultPaths(); - - // 1. Check if user has configured a custom path (not one of the defaults) - if (configuredPath && !defaultPaths.includes(configuredPath)) { - const isValid = await deps.tryExecute(configuredPath); - if (isValid) { - extensionLogOutputChannel.info(`Using user-configured Aspire CLI path: ${configuredPath}`); - return { cliPath: configuredPath, available: true, source: 'configured' }; - } - - extensionLogOutputChannel.warn(`Configured CLI path is invalid: ${configuredPath}`); - // Continue to check other locations - } - - // 2. Check if CLI is on PATH - const onPath = await deps.isOnPath(); - if (onPath) { - extensionLogOutputChannel.info('Aspire CLI found on system PATH'); - - // If we previously auto-set the path to a default install location, clear it - // since PATH is now working - if (defaultPaths.includes(configuredPath)) { - extensionLogOutputChannel.info('Clearing aspireCliExecutablePath setting since CLI is on PATH'); - await deps.setConfiguredPath(''); - } - - return { cliPath: 'aspire', available: true, source: 'path' }; - } - - // 3. Check default installation paths (~/.aspire/bin first, then ~/.dotnet/tools) - const foundPath = await deps.findAtDefaultPath(); - if (foundPath) { - extensionLogOutputChannel.info(`Aspire CLI found at default install location: ${foundPath}`); - - // Update the setting so future invocations use this path - if (configuredPath !== foundPath) { - extensionLogOutputChannel.info('Updating aspireCliExecutablePath setting to use default install location'); - await deps.setConfiguredPath(foundPath); - } - - return { cliPath: foundPath, available: true, source: 'default-install' }; - } - - // 4. CLI not found anywhere - extensionLogOutputChannel.warn('Aspire CLI not found on PATH or at default install locations'); - return { cliPath: 'aspire', available: false, source: 'not-found' }; -} diff --git a/extension/src/utils/configInfoProvider.ts b/extension/src/utils/configInfoProvider.ts index bd342a5feb5..ca9f4ea3c64 100644 --- a/extension/src/utils/configInfoProvider.ts +++ b/extension/src/utils/configInfoProvider.ts @@ -9,13 +9,11 @@ import * as strings from '../loc/strings'; * Gets configuration information from the Aspire CLI. */ export async function getConfigInfo(terminalProvider: AspireTerminalProvider): Promise { - const cliPath = await terminalProvider.getAspireCliExecutablePath(); - return new Promise((resolve) => { const args = ['config', 'info', '--json']; let output = ''; - spawnCliProcess(terminalProvider, cliPath, args, { + spawnCliProcess(terminalProvider, terminalProvider.getAspireCliExecutablePath(), args, { stdoutCallback: (data) => { output += data; }, diff --git a/extension/src/utils/workspace.ts b/extension/src/utils/workspace.ts index f1335aa87d4..302b11dc716 100644 --- a/extension/src/utils/workspace.ts +++ b/extension/src/utils/workspace.ts @@ -1,13 +1,13 @@ import * as vscode from 'vscode'; -import { cliNotAvailable, cliFoundAtDefaultPath, dismissLabel, dontShowAgainLabel, doYouWantToSetDefaultApphost, noLabel, noWorkspaceOpen, openCliInstallInstructions, selectDefaultLaunchApphost, yesLabel } from '../loc/strings'; +import { cliNotAvailable, 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 { ChildProcessWithoutNullStreams, execFile } from 'child_process'; import { AspireSettingsFile } from './cliTypes'; import { extensionLogOutputChannel } from './logging'; import { EnvironmentVariables } from './environment'; -import { resolveCliPath } from './cliPath'; +import { promisify } from 'util'; /** * Common file patterns to exclude from workspace file searches. @@ -158,14 +158,13 @@ export async function checkForExistingAppHostPathInWorkspace(terminalProvider: A extensionLogOutputChannel.info('Searching for AppHost projects using CLI command: aspire extension get-apphosts'); let proc: ChildProcessWithoutNullStreams; - const cliPath = await terminalProvider.getAspireCliExecutablePath(); new Promise((resolve, reject) => { const args = ['extension', 'get-apphosts']; if (process.env[EnvironmentVariables.ASPIRE_CLI_STOP_ON_ENTRY] === 'true') { args.push('--cli-wait-for-debugger'); } - proc = spawnCliProcess(terminalProvider, cliPath, args, { + proc = spawnCliProcess(terminalProvider, terminalProvider.getAspireCliExecutablePath(), args, { errorCallback: error => { extensionLogOutputChannel.error(`Error executing get-apphosts command: ${error}`); reject(); @@ -269,38 +268,44 @@ async function promptToAddAppHostPathToSettingsFile(result: AppHostProjectSearch extensionLogOutputChannel.info(`Successfully set appHostPath to: ${appHostToUse} in ${settingsFileLocation.fsPath}`); } +const execFileAsync = promisify(execFile); + +let cliAvailableOnPath: boolean | undefined = undefined; + /** - * Checks if the Aspire CLI is available. If not found on PATH, it checks the default - * installation directory and updates the VS Code setting accordingly. - * - * If not available, shows a message prompting to open Aspire CLI installation steps. - * @returns An object containing the CLI path to use and whether CLI is available + * Checks if the Aspire CLI is available. If not, shows a message prompting to open Aspire CLI installation steps on the repo. + * @param cliPath The path to the Aspire CLI executable + * @returns true if CLI is available, false otherwise */ -export async function checkCliAvailableOrRedirect(): Promise<{ cliPath: string; available: boolean }> { - // Resolve CLI path fresh each time — settings or PATH may have changed - const result = await resolveCliPath(); - - if (result.available) { - // Show informational message if CLI was found at default path (not on PATH) - if (result.source === 'default-install') { - extensionLogOutputChannel.info(`Using Aspire CLI from default install location: ${result.cliPath}`); - vscode.window.showInformationMessage(cliFoundAtDefaultPath(result.cliPath)); - } - - return { cliPath: result.cliPath, available: true }; +export async function checkCliAvailableOrRedirect(cliPath: string): Promise { + if (cliAvailableOnPath === true) { + // Assume, for now, that CLI availability does not change during the session if it was previously confirmed + return Promise.resolve(true); } - // CLI not found - show error message with install instructions - vscode.window.showErrorMessage( - cliNotAvailable, - openCliInstallInstructions, - dismissLabel - ).then(selection => { - if (selection === openCliInstallInstructions) { - // Go to Aspire CLI installation instruction page in external browser - vscode.env.openExternal(vscode.Uri.parse('https://aspire.dev/get-started/install-cli/')); + try { + // Remove surrounding quotes if present (both single and double quotes) + let cleanPath = cliPath.trim(); + if ((cleanPath.startsWith("'") && cleanPath.endsWith("'")) || + (cleanPath.startsWith('"') && cleanPath.endsWith('"'))) { + cleanPath = cleanPath.slice(1, -1); } - }); + await execFileAsync(cleanPath, ['--version'], { timeout: 5000 }); + cliAvailableOnPath = true; + return true; + } catch (error) { + cliAvailableOnPath = false; + vscode.window.showErrorMessage( + cliNotAvailable, + openCliInstallInstructions, + dismissLabel + ).then(selection => { + if (selection === openCliInstallInstructions) { + // Go to Aspire CLI installation instruction page in external browser + vscode.env.openExternal(vscode.Uri.parse('https://aspire.dev/get-started/install-cli/')); + } + }); - return { cliPath: result.cliPath, available: false }; + return false; + } } diff --git a/global.json b/global.json index 39ccee4a4d2..087505bbcae 100644 --- a/global.json +++ b/global.json @@ -33,8 +33,8 @@ "msbuild-sdks": { "Microsoft.Build.NoTargets": "3.7.0", "Microsoft.Build.Traversal": "3.2.0", - "Microsoft.DotNet.Arcade.Sdk": "10.0.0-beta.26110.1", - "Microsoft.DotNet.Helix.Sdk": "10.0.0-beta.26110.1", - "Microsoft.DotNet.SharedFramework.Sdk": "10.0.0-beta.26110.1" + "Microsoft.DotNet.Arcade.Sdk": "11.0.0-beta.25610.3", + "Microsoft.DotNet.Helix.Sdk": "11.0.0-beta.25610.3", + "Microsoft.DotNet.SharedFramework.Sdk": "11.0.0-beta.25610.3" } } diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentContext.cs index cbd4163d04c..738fb0d2ec0 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentContext.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentContext.cs @@ -8,7 +8,7 @@ namespace Aspire.Hosting.Kubernetes; internal sealed class KubernetesEnvironmentContext(KubernetesEnvironmentResource environment, ILogger logger) { - private readonly Dictionary _kubernetesComponents = new(new ResourceNameComparer()); + private readonly Dictionary _kubernetesComponents = []; public ILogger Logger => logger; diff --git a/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs index e18fa6d9492..fee7f2ccda8 100644 --- a/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DeployCommandTests.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 System.Runtime.CompilerServices; @@ -9,6 +9,7 @@ using Aspire.Cli.Tests.TestServices; using Microsoft.Extensions.DependencyInjection; using Aspire.Cli.Utils; +using Aspire.TestUtilities; using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -268,6 +269,7 @@ public async Task DeployCommandSucceedsEndToEnd() } [Fact] + [QuarantinedTest("https://github.com/dotnet/aspire/issues/11217")] public async Task DeployCommandIncludesDeployFlagInArguments() { using var tempRepo = TemporaryWorkspace.Create(outputHelper); diff --git a/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs index e46e24a6026..85dc01e97f3 100644 --- a/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ExtensionInternalCommandTests.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 System.Text.Json; @@ -7,6 +7,7 @@ using Aspire.Cli.Projects; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; +using Aspire.TestUtilities; using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Commands; @@ -54,6 +55,7 @@ public async Task ExtensionInternalCommand_WithNoSubcommand_ReturnsZero() } [Fact] + [QuarantinedTest("https://github.com/dotnet/aspire/issues/12304")] public async Task GetAppHostsCommand_WithSingleProject_ReturnsSuccessWithValidJson() { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -95,6 +97,7 @@ public async Task GetAppHostsCommand_WithSingleProject_ReturnsSuccessWithValidJs } [Fact] + [QuarantinedTest("https://github.com/dotnet/aspire/issues/12300")] public async Task GetAppHostsCommand_WithMultipleProjects_ReturnsSuccessWithAllCandidates() { using var workspace = TemporaryWorkspace.Create(outputHelper); diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt index 04e5588f32e..0214785534c 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt @@ -29,26 +29,26 @@ Steps with no dependencies run first, followed by steps that depend on them. 13. login-to-acr-aca-env-acr 14. push-prereq 15. push-api-service - 16. provision-api-service-website - 17. print-api-service-summary - 18. provision-aca-env - 19. provision-cache-containerapp - 20. print-cache-summary - 21. push-python-app - 22. provision-python-app-containerapp - 23. provision-storage - 24. provision-azure-bicep-resources - 25. print-dashboard-url-aas-env - 26. print-dashboard-url-aca-env - 27. print-python-app-summary - 28. deploy - 29. deploy-api-service - 30. deploy-cache - 31. deploy-python-app - 32. diagnostics - 33. publish-prereq - 34. publish-azure634f9 - 35. validate-appservice-config-aas-env + 16. update-api-service-provisionable-resource + 17. provision-api-service-website + 18. print-api-service-summary + 19. provision-aca-env + 20. provision-cache-containerapp + 21. print-cache-summary + 22. push-python-app + 23. provision-python-app-containerapp + 24. provision-storage + 25. provision-azure-bicep-resources + 26. print-dashboard-url-aas-env + 27. print-dashboard-url-aca-env + 28. print-python-app-summary + 29. deploy + 30. deploy-api-service + 31. deploy-cache + 32. deploy-python-app + 33. diagnostics + 34. publish-prereq + 35. publish-azure634f9 36. publish 37. publish-manifest 38. push @@ -182,7 +182,7 @@ Step: provision-aca-env-acr Step: provision-api-service-website Description: Provisions the Azure Bicep resource api-service-website using Azure infrastructure. - Dependencies: ✓ create-provisioning-context, ✓ provision-aas-env, ✓ push-api-service + Dependencies: ✓ create-provisioning-context, ✓ provision-aas-env, ✓ push-api-service, ✓ update-api-service-provisionable-resource Resource: api-service-website (AzureAppServiceWebSiteResource) Tags: provision-infra @@ -212,7 +212,7 @@ Step: provision-storage Step: publish Description: Aggregation step for all publish operations. All publish steps should be required by this step. - Dependencies: ✓ publish-azure634f9, ✓ validate-appservice-config-aas-env + Dependencies: ✓ publish-azure634f9 Step: publish-azure634f9 Description: Publishes the Azure environment configuration for azure634f9. @@ -245,10 +245,10 @@ Step: push-python-app Resource: python-app (ContainerResource) Tags: push-container-image -Step: validate-appservice-config-aas-env - Description: Validates Azure App Service configuration for aas-env. - Dependencies: ✓ publish-prereq - Resource: aas-env (AzureAppServiceEnvironmentResource) +Step: update-api-service-provisionable-resource + Dependencies: ✓ create-provisioning-context + Resource: api-service-website (AzureAppServiceWebSiteResource) + Tags: update-website-provisionable-resource Step: validate-azure-login Description: Validates Azure CLI authentication before deployment. @@ -309,13 +309,13 @@ If targeting 'create-provisioning-context': If targeting 'deploy': Direct dependencies: build-api-service, build-python-app, create-provisioning-context, print-api-service-summary, print-cache-summary, print-dashboard-url-aas-env, print-dashboard-url-aca-env, print-python-app-summary, provision-azure-bicep-resources, validate-azure-login - Total steps: 27 + Total steps: 28 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | build-python-app | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage | update-api-service-provisionable-resource (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env | provision-aca-env (parallel) [6] provision-cache-containerapp | push-prereq (parallel) [7] print-cache-summary | push-api-service | push-python-app (parallel) @@ -326,13 +326,13 @@ If targeting 'deploy': If targeting 'deploy-api-service': Direct dependencies: print-api-service-summary - Total steps: 16 + Total steps: 17 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr | update-api-service-provisionable-resource (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env (parallel) [6] push-prereq [7] push-api-service @@ -407,13 +407,13 @@ If targeting 'login-to-acr-aca-env-acr': If targeting 'print-api-service-summary': Direct dependencies: provision-api-service-website - Total steps: 15 + Total steps: 16 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr | update-api-service-provisionable-resource (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env (parallel) [6] push-prereq [7] push-api-service @@ -435,13 +435,13 @@ If targeting 'print-cache-summary': If targeting 'print-dashboard-url-aas-env': Direct dependencies: provision-aas-env, provision-azure-bicep-resources - Total steps: 22 + Total steps: 23 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | build-python-app | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage | update-api-service-provisionable-resource (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env | provision-aca-env (parallel) [6] provision-cache-containerapp | push-prereq (parallel) [7] push-api-service | push-python-app (parallel) @@ -451,13 +451,13 @@ If targeting 'print-dashboard-url-aas-env': If targeting 'print-dashboard-url-aca-env': Direct dependencies: provision-aca-env, provision-azure-bicep-resources - Total steps: 22 + Total steps: 23 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | build-python-app | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage | update-api-service-provisionable-resource (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env | provision-aca-env (parallel) [6] provision-cache-containerapp | push-prereq (parallel) [7] push-api-service | push-python-app (parallel) @@ -529,14 +529,14 @@ If targeting 'provision-aca-env-acr': [4] provision-aca-env-acr If targeting 'provision-api-service-website': - Direct dependencies: create-provisioning-context, provision-aas-env, push-api-service - Total steps: 14 + Direct dependencies: create-provisioning-context, provision-aas-env, push-api-service, update-api-service-provisionable-resource + Total steps: 15 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr | update-api-service-provisionable-resource (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env (parallel) [6] push-prereq [7] push-api-service @@ -544,13 +544,13 @@ If targeting 'provision-api-service-website': If targeting 'provision-azure-bicep-resources': Direct dependencies: create-provisioning-context, deploy-prereq, provision-aas-env, provision-aas-env-acr, provision-aca-env, provision-aca-env-acr, provision-api-service-website, provision-cache-containerapp, provision-python-app-containerapp, provision-storage - Total steps: 21 + Total steps: 22 Execution order: [0] process-parameters [1] build-prereq | deploy-prereq (parallel) [2] build-api-service | build-python-app | validate-azure-login (parallel) [3] create-provisioning-context - [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage (parallel) + [4] provision-aas-env-acr | provision-aca-env-acr | provision-storage | update-api-service-provisionable-resource (parallel) [5] login-to-acr-aas-env-acr | login-to-acr-aca-env-acr | provision-aas-env | provision-aca-env (parallel) [6] provision-cache-containerapp | push-prereq (parallel) [7] push-api-service | push-python-app (parallel) @@ -594,12 +594,12 @@ If targeting 'provision-storage': [4] provision-storage If targeting 'publish': - Direct dependencies: publish-azure634f9, validate-appservice-config-aas-env - Total steps: 5 + Direct dependencies: publish-azure634f9 + Total steps: 4 Execution order: [0] process-parameters [1] publish-prereq - [2] publish-azure634f9 | validate-appservice-config-aas-env (parallel) + [2] publish-azure634f9 [3] publish If targeting 'publish-azure634f9': @@ -675,13 +675,15 @@ If targeting 'push-python-app': [6] push-prereq [7] push-python-app -If targeting 'validate-appservice-config-aas-env': - Direct dependencies: publish-prereq - Total steps: 3 +If targeting 'update-api-service-provisionable-resource': + Direct dependencies: create-provisioning-context + Total steps: 5 Execution order: [0] process-parameters - [1] publish-prereq - [2] validate-appservice-config-aas-env + [1] deploy-prereq + [2] validate-azure-login + [3] create-provisioning-context + [4] update-api-service-provisionable-resource If targeting 'validate-azure-login': Direct dependencies: deploy-prereq diff --git a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs index ddfe4aae3b4..15034bb96c0 100644 --- a/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs +++ b/tests/Aspire.Hosting.Docker.Tests/DockerComposeTests.cs @@ -652,6 +652,7 @@ public async Task PushImageToRegistry_WithLocalRegistry_OnlyTagsImage() } [Fact] + [QuarantinedTest("https://github.com/dotnet/aspire/issues/13878")] public async Task PushImageToRegistry_WithRemoteRegistry_PushesImage() { using var tempDir = new TestTempDirectory(); diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index 654b8cf29b4..a3da353c498 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -407,48 +407,6 @@ public async Task KubernetesWithProjectResources() await settingsTask; } - [Fact] - public async Task KubernetesMapsPortsForBaitAndSwitchResources() - { - using var tempDir = new TestTempDirectory(); - var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path); - builder.AddKubernetesEnvironment("env"); - var api = builder.AddExecutable("api", "node", ".") - .PublishAsDockerFile() - .WithHttpEndpoint(env: "PORT"); - builder.AddContainer("gateway", "nginx") - .WithHttpEndpoint(targetPort: 8080) - .WithReference(api.GetEndpoint("http")); - var app = builder.Build(); - app.Run(); - // Assert - var expectedFiles = new[] - { - "Chart.yaml", - "values.yaml", - "templates/api/deployment.yaml", - "templates/api/service.yaml", - "templates/api/config.yaml", - "templates/gateway/deployment.yaml", - "templates/gateway/config.yaml" - }; - SettingsTask settingsTask = default!; - foreach (var expectedFile in expectedFiles) - { - var filePath = Path.Combine(tempDir.Path, expectedFile); - var fileExtension = Path.GetExtension(filePath)[1..]; - if (settingsTask is null) - { - settingsTask = Verify(File.ReadAllText(filePath), fileExtension); - } - else - { - settingsTask = settingsTask.AppendContentAsFile(File.ReadAllText(filePath), fileExtension); - } - } - await settingsTask; - } - private sealed class TestProject : IProjectMetadata { public string ProjectPath => "another-path"; diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#00.verified.yaml deleted file mode 100644 index e4179697054..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#00.verified.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: "v2" -name: "aspire-hosting-tests" -version: "0.1.0" -kubeVersion: ">= 1.18.0-0" -description: "Aspire Helm Chart" -type: "application" -keywords: - - "aspire" - - "kubernetes" -appVersion: "0.1.0" -deprecated: false diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#01.verified.yaml deleted file mode 100644 index 9bb8e2495d4..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#01.verified.yaml +++ /dev/null @@ -1,10 +0,0 @@ -parameters: - api: - api_image: "api:latest" -secrets: {} -config: - api: - PORT: "8000" - gateway: - API_HTTP: "http://api-service:8000" - services__api__http__0: "http://api-service:8000" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#02.verified.yaml deleted file mode 100644 index 7c0045b550b..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#02.verified.yaml +++ /dev/null @@ -1,40 +0,0 @@ ---- -apiVersion: "apps/v1" -kind: "Deployment" -metadata: - name: "api-deployment" - labels: - app.kubernetes.io/name: "aspire-hosting-tests" - app.kubernetes.io/component: "api" - app.kubernetes.io/instance: "{{ .Release.Name }}" -spec: - template: - metadata: - labels: - app.kubernetes.io/name: "aspire-hosting-tests" - app.kubernetes.io/component: "api" - app.kubernetes.io/instance: "{{ .Release.Name }}" - spec: - containers: - - image: "{{ .Values.parameters.api.api_image }}" - name: "api" - envFrom: - - configMapRef: - name: "api-config" - ports: - - name: "http" - protocol: "TCP" - containerPort: 8000 - imagePullPolicy: "IfNotPresent" - selector: - matchLabels: - app.kubernetes.io/name: "aspire-hosting-tests" - app.kubernetes.io/component: "api" - app.kubernetes.io/instance: "{{ .Release.Name }}" - replicas: 1 - revisionHistoryLimit: 3 - strategy: - rollingUpdate: - maxSurge: 1 - maxUnavailable: 1 - type: "RollingUpdate" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#03.verified.yaml deleted file mode 100644 index a3bfbdbc5d2..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#03.verified.yaml +++ /dev/null @@ -1,20 +0,0 @@ ---- -apiVersion: "v1" -kind: "Service" -metadata: - name: "api-service" - labels: - app.kubernetes.io/name: "aspire-hosting-tests" - app.kubernetes.io/component: "api" - app.kubernetes.io/instance: "{{ .Release.Name }}" -spec: - type: "ClusterIP" - selector: - app.kubernetes.io/name: "aspire-hosting-tests" - app.kubernetes.io/component: "api" - app.kubernetes.io/instance: "{{ .Release.Name }}" - ports: - - name: "http" - protocol: "TCP" - port: 8000 - targetPort: 8000 diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#04.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#04.verified.yaml deleted file mode 100644 index 2b756089179..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#04.verified.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -apiVersion: "v1" -kind: "ConfigMap" -metadata: - name: "api-config" - labels: - app.kubernetes.io/name: "aspire-hosting-tests" - app.kubernetes.io/component: "api" - app.kubernetes.io/instance: "{{ .Release.Name }}" -data: - PORT: "{{ .Values.config.api.PORT }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#05.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#05.verified.yaml deleted file mode 100644 index 7abdfd9076b..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#05.verified.yaml +++ /dev/null @@ -1,40 +0,0 @@ ---- -apiVersion: "apps/v1" -kind: "Deployment" -metadata: - name: "gateway-deployment" - labels: - app.kubernetes.io/name: "aspire-hosting-tests" - app.kubernetes.io/component: "gateway" - app.kubernetes.io/instance: "{{ .Release.Name }}" -spec: - template: - metadata: - labels: - app.kubernetes.io/name: "aspire-hosting-tests" - app.kubernetes.io/component: "gateway" - app.kubernetes.io/instance: "{{ .Release.Name }}" - spec: - containers: - - image: "nginx:latest" - name: "gateway" - envFrom: - - configMapRef: - name: "gateway-config" - ports: - - name: "http" - protocol: "TCP" - containerPort: 8080 - imagePullPolicy: "IfNotPresent" - selector: - matchLabels: - app.kubernetes.io/name: "aspire-hosting-tests" - app.kubernetes.io/component: "gateway" - app.kubernetes.io/instance: "{{ .Release.Name }}" - replicas: 1 - revisionHistoryLimit: 3 - strategy: - rollingUpdate: - maxSurge: 1 - maxUnavailable: 1 - type: "RollingUpdate" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#06.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#06.verified.yaml deleted file mode 100644 index 190928c781d..00000000000 --- a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#06.verified.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -apiVersion: "v1" -kind: "ConfigMap" -metadata: - name: "gateway-config" - labels: - app.kubernetes.io/name: "aspire-hosting-tests" - app.kubernetes.io/component: "gateway" - app.kubernetes.io/instance: "{{ .Release.Name }}" -data: - API_HTTP: "{{ .Values.config.gateway.API_HTTP }}" - services__api__http__0: "{{ .Values.config.gateway.services__api__http__0 }}" diff --git a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs index 5af54367a6d..edfc9542a61 100644 --- a/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.cs +++ b/tests/Aspire.Hosting.Tests/Pipelines/DistributedApplicationPipelineTests.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. #pragma warning disable CS0618 // Type or member is obsolete @@ -13,6 +13,7 @@ using Aspire.Hosting.Pipelines; using Aspire.Hosting.Tests.Publishing; using Aspire.Hosting.Utils; +using Aspire.TestUtilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -2026,6 +2027,7 @@ public async Task FilterStepsForExecution_WithRequiredBy_IncludesTransitiveDepen } [Fact] + [QuarantinedTest("https://github.com/dotnet/aspire/issues/13083")] public async Task ProcessParametersStep_ValidatesBehavior() { // Arrange diff --git a/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs b/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs index daca5107af6..e0659ee6e79 100644 --- a/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs +++ b/tests/Aspire.Hosting.Tests/WithHttpCommandTests.cs @@ -4,6 +4,7 @@ using System.Net; using Aspire.Hosting.Testing; using Aspire.Hosting.Utils; +using Aspire.TestUtilities; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http.Resilience; @@ -400,6 +401,7 @@ public async Task WithHttpCommand_CallsGetResponseCallback_AfterSendingRequest() } [Fact] + [QuarantinedTest("https://github.com/dotnet/aspire/issues/8101")] public async Task WithHttpCommand_EnablesCommandOnceResourceIsRunning() { // Arrange diff --git a/tools/perf/Measure-StartupPerformance.ps1 b/tools/perf/Measure-StartupPerformance.ps1 deleted file mode 100644 index 626adff10ed..00000000000 --- a/tools/perf/Measure-StartupPerformance.ps1 +++ /dev/null @@ -1,678 +0,0 @@ -<# -.SYNOPSIS - Measures .NET Aspire application startup performance by collecting ETW traces. - -.DESCRIPTION - This script runs an Aspire application, collects a performance trace - using dotnet-trace, and computes the startup time from AspireEventSource events. - The trace collection ends when the DcpModelCreationStop event is fired. - -.PARAMETER ProjectPath - Path to the AppHost project (.csproj) to measure. Can be absolute or relative. - Defaults to the TestShop.AppHost project in the playground folder. - -.PARAMETER Iterations - Number of times to run the scenario and collect traces. Defaults to 1. - -.PARAMETER PreserveTraces - If specified, trace files are preserved after the run. By default, traces are - stored in a temporary folder and deleted after analysis. - -.PARAMETER TraceOutputDirectory - Directory where trace files will be saved when PreserveTraces is set. - Defaults to a 'traces' subdirectory in the script folder. - -.PARAMETER SkipBuild - If specified, skips building the project before running. - -.PARAMETER TraceDurationSeconds - Duration in seconds for the trace collection. Defaults to 60 (1 minute). - The value is automatically converted to the dd:hh:mm:ss format required by dotnet-trace. - -.PARAMETER PauseBetweenIterationsSeconds - Number of seconds to pause between iterations. Defaults to 15. - Set to 0 to disable the pause. - -.PARAMETER Verbose - If specified, shows detailed output during execution. - -.EXAMPLE - .\Measure-StartupPerformance.ps1 - -.EXAMPLE - .\Measure-StartupPerformance.ps1 -Iterations 5 - -.EXAMPLE - .\Measure-StartupPerformance.ps1 -ProjectPath "C:\MyApp\MyApp.AppHost.csproj" -Iterations 3 - -.EXAMPLE - .\Measure-StartupPerformance.ps1 -Iterations 3 -PreserveTraces -TraceOutputDirectory "C:\traces" - -.EXAMPLE - .\Measure-StartupPerformance.ps1 -TraceDurationSeconds 120 - -.EXAMPLE - .\Measure-StartupPerformance.ps1 -Iterations 5 -PauseBetweenIterationsSeconds 30 - -.NOTES - Requires: - - PowerShell 7+ - - dotnet-trace global tool (dotnet tool install -g dotnet-trace) - - .NET SDK -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $false)] - [string]$ProjectPath, - - [Parameter(Mandatory = $false)] - [ValidateRange(1, 100)] - [int]$Iterations = 1, - - [Parameter(Mandatory = $false)] - [switch]$PreserveTraces, - - [Parameter(Mandatory = $false)] - [string]$TraceOutputDirectory, - - [Parameter(Mandatory = $false)] - [switch]$SkipBuild, - - [Parameter(Mandatory = $false)] - [ValidateRange(1, 86400)] - [int]$TraceDurationSeconds = 60, - - [Parameter(Mandatory = $false)] - [ValidateRange(0, 3600)] - [int]$PauseBetweenIterationsSeconds = 45 -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -# Constants -$EventSourceName = 'Microsoft-Aspire-Hosting' -$DcpModelCreationStartEventId = 17 -$DcpModelCreationStopEventId = 18 - -# Get repository root (script is in tools/perf) -$ScriptDir = $PSScriptRoot -$RepoRoot = (Resolve-Path (Join-Path $ScriptDir '..' '..')).Path - -# Resolve project path -if (-not $ProjectPath) { - # Default to TestShop.AppHost - $ProjectPath = Join-Path $RepoRoot 'playground' 'TestShop' 'TestShop.AppHost' 'TestShop.AppHost.csproj' -} -elseif (-not [System.IO.Path]::IsPathRooted($ProjectPath)) { - # Relative path - resolve from current directory - $ProjectPath = (Resolve-Path $ProjectPath -ErrorAction Stop).Path -} - -$AppHostProject = $ProjectPath -$AppHostDir = Split-Path $AppHostProject -Parent -$AppHostName = [System.IO.Path]::GetFileNameWithoutExtension($AppHostProject) - -# Determine output directory for traces - always use temp directory unless explicitly specified -if ($TraceOutputDirectory) { - $OutputDirectory = $TraceOutputDirectory -} -else { - # Always use a temp directory for traces - $OutputDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "aspire-perf-$([System.Guid]::NewGuid().ToString('N').Substring(0, 8))" -} - -# Only delete temp directory if not preserving traces and no custom directory was specified -$ShouldCleanupDirectory = -not $PreserveTraces -and -not $TraceOutputDirectory - -# Ensure output directory exists -if (-not (Test-Path $OutputDirectory)) { - New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null -} - -# Verify prerequisites -function Test-Prerequisites { - Write-Host "Checking prerequisites..." -ForegroundColor Cyan - - # Check dotnet-trace is installed - $dotnetTrace = Get-Command 'dotnet-trace' -ErrorAction SilentlyContinue - if (-not $dotnetTrace) { - throw "dotnet-trace is not installed. Install it with: dotnet tool install -g dotnet-trace" - } - Write-Verbose "dotnet-trace found at: $($dotnetTrace.Source)" - - # Check project exists - if (-not (Test-Path $AppHostProject)) { - throw "AppHost project not found at: $AppHostProject" - } - Write-Verbose "AppHost project found at: $AppHostProject" - - Write-Host "Prerequisites check passed." -ForegroundColor Green -} - -# Build the project -function Build-AppHost { - Write-Host "Building $AppHostName..." -ForegroundColor Cyan - - Push-Location $AppHostDir - try { - $buildOutput = & dotnet build -c Release --nologo 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Host ($buildOutput -join "`n") -ForegroundColor Red - throw "Failed to build $AppHostName" - } - Write-Verbose ($buildOutput -join "`n") - Write-Host "Build completed successfully." -ForegroundColor Green - } - finally { - Pop-Location - } -} - -# Run a single iteration of the performance test -function Invoke-PerformanceIteration { - param( - [int]$IterationNumber, - [string]$TraceOutputPath - ) - - Write-Host "`nIteration $IterationNumber" -ForegroundColor Yellow - Write-Host ("-" * 40) -ForegroundColor Yellow - - $nettracePath = "$TraceOutputPath.nettrace" - $appProcess = $null - $traceProcess = $null - - try { - # Find the compiled executable - we need the path to launch it - $exePath = $null - $dllPath = $null - - # Search in multiple possible output locations: - # 1. Arcade-style: artifacts/bin//Release// - # 2. Traditional: /bin/Release// - $searchPaths = @( - (Join-Path $RepoRoot 'artifacts' 'bin' $AppHostName 'Release'), - (Join-Path $AppHostDir 'bin' 'Release') - ) - - foreach ($basePath in $searchPaths) { - if (-not (Test-Path $basePath)) { - continue - } - - # Find TFM subdirectories (e.g., net8.0, net9.0, net10.0) - $tfmDirs = Get-ChildItem -Path $basePath -Directory -Filter 'net*' -ErrorAction SilentlyContinue - foreach ($tfmDir in $tfmDirs) { - $candidateExe = Join-Path $tfmDir.FullName "$AppHostName.exe" - $candidateDll = Join-Path $tfmDir.FullName "$AppHostName.dll" - - if (Test-Path $candidateExe) { - $exePath = $candidateExe - Write-Verbose "Found executable at: $exePath" - break - } - elseif (Test-Path $candidateDll) { - $dllPath = $candidateDll - Write-Verbose "Found DLL at: $dllPath" - break - } - } - - if ($exePath -or $dllPath) { - break - } - } - - if (-not $exePath -and -not $dllPath) { - $searchedPaths = $searchPaths -join "`n - " - throw "Could not find compiled executable or DLL. Searched in:`n - $searchedPaths`nPlease build the project first (without -SkipBuild)." - } - - # Read launchSettings.json to get environment variables - $launchSettingsPath = Join-Path $AppHostDir 'Properties' 'launchSettings.json' - $envVars = @{} - if (Test-Path $launchSettingsPath) { - Write-Verbose "Reading launch settings from: $launchSettingsPath" - try { - # Read the file and remove JSON comments (// style) before parsing - # Only remove lines that start with // (after optional whitespace) to avoid breaking URLs like https:// - $jsonLines = Get-Content $launchSettingsPath - $filteredLines = $jsonLines | Where-Object { $_.Trim() -notmatch '^//' } - $jsonContent = $filteredLines -join "`n" - $launchSettings = $jsonContent | ConvertFrom-Json - - # Try to find a suitable profile (prefer 'http' for simplicity, then first available) - $profile = $null - if ($launchSettings.profiles.http) { - $profile = $launchSettings.profiles.http - Write-Verbose "Using 'http' launch profile" - } - elseif ($launchSettings.profiles.https) { - $profile = $launchSettings.profiles.https - Write-Verbose "Using 'https' launch profile" - } - else { - # Use first profile that has environmentVariables - foreach ($prop in $launchSettings.profiles.PSObject.Properties) { - if ($prop.Value.environmentVariables) { - $profile = $prop.Value - Write-Verbose "Using '$($prop.Name)' launch profile" - break - } - } - } - - if ($profile -and $profile.environmentVariables) { - foreach ($prop in $profile.environmentVariables.PSObject.Properties) { - $envVars[$prop.Name] = $prop.Value - Write-Verbose " Environment: $($prop.Name)=$($prop.Value)" - } - } - - # Use applicationUrl to set ASPNETCORE_URLS if not already set - if ($profile -and $profile.applicationUrl -and -not $envVars.ContainsKey('ASPNETCORE_URLS')) { - $envVars['ASPNETCORE_URLS'] = $profile.applicationUrl - Write-Verbose " Environment: ASPNETCORE_URLS=$($profile.applicationUrl) (from applicationUrl)" - } - } - catch { - Write-Warning "Failed to parse launchSettings.json: $_" - } - } - else { - Write-Verbose "No launchSettings.json found at: $launchSettingsPath" - } - - # Always ensure Development environment is set - if (-not $envVars.ContainsKey('DOTNET_ENVIRONMENT')) { - $envVars['DOTNET_ENVIRONMENT'] = 'Development' - } - if (-not $envVars.ContainsKey('ASPNETCORE_ENVIRONMENT')) { - $envVars['ASPNETCORE_ENVIRONMENT'] = 'Development' - } - - # Start the AppHost application as a separate process - Write-Host "Starting $AppHostName..." -ForegroundColor Cyan - - $appPsi = [System.Diagnostics.ProcessStartInfo]::new() - if ($exePath) { - $appPsi.FileName = $exePath - $appPsi.Arguments = '' - } - else { - $appPsi.FileName = 'dotnet' - $appPsi.Arguments = "`"$dllPath`"" - } - $appPsi.WorkingDirectory = $AppHostDir - $appPsi.UseShellExecute = $false - $appPsi.RedirectStandardOutput = $true - $appPsi.RedirectStandardError = $true - $appPsi.CreateNoWindow = $true - - # Set environment variables from launchSettings.json - foreach ($key in $envVars.Keys) { - $appPsi.Environment[$key] = $envVars[$key] - } - - $appProcess = [System.Diagnostics.Process]::Start($appPsi) - $appPid = $appProcess.Id - - Write-Verbose "$AppHostName started with PID: $appPid" - - # Give the process a moment to initialize before attaching - Start-Sleep -Milliseconds 200 - - # Verify the process is still running - if ($appProcess.HasExited) { - $stdout = $appProcess.StandardOutput.ReadToEnd() - $stderr = $appProcess.StandardError.ReadToEnd() - throw "Application exited immediately with code $($appProcess.ExitCode).`nStdOut: $stdout`nStdErr: $stderr" - } - - # Start dotnet-trace to attach to the running process - Write-Host "Attaching trace collection to PID $appPid..." -ForegroundColor Cyan - - # Use dotnet-trace with the EventSource provider - # Format: ProviderName:Keywords:Level - # Keywords=0xFFFFFFFF (all), Level=5 (Verbose) - $providers = "${EventSourceName}" - - # Convert TraceDurationSeconds to dd:hh:mm:ss format required by dotnet-trace - $days = [math]::Floor($TraceDurationSeconds / 86400) - $hours = [math]::Floor(($TraceDurationSeconds % 86400) / 3600) - $minutes = [math]::Floor(($TraceDurationSeconds % 3600) / 60) - $seconds = $TraceDurationSeconds % 60 - $traceDuration = '{0:00}:{1:00}:{2:00}:{3:00}' -f $days, $hours, $minutes, $seconds - - $traceArgs = @( - 'collect', - '--process-id', $appPid, - '--providers', $providers, - '--output', $nettracePath, - '--format', 'nettrace', - '--duration', $traceDuration, - '--buffersize', '8192' - ) - - Write-Verbose "dotnet-trace arguments: $($traceArgs -join ' ')" - - $tracePsi = [System.Diagnostics.ProcessStartInfo]::new() - $tracePsi.FileName = 'dotnet-trace' - $tracePsi.Arguments = $traceArgs -join ' ' - $tracePsi.WorkingDirectory = $AppHostDir - $tracePsi.UseShellExecute = $false - $tracePsi.RedirectStandardOutput = $true - $tracePsi.RedirectStandardError = $true - $tracePsi.CreateNoWindow = $true - - $traceProcess = [System.Diagnostics.Process]::Start($tracePsi) - - Write-Host "Collecting performance trace..." -ForegroundColor Cyan - - # Wait for trace to complete - $traceProcess.WaitForExit() - - # Read app process output (what was captured while trace was running) - # Use async read to avoid blocking - read whatever is available - $appStdout = "" - $appStderr = "" - if ($appProcess -and -not $appProcess.HasExited) { - # Process is still running, we can try to read available output - # Note: ReadToEnd would block, so we read what's available after stopping - } - - $traceOutput = $traceProcess.StandardOutput.ReadToEnd() - $traceError = $traceProcess.StandardError.ReadToEnd() - - if ($traceOutput) { Write-Verbose "dotnet-trace output: $traceOutput" } - if ($traceError) { Write-Verbose "dotnet-trace stderr: $traceError" } - - # Check if trace file was created despite any errors - # dotnet-trace may report errors during cleanup but the trace file is often still valid - if ($traceProcess.ExitCode -ne 0) { - if (Test-Path $nettracePath) { - Write-Warning "dotnet-trace exited with code $($traceProcess.ExitCode), but trace file was created. Attempting to analyze." - } - else { - Write-Warning "dotnet-trace exited with code $($traceProcess.ExitCode) and no trace file was created." - return $null - } - } - - Write-Host "Trace collection completed." -ForegroundColor Green - - return $nettracePath - } - finally { - # Clean up the application process and capture its output - if ($appProcess) { - # Read any remaining output before killing the process - $appStdout = "" - $appStderr = "" - try { - # Give a moment for any buffered output - Start-Sleep -Milliseconds 100 - - # We need to read asynchronously since the process may still be running - # Read what's available without blocking indefinitely - $stdoutTask = $appProcess.StandardOutput.ReadToEndAsync() - $stderrTask = $appProcess.StandardError.ReadToEndAsync() - - # Wait briefly for output - [System.Threading.Tasks.Task]::WaitAll(@($stdoutTask, $stderrTask), 1000) | Out-Null - - if ($stdoutTask.IsCompleted) { - $appStdout = $stdoutTask.Result - } - if ($stderrTask.IsCompleted) { - $appStderr = $stderrTask.Result - } - } - catch { - # Ignore errors reading output - } - - if ($appStdout) { - Write-Verbose "Application stdout:`n$appStdout" - } - if ($appStderr) { - Write-Verbose "Application stderr:`n$appStderr" - } - - if (-not $appProcess.HasExited) { - Write-Verbose "Stopping $AppHostName (PID: $($appProcess.Id))..." - try { - # Try graceful shutdown first - $appProcess.Kill($true) - $appProcess.WaitForExit(5000) | Out-Null - } - catch { - Write-Warning "Failed to stop application: $_" - } - } - $appProcess.Dispose() - } - - # Clean up trace process - if ($traceProcess) { - if (-not $traceProcess.HasExited) { - try { - $traceProcess.Kill() - $traceProcess.WaitForExit(2000) | Out-Null - } - catch { - # Ignore errors killing trace process - } - } - $traceProcess.Dispose() - } - } -} - -# Path to the trace analyzer tool -$TraceAnalyzerDir = Join-Path $ScriptDir 'TraceAnalyzer' -$TraceAnalyzerProject = Join-Path $TraceAnalyzerDir 'TraceAnalyzer.csproj' - -# Build the trace analyzer tool -function Build-TraceAnalyzer { - if (-not (Test-Path $TraceAnalyzerProject)) { - Write-Warning "TraceAnalyzer project not found at: $TraceAnalyzerProject" - return $false - } - - Write-Verbose "Building TraceAnalyzer tool..." - $buildOutput = & dotnet build $TraceAnalyzerProject -c Release --verbosity quiet 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Warning "Failed to build TraceAnalyzer: $buildOutput" - return $false - } - - Write-Verbose "TraceAnalyzer built successfully" - return $true -} - -# Parse nettrace file using the TraceAnalyzer tool -function Get-StartupTiming { - param( - [string]$TracePath - ) - - Write-Host "Analyzing trace: $TracePath" -ForegroundColor Cyan - - if (-not (Test-Path $TracePath)) { - Write-Warning "Trace file not found: $TracePath" - return $null - } - - try { - $output = & dotnet run --project $TraceAnalyzerProject -c Release --no-build -- $TracePath 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Warning "TraceAnalyzer failed: $output" - return $null - } - - $result = $output | Select-Object -Last 1 - if ($result -eq 'null') { - Write-Warning "Could not find DcpModelCreation events in the trace" - return $null - } - - $duration = [double]::Parse($result, [System.Globalization.CultureInfo]::InvariantCulture) - Write-Verbose "Calculated duration: $duration ms" - return $duration - } - catch { - Write-Warning "Error parsing trace: $_" - return $null - } -} - -# Main execution -function Main { - Write-Host "==================================================" -ForegroundColor Cyan - Write-Host " Aspire Startup Performance Measurement" -ForegroundColor Cyan - Write-Host "==================================================" -ForegroundColor Cyan - Write-Host "" - Write-Host "Project: $AppHostName" - Write-Host "Project Path: $AppHostProject" - Write-Host "Iterations: $Iterations" - Write-Host "Trace Duration: $TraceDurationSeconds seconds" - Write-Host "Pause Between Iterations: $PauseBetweenIterationsSeconds seconds" - Write-Host "Preserve Traces: $PreserveTraces" - if ($PreserveTraces -or $TraceOutputDirectory) { - Write-Host "Trace Directory: $OutputDirectory" - } - Write-Host "" - - Test-Prerequisites - - # Build the TraceAnalyzer tool for parsing traces - $traceAnalyzerAvailable = Build-TraceAnalyzer - - # Ensure output directory exists - if (-not (Test-Path $OutputDirectory)) { - New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null - } - - if (-not $SkipBuild) { - Build-AppHost - } - else { - Write-Host "Skipping build (SkipBuild flag set)" -ForegroundColor Yellow - } - - $results = @() - $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' - - try { - for ($i = 1; $i -le $Iterations; $i++) { - $traceBaseName = "${AppHostName}_startup_${timestamp}_iter${i}" - $traceOutputPath = Join-Path $OutputDirectory $traceBaseName - - $tracePath = Invoke-PerformanceIteration -IterationNumber $i -TraceOutputPath $traceOutputPath - - if ($tracePath -and (Test-Path $tracePath)) { - $duration = $null - if ($traceAnalyzerAvailable) { - $duration = Get-StartupTiming -TracePath $tracePath - } - - if ($null -ne $duration) { - $results += [PSCustomObject]@{ - Iteration = $i - TracePath = $tracePath - StartupTimeMs = [math]::Round($duration, 2) - } - Write-Host "Startup time: $([math]::Round($duration, 2)) ms" -ForegroundColor Green - } - else { - $results += [PSCustomObject]@{ - Iteration = $i - TracePath = $tracePath - StartupTimeMs = $null - } - Write-Host "Trace collected: $tracePath" -ForegroundColor Green - } - } - else { - Write-Warning "No trace file generated for iteration $i" - } - - # Pause between iterations - if ($i -lt $Iterations -and $PauseBetweenIterationsSeconds -gt 0) { - Write-Verbose "Pausing for $PauseBetweenIterationsSeconds seconds before next iteration..." - Start-Sleep -Seconds $PauseBetweenIterationsSeconds - } - } - } - finally { - # Clean up temporary trace directory if not preserving traces - if ($ShouldCleanupDirectory -and (Test-Path $OutputDirectory)) { - Write-Verbose "Cleaning up temporary trace directory: $OutputDirectory" - Remove-Item -Path $OutputDirectory -Recurse -Force -ErrorAction SilentlyContinue - } - } - - # Summary - Write-Host "" - Write-Host "==================================================" -ForegroundColor Cyan - Write-Host " Results Summary" -ForegroundColor Cyan - Write-Host "==================================================" -ForegroundColor Cyan - - # Wrap in @() to ensure array even with single/null results - $validResults = @($results | Where-Object { $null -ne $_.StartupTimeMs }) - - if ($validResults.Count -gt 0) { - Write-Host "" - # Only show TracePath in summary if PreserveTraces is set - if ($PreserveTraces) { - $results | Format-Table -AutoSize - } - else { - $results | Select-Object Iteration, StartupTimeMs | Format-Table -AutoSize - } - - $times = @($validResults | ForEach-Object { $_.StartupTimeMs }) - $avg = ($times | Measure-Object -Average).Average - $min = ($times | Measure-Object -Minimum).Minimum - $max = ($times | Measure-Object -Maximum).Maximum - - Write-Host "" - Write-Host "Statistics:" -ForegroundColor Yellow - Write-Host " Successful iterations: $($validResults.Count) / $Iterations" - Write-Host " Minimum: $([math]::Round($min, 2)) ms" - Write-Host " Maximum: $([math]::Round($max, 2)) ms" - Write-Host " Average: $([math]::Round($avg, 2)) ms" - - if ($validResults.Count -gt 1) { - $stdDev = [math]::Sqrt(($times | ForEach-Object { [math]::Pow($_ - $avg, 2) } | Measure-Object -Average).Average) - Write-Host " Std Dev: $([math]::Round($stdDev, 2)) ms" - } - - if ($PreserveTraces) { - Write-Host "" - Write-Host "Trace files saved to: $OutputDirectory" -ForegroundColor Cyan - } - } - elseif ($results.Count -gt 0) { - Write-Host "" - Write-Host "Collected $($results.Count) trace(s) but could not extract timing." -ForegroundColor Yellow - if ($PreserveTraces) { - Write-Host "" - Write-Host "Trace files saved to: $OutputDirectory" -ForegroundColor Cyan - $results | Select-Object Iteration, TracePath | Format-Table -AutoSize - Write-Host "" - Write-Host "Open traces in PerfView or Visual Studio to analyze startup timing." -ForegroundColor Yellow - } - } - else { - Write-Warning "No traces were collected." - } - - return $results -} - -# Run the script -Main diff --git a/tools/perf/TraceAnalyzer/Program.cs b/tools/perf/TraceAnalyzer/Program.cs deleted file mode 100644 index 76ffe45d44d..00000000000 --- a/tools/perf/TraceAnalyzer/Program.cs +++ /dev/null @@ -1,80 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -// Tool to analyze .nettrace files and extract Aspire startup timing information. -// Usage: dotnet run -- -// Output: Prints the startup duration in milliseconds to stdout, or "null" if events not found. - -using Microsoft.Diagnostics.Tracing; - -if (args.Length == 0) -{ - Console.Error.WriteLine("Usage: TraceAnalyzer "); - return 1; -} - -var tracePath = args[0]; - -if (!File.Exists(tracePath)) -{ - Console.Error.WriteLine($"Error: File not found: {tracePath}"); - return 1; -} - -// Event IDs from AspireEventSource -const int DcpModelCreationStartEventId = 17; -const int DcpModelCreationStopEventId = 18; - -const string AspireHostingProviderName = "Microsoft-Aspire-Hosting"; - -try -{ - double? startTime = null; - double? stopTime = null; - - using (var source = new EventPipeEventSource(tracePath)) - { - source.Dynamic.AddCallbackForProviderEvents((string pName, string eName) => - { - if (pName != AspireHostingProviderName) - { - return EventFilterResponse.RejectProvider; - } - if (eName == null || eName.StartsWith("DcpModelCreation", StringComparison.Ordinal)) - { - return EventFilterResponse.AcceptEvent; - } - return EventFilterResponse.RejectEvent; - }, - (TraceEvent traceEvent) => - { - if ((int)traceEvent.ID == DcpModelCreationStartEventId) - { - startTime = traceEvent.TimeStampRelativeMSec; - } - else if ((int)traceEvent.ID == DcpModelCreationStopEventId) - { - stopTime = traceEvent.TimeStampRelativeMSec; - } - }); - - source.Process(); - } - - if (startTime.HasValue && stopTime.HasValue) - { - var duration = stopTime.Value - startTime.Value; - Console.WriteLine(duration.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)); - return 0; - } - else - { - Console.WriteLine("null"); - return 0; - } -} -catch (Exception ex) -{ - Console.Error.WriteLine($"Error parsing trace: {ex.Message}"); - return 1; -} diff --git a/tools/perf/TraceAnalyzer/TraceAnalyzer.csproj b/tools/perf/TraceAnalyzer/TraceAnalyzer.csproj deleted file mode 100644 index f984521fbc3..00000000000 --- a/tools/perf/TraceAnalyzer/TraceAnalyzer.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - Exe - net8.0 - enable - enable - - false - - - - - - - From f5b4bc0c7cd2d6b32d490e182a458fc6da467d9f Mon Sep 17 00:00:00 2001 From: Karol Zadora-Przylecki Date: Tue, 24 Feb 2026 09:48:13 -0800 Subject: [PATCH 163/256] Update release/13.2 DCP version to 0.22.7 (#14637) --- eng/Version.Details.xml | 28 ++++++++++++++-------------- eng/Versions.props | 14 +++++++------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 1cdf20f5f5e..1f696cdb9f5 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,33 +1,33 @@ - + https://github.com/microsoft/dcp - 9585d3bbfad8a356770096fcda944349da4145f1 + e6844e7cee9edb9fbe3d2315a8817fd3b565979f - + https://github.com/microsoft/dcp - 9585d3bbfad8a356770096fcda944349da4145f1 + e6844e7cee9edb9fbe3d2315a8817fd3b565979f - + https://github.com/microsoft/dcp - 9585d3bbfad8a356770096fcda944349da4145f1 + e6844e7cee9edb9fbe3d2315a8817fd3b565979f - + https://github.com/microsoft/dcp - 9585d3bbfad8a356770096fcda944349da4145f1 + e6844e7cee9edb9fbe3d2315a8817fd3b565979f - + https://github.com/microsoft/dcp - 9585d3bbfad8a356770096fcda944349da4145f1 + e6844e7cee9edb9fbe3d2315a8817fd3b565979f - + https://github.com/microsoft/dcp - 9585d3bbfad8a356770096fcda944349da4145f1 + e6844e7cee9edb9fbe3d2315a8817fd3b565979f - + https://github.com/microsoft/dcp - 9585d3bbfad8a356770096fcda944349da4145f1 + e6844e7cee9edb9fbe3d2315a8817fd3b565979f https://dev.azure.com/dnceng/internal/_git/dotnet-extensions diff --git a/eng/Versions.props b/eng/Versions.props index d6fac17c504..d7cc1618567 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -28,13 +28,13 @@ 8.0.100-rtm.23512.16 - 0.22.6 - 0.22.6 - 0.22.6 - 0.22.6 - 0.22.6 - 0.22.6 - 0.22.6 + 0.22.7 + 0.22.7 + 0.22.7 + 0.22.7 + 0.22.7 + 0.22.7 + 0.22.7 11.0.0-beta.25610.3 11.0.0-beta.25610.3 From be6509acd3114efcebcd9aec5e3bb70bc020aa15 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Tue, 24 Feb 2026 10:09:17 -0800 Subject: [PATCH 164/256] Fix port mismatch for bait-and-switch resources in Kubernetes publisher (#14653) * Update Aspire.Hosting.Kubernetes.csproj * Initialize _kubernetesComponents with ResourceNameComparer * Update KubernetesPublisherTests.cs * Update Aspire.Hosting.Kubernetes.csproj * Adds snapshots * Adds Chart.yaml to snapshot --------- Co-authored-by: Benjamin Bartels --- .../KubernetesEnvironmentContext.cs | 2 +- .../KubernetesPublisherTests.cs | 42 +++++++++++++++++++ ...ForBaitAndSwitchResources#00.verified.yaml | 11 +++++ ...ForBaitAndSwitchResources#01.verified.yaml | 10 +++++ ...ForBaitAndSwitchResources#02.verified.yaml | 40 ++++++++++++++++++ ...ForBaitAndSwitchResources#03.verified.yaml | 20 +++++++++ ...ForBaitAndSwitchResources#04.verified.yaml | 11 +++++ ...ForBaitAndSwitchResources#05.verified.yaml | 40 ++++++++++++++++++ ...ForBaitAndSwitchResources#06.verified.yaml | 12 ++++++ 9 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#00.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#01.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#02.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#03.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#04.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#05.verified.yaml create mode 100644 tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#06.verified.yaml diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentContext.cs b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentContext.cs index 738fb0d2ec0..cbd4163d04c 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentContext.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentContext.cs @@ -8,7 +8,7 @@ namespace Aspire.Hosting.Kubernetes; internal sealed class KubernetesEnvironmentContext(KubernetesEnvironmentResource environment, ILogger logger) { - private readonly Dictionary _kubernetesComponents = []; + private readonly Dictionary _kubernetesComponents = new(new ResourceNameComparer()); public ILogger Logger => logger; diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs index a3da353c498..654b8cf29b4 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs @@ -407,6 +407,48 @@ public async Task KubernetesWithProjectResources() await settingsTask; } + [Fact] + public async Task KubernetesMapsPortsForBaitAndSwitchResources() + { + using var tempDir = new TestTempDirectory(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path); + builder.AddKubernetesEnvironment("env"); + var api = builder.AddExecutable("api", "node", ".") + .PublishAsDockerFile() + .WithHttpEndpoint(env: "PORT"); + builder.AddContainer("gateway", "nginx") + .WithHttpEndpoint(targetPort: 8080) + .WithReference(api.GetEndpoint("http")); + var app = builder.Build(); + app.Run(); + // Assert + var expectedFiles = new[] + { + "Chart.yaml", + "values.yaml", + "templates/api/deployment.yaml", + "templates/api/service.yaml", + "templates/api/config.yaml", + "templates/gateway/deployment.yaml", + "templates/gateway/config.yaml" + }; + SettingsTask settingsTask = default!; + foreach (var expectedFile in expectedFiles) + { + var filePath = Path.Combine(tempDir.Path, expectedFile); + var fileExtension = Path.GetExtension(filePath)[1..]; + if (settingsTask is null) + { + settingsTask = Verify(File.ReadAllText(filePath), fileExtension); + } + else + { + settingsTask = settingsTask.AppendContentAsFile(File.ReadAllText(filePath), fileExtension); + } + } + await settingsTask; + } + private sealed class TestProject : IProjectMetadata { public string ProjectPath => "another-path"; diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#00.verified.yaml new file mode 100644 index 00000000000..e4179697054 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#00.verified.yaml @@ -0,0 +1,11 @@ +apiVersion: "v2" +name: "aspire-hosting-tests" +version: "0.1.0" +kubeVersion: ">= 1.18.0-0" +description: "Aspire Helm Chart" +type: "application" +keywords: + - "aspire" + - "kubernetes" +appVersion: "0.1.0" +deprecated: false diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#01.verified.yaml new file mode 100644 index 00000000000..9bb8e2495d4 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#01.verified.yaml @@ -0,0 +1,10 @@ +parameters: + api: + api_image: "api:latest" +secrets: {} +config: + api: + PORT: "8000" + gateway: + API_HTTP: "http://api-service:8000" + services__api__http__0: "http://api-service:8000" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#02.verified.yaml new file mode 100644 index 00000000000..7c0045b550b --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#02.verified.yaml @@ -0,0 +1,40 @@ +--- +apiVersion: "apps/v1" +kind: "Deployment" +metadata: + name: "api-deployment" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" +spec: + template: + metadata: + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" + spec: + containers: + - image: "{{ .Values.parameters.api.api_image }}" + name: "api" + envFrom: + - configMapRef: + name: "api-config" + ports: + - name: "http" + protocol: "TCP" + containerPort: 8000 + imagePullPolicy: "IfNotPresent" + selector: + matchLabels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" + replicas: 1 + revisionHistoryLimit: 3 + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: "RollingUpdate" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#03.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#03.verified.yaml new file mode 100644 index 00000000000..a3bfbdbc5d2 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#03.verified.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: "v1" +kind: "Service" +metadata: + name: "api-service" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" +spec: + type: "ClusterIP" + selector: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" + ports: + - name: "http" + protocol: "TCP" + port: 8000 + targetPort: 8000 diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#04.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#04.verified.yaml new file mode 100644 index 00000000000..2b756089179 --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#04.verified.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "api-config" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "api" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + PORT: "{{ .Values.config.api.PORT }}" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#05.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#05.verified.yaml new file mode 100644 index 00000000000..7abdfd9076b --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#05.verified.yaml @@ -0,0 +1,40 @@ +--- +apiVersion: "apps/v1" +kind: "Deployment" +metadata: + name: "gateway-deployment" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "gateway" + app.kubernetes.io/instance: "{{ .Release.Name }}" +spec: + template: + metadata: + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "gateway" + app.kubernetes.io/instance: "{{ .Release.Name }}" + spec: + containers: + - image: "nginx:latest" + name: "gateway" + envFrom: + - configMapRef: + name: "gateway-config" + ports: + - name: "http" + protocol: "TCP" + containerPort: 8080 + imagePullPolicy: "IfNotPresent" + selector: + matchLabels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "gateway" + app.kubernetes.io/instance: "{{ .Release.Name }}" + replicas: 1 + revisionHistoryLimit: 3 + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: "RollingUpdate" diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#06.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#06.verified.yaml new file mode 100644 index 00000000000..190928c781d --- /dev/null +++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesPublisherTests.KubernetesMapsPortsForBaitAndSwitchResources#06.verified.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: "v1" +kind: "ConfigMap" +metadata: + name: "gateway-config" + labels: + app.kubernetes.io/name: "aspire-hosting-tests" + app.kubernetes.io/component: "gateway" + app.kubernetes.io/instance: "{{ .Release.Name }}" +data: + API_HTTP: "{{ .Values.config.gateway.API_HTTP }}" + services__api__http__0: "{{ .Values.config.gateway.services__api__http__0 }}" From 97af3b02c4e57ea2ef6d8f236fe6f9ee8da4e02c Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 24 Feb 2026 11:33:41 -0800 Subject: [PATCH 165/256] Fix Windows bundle build: add Bundle.proj payload step to BuildAndTest pipeline (#14650) The Windows build in BuildAndTest.yml was missing the Bundle.proj step that Linux/macOS have in build_sign_native.yml, producing an unbundled CLI binary. - Add Bundle.proj payload build step per target RID before the main build - Default BundlePayloadPath to convention path in Common.projitems - Fail the build with clear error if bundle payload is missing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/clipack/Common.projitems | 11 +++++++++++ eng/pipelines/templates/BuildAndTest.yml | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/eng/clipack/Common.projitems b/eng/clipack/Common.projitems index 137c5532b8a..5e67c75bbc5 100644 --- a/eng/clipack/Common.projitems +++ b/eng/clipack/Common.projitems @@ -38,6 +38,17 @@ + + + $(RepoRoot)artifacts/bundle/aspire-ci-bundlepayload-$(CliRuntime).tar.gz + diff --git a/eng/pipelines/templates/BuildAndTest.yml b/eng/pipelines/templates/BuildAndTest.yml index 04e532ea7c2..ab5a2b4977c 100644 --- a/eng/pipelines/templates/BuildAndTest.yml +++ b/eng/pipelines/templates/BuildAndTest.yml @@ -41,6 +41,20 @@ parameters: steps: # Internal pipeline: Build with pack+sign - ${{ if ne(parameters.runAsPublic, 'true') }}: + # Build bundle payload (aspire-managed) for each target RID before the main build + - ${{ each targetRid in parameters.targetRids }}: + - script: ${{ parameters.dotnetScript }} + msbuild + $(Build.SourcesDirectory)/eng/Bundle.proj + /restore + /p:Configuration=${{ parameters.buildConfig }} + /p:TargetRid=${{ targetRid }} + /p:BundleVersion=ci-bundlepayload + /p:SkipNativeBuild=true + /p:ContinuousIntegrationBuild=true + /bl:${{ parameters.repoLogPath }}/BundlePayload-${{ targetRid }}.binlog + displayName: 🟣Build bundle payload (${{ targetRid }}) + - script: ${{ parameters.buildScript }} -restore -build -pack From e50fec937103eef34d4bf90247f0a31501549b3a Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 24 Feb 2026 16:00:37 -0500 Subject: [PATCH 166/256] re-build schema and rename from .NET Aspire (#14655) --- .../aspire-global-settings.schema.json | 158 +++++++----------- extension/schemas/aspire-settings.schema.json | 158 +++++++----------- extension/scripts/generate-schema.js | 4 +- 3 files changed, 132 insertions(+), 188 deletions(-) diff --git a/extension/schemas/aspire-global-settings.schema.json b/extension/schemas/aspire-global-settings.schema.json index bf1ec2494b1..a6b10654fbb 100644 --- a/extension/schemas/aspire-global-settings.schema.json +++ b/extension/schemas/aspire-global-settings.schema.json @@ -3,7 +3,7 @@ "$id": "https://json.schemastore.org/aspire-global-settings.json", "type": "object", "title": "Aspire Global Settings", - "description": "Global configuration file for .NET Aspire CLI (~/.aspire/settings.json)", + "description": "Aspire CLI global configuration file (~/.aspire/settings.json)", "properties": { "channel": { "description": "The Aspire channel to use for package resolution (e.g., \"stable\", \"preview\", \"staging\"). Used by aspire add to determine which NuGet feed to use.", @@ -61,6 +61,70 @@ "description": "Enable or disable the 'aspire exec' command for executing commands inside running resources", "default": false }, + "experimentalPolyglot:go": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Go language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + }, + "experimentalPolyglot:java": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Java language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + }, + "experimentalPolyglot:python": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Python language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + }, + "experimentalPolyglot:rust": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Rust language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + }, "minimumSdkCheckEnabled": { "anyOf": [ { @@ -125,77 +189,6 @@ "description": "Enable or disable support for non-.NET (polyglot) languages and runtimes in Aspire applications", "default": false }, - "experimentalPolyglot": { - "description": "Per-language feature flags for experimental polyglot languages (requires polyglotSupportEnabled).", - "type": "object", - "properties": { - "go": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ - "true", - "false" - ] - } - ], - "description": "Enable or disable experimental Go language support for polyglot Aspire applications (requires polyglotSupportEnabled)", - "default": false - }, - "java": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ - "true", - "false" - ] - } - ], - "description": "Enable or disable experimental Java language support for polyglot Aspire applications (requires polyglotSupportEnabled)", - "default": false - }, - "python": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ - "true", - "false" - ] - } - ], - "description": "Enable or disable experimental Python language support for polyglot Aspire applications (requires polyglotSupportEnabled)", - "default": false - }, - "rust": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ - "true", - "false" - ] - } - ], - "description": "Enable or disable experimental Rust language support for polyglot Aspire applications (requires polyglotSupportEnabled)", - "default": false - } - }, - "additionalProperties": false - }, "runningInstanceDetectionEnabled": { "anyOf": [ { @@ -294,27 +287,6 @@ "sdkVersion": { "description": "The Aspire SDK version used for this polyglot AppHost project. Determines the version of Aspire.Hosting packages to use.", "type": "string" - }, - "overrideStagingFeed": { - "description": "[Internal] Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", - "type": "string" - }, - "overrideStagingQuality": { - "description": "[Internal] Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", - "type": "string", - "enum": [ - "Stable", - "Prerelease", - "Both" - ] - }, - "stagingPinToCliVersion": { - "description": "[Internal] When set to \"true\" and using the staging channel with Prerelease quality on the shared feed, all template and integration packages are pinned to the exact version of the installed CLI. This bypasses NuGet search entirely, ensuring version consistency.", - "type": "string", - "enum": [ - "true", - "false" - ] } }, "additionalProperties": false diff --git a/extension/schemas/aspire-settings.schema.json b/extension/schemas/aspire-settings.schema.json index bd71628f932..080a104514e 100644 --- a/extension/schemas/aspire-settings.schema.json +++ b/extension/schemas/aspire-settings.schema.json @@ -3,7 +3,7 @@ "$id": "https://json.schemastore.org/aspire-settings.json", "type": "object", "title": "Aspire Local Settings", - "description": "Configuration file for .NET Aspire application host (.aspire/settings.json)", + "description": "Aspire CLI local configuration file (.aspire/settings.json)", "properties": { "appHostPath": { "description": "The path to the AppHost entry point file (e.g., \"Program.cs\", \"app.ts\"). Relative to the directory containing .aspire/settings.json.", @@ -65,6 +65,70 @@ "description": "Enable or disable the 'aspire exec' command for executing commands inside running resources", "default": false }, + "experimentalPolyglot:go": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Go language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + }, + "experimentalPolyglot:java": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Java language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + }, + "experimentalPolyglot:python": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Python language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + }, + "experimentalPolyglot:rust": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "Enable or disable experimental Rust language support for polyglot Aspire applications (requires polyglotSupportEnabled)", + "default": false + }, "minimumSdkCheckEnabled": { "anyOf": [ { @@ -129,77 +193,6 @@ "description": "Enable or disable support for non-.NET (polyglot) languages and runtimes in Aspire applications", "default": false }, - "experimentalPolyglot": { - "description": "Per-language feature flags for experimental polyglot languages (requires polyglotSupportEnabled).", - "type": "object", - "properties": { - "go": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ - "true", - "false" - ] - } - ], - "description": "Enable or disable experimental Go language support for polyglot Aspire applications (requires polyglotSupportEnabled)", - "default": false - }, - "java": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ - "true", - "false" - ] - } - ], - "description": "Enable or disable experimental Java language support for polyglot Aspire applications (requires polyglotSupportEnabled)", - "default": false - }, - "python": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ - "true", - "false" - ] - } - ], - "description": "Enable or disable experimental Python language support for polyglot Aspire applications (requires polyglotSupportEnabled)", - "default": false - }, - "rust": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ - "true", - "false" - ] - } - ], - "description": "Enable or disable experimental Rust language support for polyglot Aspire applications (requires polyglotSupportEnabled)", - "default": false - } - }, - "additionalProperties": false - }, "runningInstanceDetectionEnabled": { "anyOf": [ { @@ -298,27 +291,6 @@ "sdkVersion": { "description": "The Aspire SDK version used for this polyglot AppHost project. Determines the version of Aspire.Hosting packages to use.", "type": "string" - }, - "overrideStagingFeed": { - "description": "[Internal] Override the NuGet feed URL used by the staging channel. When set, this URL is used instead of the default SHA-based build-specific feed.", - "type": "string" - }, - "overrideStagingQuality": { - "description": "[Internal] Override the package quality filter for the staging channel. Set to \"Prerelease\" when staging builds are not yet marked as stable to use the shared daily feed instead of the SHA-based feed. Valid values: \"Stable\", \"Prerelease\", \"Both\".", - "type": "string", - "enum": [ - "Stable", - "Prerelease", - "Both" - ] - }, - "stagingPinToCliVersion": { - "description": "[Internal] When set to \"true\" and using the staging channel with Prerelease quality on the shared feed, all template and integration packages are pinned to the exact version of the installed CLI. This bypasses NuGet search entirely, ensuring version consistency.", - "type": "string", - "enum": [ - "true", - "false" - ] } }, "additionalProperties": false diff --git a/extension/scripts/generate-schema.js b/extension/scripts/generate-schema.js index 2c4e40c1925..d81c3568f6d 100755 --- a/extension/scripts/generate-schema.js +++ b/extension/scripts/generate-schema.js @@ -41,7 +41,7 @@ try { const localSchema = generateJsonSchema(configInfo, configInfo.LocalSettingsSchema, { id: 'https://json.schemastore.org/aspire-settings.json', title: 'Aspire Local Settings', - description: 'Configuration file for .NET Aspire application host (.aspire/settings.json)' + description: 'Aspire CLI local configuration file (.aspire/settings.json)' }); fs.writeFileSync(localSchemaOutputPath, JSON.stringify(localSchema, null, 2), 'utf8'); console.log(`✓ Local schema generated: ${localSchemaOutputPath}`); @@ -51,7 +51,7 @@ try { const globalSchema = generateJsonSchema(configInfo, configInfo.GlobalSettingsSchema, { id: 'https://json.schemastore.org/aspire-global-settings.json', title: 'Aspire Global Settings', - description: 'Global configuration file for .NET Aspire CLI (~/.aspire/settings.json)' + description: 'Aspire CLI global configuration file (~/.aspire/settings.json)' }); fs.writeFileSync(globalSchemaOutputPath, JSON.stringify(globalSchema, null, 2), 'utf8'); console.log(`✓ Global schema generated: ${globalSchemaOutputPath}`); From 0b99806d0c2071f950f6bf0b50ced65f8f016cbe Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 25 Feb 2026 07:04:24 +0800 Subject: [PATCH 167/256] Fix LogsCommand to sort results by timestamp (#14645) --- src/Aspire.Cli/Commands/LogsCommand.cs | 109 ++++----- .../AuxiliaryBackchannelRpcTarget.cs | 193 +++++----------- .../Commands/LogsCommandTests.cs | 73 +++--- .../AuxiliaryBackchannelRpcTargetTests.cs | 214 ++++++++++++++++++ 4 files changed, 349 insertions(+), 240 deletions(-) diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs index b6412a0aced..9483fa44cf7 100644 --- a/src/Aspire.Cli/Commands/LogsCommand.cs +++ b/src/Aspire.Cli/Commands/LogsCommand.cs @@ -3,7 +3,6 @@ using System.CommandLine; using System.Globalization; -using System.Runtime.CompilerServices; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; @@ -225,42 +224,27 @@ private async Task ExecuteGetAsync( IReadOnlyList snapshots, CancellationToken cancellationToken) { - // Collect all logs - List logLines; - if (!tail.HasValue) - { - logLines = await CollectLogsAsync(connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false); - } - else - { - // With tail specified, collect all logs first then take last N - logLines = await CollectLogsAsync(connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false); + // Collect all logs, parsing into LogEntry with resolved resource names sorted by timestamp + var entries = await CollectLogsAsync(connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false); - // Apply tail filter (tail.Value is guaranteed >= 1 by earlier validation) - if (logLines.Count > tail.Value) - { - logLines = logLines.Skip(logLines.Count - tail.Value).ToList(); - } + // Apply tail filter (tail.Value is guaranteed >= 1 by earlier validation) + if (tail.HasValue && entries.Count > tail.Value) + { + entries = entries.Skip(entries.Count - tail.Value).ToList(); } // Output the logs - var logParser = new LogParser(ConsoleColor.Black); - if (format == OutputFormat.Json) { // Wrapped JSON for snapshot - single JSON object compatible with jq var logsOutput = new LogsOutput { - Logs = logLines.Select(l => + Logs = entries.Select(entry => new LogLineJson { - var entry = logParser.CreateLogEntry(l.Content, l.IsError, l.ResourceName); - return new LogLineJson - { - ResourceName = ResolveResourceName(l.ResourceName, snapshots), - Timestamp = timestamps && entry.Timestamp.HasValue ? FormatTimestamp(entry.Timestamp.Value) : null, - Content = entry.Content ?? l.Content, - IsError = l.IsError - }; + ResourceName = entry.ResourcePrefix ?? string.Empty, + Timestamp = timestamps && entry.Timestamp.HasValue ? FormatTimestamp(entry.Timestamp.Value) : null, + Content = entry.Content ?? entry.RawContent ?? string.Empty, + IsError = entry.Type == LogEntryType.Error }).ToArray() }; var json = JsonSerializer.Serialize(logsOutput, LogsCommandJsonContext.Snapshot.LogsOutput); @@ -269,9 +253,9 @@ private async Task ExecuteGetAsync( } else { - foreach (var logLine in logLines) + foreach (var entry in entries) { - OutputLogLine(logLine, format, timestamps, logParser, snapshots); + OutputLogLine(entry, format, timestamps); } } @@ -292,78 +276,63 @@ private async Task ExecuteWatchAsync( // If tail is specified, show last N lines first before streaming if (tail.HasValue) { - var historicalLogs = await CollectLogsAsync(connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false); + var entries = await CollectLogsAsync(connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false); // Output last N lines - var tailedLogs = historicalLogs.Count > tail.Value - ? historicalLogs.Skip(historicalLogs.Count - tail.Value) - : historicalLogs; + var tailedEntries = entries.Count > tail.Value + ? entries.Skip(entries.Count - tail.Value) + : entries; - foreach (var logLine in tailedLogs) + foreach (var entry in tailedEntries) { - OutputLogLine(logLine, format, timestamps, logParser, snapshots); + OutputLogLine(entry, format, timestamps); } } // Now stream new logs await foreach (var logLine in connection.GetResourceLogsAsync(resourceName, follow: true, cancellationToken).ConfigureAwait(false)) { - OutputLogLine(logLine, format, timestamps, logParser, snapshots); + var entry = ParseLogLine(logLine, logParser, snapshots); + OutputLogLine(entry, format, timestamps); } return ExitCodeConstants.Success; } /// - /// Collects all logs for a resource (or all resources if resourceName is null) into a list. + /// Collects all logs for a resource (or all resources if resourceName is null), parsing each + /// into a with the resolved resource name set on + /// and returning entries sorted by timestamp. /// - private static async Task> CollectLogsAsync( + private static async Task> CollectLogsAsync( IAppHostAuxiliaryBackchannel connection, string? resourceName, IReadOnlyList snapshots, CancellationToken cancellationToken) { - var logLines = new List(); - await foreach (var logLine in GetLogsAsync(connection, resourceName, snapshots, cancellationToken).ConfigureAwait(false)) + var logParser = new LogParser(ConsoleColor.Black); + var logEntries = new LogEntries(int.MaxValue) { BaseLineNumber = 1 }; + await foreach (var logLine in connection.GetResourceLogsAsync(resourceName, follow: false, cancellationToken).ConfigureAwait(false)) { - logLines.Add(logLine); + logEntries.InsertSorted(ParseLogLine(logLine, logParser, snapshots)); } - return logLines; + return logEntries.GetEntries(); } /// - /// Gets logs for a resource (or all resources if resourceName is null) as an async enumerable. + /// Parses a into a with the resolved resource name + /// set on . /// - private static async IAsyncEnumerable GetLogsAsync( - IAppHostAuxiliaryBackchannel connection, - string? resourceName, - IReadOnlyList snapshots, - [EnumeratorCancellation] CancellationToken cancellationToken) + private static LogEntry ParseLogLine(ResourceLogLine logLine, LogParser logParser, IReadOnlyList snapshots) { - if (resourceName is not null) - { - await foreach (var logLine in connection.GetResourceLogsAsync(resourceName, follow: false, cancellationToken).ConfigureAwait(false)) - { - yield return logLine; - } - yield break; - } - - // Get all resources and stream logs for each (like docker compose logs) - foreach (var snapshot in snapshots.OrderBy(s => s.Name)) - { - await foreach (var logLine in connection.GetResourceLogsAsync(snapshot.Name, follow: false, cancellationToken).ConfigureAwait(false)) - { - yield return logLine; - } - } + var resolvedName = ResolveResourceName(logLine.ResourceName, snapshots); + return logParser.CreateLogEntry(logLine.Content, logLine.IsError, resolvedName); } - private void OutputLogLine(ResourceLogLine logLine, OutputFormat format, bool timestamps, LogParser logParser, IReadOnlyList snapshots) + private void OutputLogLine(LogEntry entry, OutputFormat format, bool timestamps) { - var displayName = ResolveResourceName(logLine.ResourceName, snapshots); - var entry = logParser.CreateLogEntry(logLine.Content, logLine.IsError, logLine.ResourceName); - var content = entry.Content ?? logLine.Content; + var displayName = entry.ResourcePrefix ?? string.Empty; + var content = entry.Content ?? entry.RawContent ?? string.Empty; var timestampPrefix = timestamps && entry.Timestamp.HasValue ? FormatTimestamp(entry.Timestamp.Value) + " " : string.Empty; if (format == OutputFormat.Json) @@ -374,7 +343,7 @@ private void OutputLogLine(ResourceLogLine logLine, OutputFormat format, bool ti ResourceName = displayName, Timestamp = timestamps && entry.Timestamp.HasValue ? FormatTimestamp(entry.Timestamp.Value) : null, Content = content, - IsError = logLine.IsError + IsError = entry.Type == LogEntryType.Error }; var output = JsonSerializer.Serialize(logLineJson, LogsCommandJsonContext.Ndjson.LogLineJson); // Structured output always goes to stdout. diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index 9596720b7e6..da94a18f547 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text.Json; +using System.Threading.Channels; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dashboard; using Microsoft.Extensions.Configuration; @@ -653,168 +654,90 @@ public async IAsyncEnumerable GetResourceLogsAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) { var resourceLoggerService = serviceProvider.GetRequiredService(); - var appModel = serviceProvider.GetService(); + var appModel = serviceProvider.GetRequiredService(); + + // Step 1: Calculate the resource names + var resourcesToLog = new List(); if (resourceName is not null) { - // Look up the resource from the app model to get resolved DCP resource names - var resource = appModel?.Resources.FirstOrDefault(r => StringComparers.ResourceName.Equals(r.Name, resourceName)); - - // Get the resolved resource names (DCP names for replicas) - var resolvedNames = resource?.GetResolvedResourceNames() ?? [resourceName]; - var hasReplicas = resolvedNames.Length > 1; - - if (hasReplicas && follow) + var resource = appModel.Resources.FirstOrDefault(r => StringComparers.ResourceName.Equals(r.Name, resourceName)); + if (resource is null) { - // For replicas in follow mode, watch each replica individually to preserve source - var channel = System.Threading.Channels.Channel.CreateUnbounded(); - var watchTasks = new List(); + logger.LogWarning("Resource '{ResourceName}' not found. No logs will be returned.", resourceName); + yield break; + } - foreach (var dcpName in resolvedNames) + resourcesToLog.AddRange(resource.GetResolvedResourceNames()); + } + else + { + foreach (var resource in appModel.Resources) + { + // Skip the dashboard + if (StringComparers.ResourceName.Equals(resource.Name, KnownResourceNames.AspireDashboard)) { - var name = dcpName; - var task = Task.Run(async () => - { - try - { - await foreach (var batch in resourceLoggerService.WatchAsync(name).WithCancellation(cancellationToken).ConfigureAwait(false)) - { - foreach (var logLine in batch) - { - await channel.Writer.WriteAsync(new ResourceLogLine - { - ResourceName = name, - LineNumber = logLine.LineNumber, - Content = logLine.Content, - IsError = logLine.IsErrorMessage - }, cancellationToken).ConfigureAwait(false); - } - } - } - catch (OperationCanceledException) - { - // Expected when cancelled - } - catch (Exception ex) - { - logger.LogDebug(ex, "Error watching logs for resource {ResourceName}", name); - } - }, cancellationToken); - watchTasks.Add(task); + continue; } - _ = Task.WhenAll(watchTasks).ContinueWith(_ => channel.Writer.Complete(), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default); - - await foreach (var logLine in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) - { - yield return logLine; - } + resourcesToLog.AddRange(resource.GetResolvedResourceNames()); } - else if (hasReplicas) + } + + if (resourcesToLog.Count == 0) + { + yield break; + } + + // Step 2: Stream logs from all resources in parallel via a channel. + var channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + var tasks = new List(); + + foreach (var name in resourcesToLog) + { + var task = Task.Run(async () => { - // For replicas in snapshot mode, get logs from each replica individually - foreach (var dcpName in resolvedNames) + try { - await foreach (var batch in resourceLoggerService.GetAllAsync(dcpName).WithCancellation(cancellationToken).ConfigureAwait(false)) + var logStream = follow + ? resourceLoggerService.WatchAsync(name) + : resourceLoggerService.GetAllAsync(name); + + await foreach (var batch in logStream.WithCancellation(cancellationToken).ConfigureAwait(false)) { foreach (var logLine in batch) { - yield return new ResourceLogLine + await channel.Writer.WriteAsync(new ResourceLogLine { - ResourceName = dcpName, + ResourceName = name, LineNumber = logLine.LineNumber, Content = logLine.Content, IsError = logLine.IsErrorMessage - }; + }, cancellationToken).ConfigureAwait(false); } } } - } - else - { - // Single resource (no replicas) - use original behavior - var logStream = follow - ? resourceLoggerService.WatchAsync(resolvedNames[0]) - : resourceLoggerService.GetAllAsync(resolvedNames[0]); - - await foreach (var batch in logStream.WithCancellation(cancellationToken).ConfigureAwait(false)) - { - foreach (var logLine in batch) - { - yield return new ResourceLogLine - { - ResourceName = resourceName, // Use app-model name for single resources - LineNumber = logLine.LineNumber, - Content = logLine.Content, - IsError = logLine.IsErrorMessage - }; - } - } - } - } - else if (follow && appModel is not null) - { - // Stream logs from all resources (only valid with follow=true) - // Create a merged stream from all resources - var channel = System.Threading.Channels.Channel.CreateUnbounded(); - - // Start watching all resources in parallel, using DCP names for replicas - var watchTasks = new List(); - foreach (var resource in appModel.Resources) - { - // Skip the dashboard - if (StringComparers.ResourceName.Equals(resource.Name, KnownResourceNames.AspireDashboard)) + catch (OperationCanceledException) { - continue; + // Expected when cancelled } - - var resolvedNames = resource.GetResolvedResourceNames(); - var hasReplicas = resolvedNames.Length > 1; - - foreach (var dcpName in resolvedNames) + catch (Exception ex) { - // Use DCP name for replicas, app-model name for single resources - var displayName = hasReplicas ? dcpName : resource.Name; - var name = dcpName; - var task = Task.Run(async () => - { - try - { - await foreach (var batch in resourceLoggerService.WatchAsync(name).WithCancellation(cancellationToken).ConfigureAwait(false)) - { - foreach (var logLine in batch) - { - await channel.Writer.WriteAsync(new ResourceLogLine - { - ResourceName = displayName, - LineNumber = logLine.LineNumber, - Content = logLine.Content, - IsError = logLine.IsErrorMessage - }, cancellationToken).ConfigureAwait(false); - } - } - } - catch (OperationCanceledException) - { - // Expected when cancelled - } - catch (Exception ex) - { - logger.LogDebug(ex, "Error watching logs for resource {ResourceName}", name); - } - }, cancellationToken); - watchTasks.Add(task); + logger.LogDebug(ex, "Error streaming logs for resource {ResourceName}", name); } - } + }, cancellationToken); + tasks.Add(task); + } - // Complete the channel when all watch tasks complete - _ = Task.WhenAll(watchTasks).ContinueWith(_ => channel.Writer.Complete(), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default); + _ = Task.WhenAll(tasks).ContinueWith(_ => channel.Writer.Complete(), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default); - // Yield log lines as they arrive - await foreach (var logLine in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) - { - yield return logLine; - } + await foreach (var logLine in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + yield return logLine; } } diff --git a/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs index 0e97fbf362f..e662f194421 100644 --- a/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/LogsCommandTests.cs @@ -388,13 +388,13 @@ public async Task LogsCommand_JsonOutput_ResolvesResourceNames() Assert.NotNull(logsOutput); Assert.Equal(3, logsOutput.Logs.Length); - // Resources are ordered alphabetically by Name in the output. - // Replicas share the same DisplayName, so the unique Name should be used instead - Assert.Equal("apiservice-abc123", logsOutput.Logs[0].ResourceName); - Assert.Equal("apiservice-def456", logsOutput.Logs[1].ResourceName); - + // Logs are sorted by timestamp. // Unique display name should be used for the redis resource - Assert.Equal("redis", logsOutput.Logs[2].ResourceName); + Assert.Equal("redis", logsOutput.Logs[0].ResourceName); + + // Replicas share the same DisplayName, so the unique Name should be used instead + Assert.Equal("apiservice-abc123", logsOutput.Logs[1].ResourceName); + Assert.Equal("apiservice-def456", logsOutput.Logs[2].ResourceName); } [Fact] @@ -470,20 +470,20 @@ public async Task LogsCommand_JsonOutput_WithTimestamps_IncludesTimestampField() Assert.NotNull(logsOutput); Assert.Equal(3, logsOutput.Logs.Length); - // Resources are ordered alphabetically by Name - Assert.Equal("apiservice-abc123", logsOutput.Logs[0].ResourceName); - Assert.Equal("2025-01-15T10:30:01.000Z", logsOutput.Logs[0].Timestamp); - Assert.Equal("Hello from replica 1", logsOutput.Logs[0].Content); + // Logs are sorted by timestamp + Assert.Equal("redis", logsOutput.Logs[0].ResourceName); + Assert.Equal("2025-01-15T10:30:00.000Z", logsOutput.Logs[0].Timestamp); + Assert.Equal("Ready to accept connections", logsOutput.Logs[0].Content); Assert.False(logsOutput.Logs[0].IsError); - Assert.Equal("apiservice-def456", logsOutput.Logs[1].ResourceName); - Assert.Equal("2025-01-15T10:30:02.000Z", logsOutput.Logs[1].Timestamp); - Assert.Equal("Hello from replica 2", logsOutput.Logs[1].Content); + Assert.Equal("apiservice-abc123", logsOutput.Logs[1].ResourceName); + Assert.Equal("2025-01-15T10:30:01.000Z", logsOutput.Logs[1].Timestamp); + Assert.Equal("Hello from replica 1", logsOutput.Logs[1].Content); Assert.False(logsOutput.Logs[1].IsError); - Assert.Equal("redis", logsOutput.Logs[2].ResourceName); - Assert.Equal("2025-01-15T10:30:00.000Z", logsOutput.Logs[2].Timestamp); - Assert.Equal("Ready to accept connections", logsOutput.Logs[2].Content); + Assert.Equal("apiservice-def456", logsOutput.Logs[2].ResourceName); + Assert.Equal("2025-01-15T10:30:02.000Z", logsOutput.Logs[2].Timestamp); + Assert.Equal("Hello from replica 2", logsOutput.Logs[2].Content); Assert.False(logsOutput.Logs[2].IsError); } @@ -509,19 +509,20 @@ public async Task LogsCommand_JsonOutput_WithoutTimestamps_OmitsTimestampField() Assert.Equal(3, logsOutput.Logs.Length); // Timestamp should be null when --timestamps is not specified - Assert.Equal("apiservice-abc123", logsOutput.Logs[0].ResourceName); + // Logs are sorted by timestamp + Assert.Equal("redis", logsOutput.Logs[0].ResourceName); Assert.Null(logsOutput.Logs[0].Timestamp); - Assert.Equal("Hello from replica 1", logsOutput.Logs[0].Content); + Assert.Equal("Ready to accept connections", logsOutput.Logs[0].Content); Assert.False(logsOutput.Logs[0].IsError); - Assert.Equal("apiservice-def456", logsOutput.Logs[1].ResourceName); + Assert.Equal("apiservice-abc123", logsOutput.Logs[1].ResourceName); Assert.Null(logsOutput.Logs[1].Timestamp); - Assert.Equal("Hello from replica 2", logsOutput.Logs[1].Content); + Assert.Equal("Hello from replica 1", logsOutput.Logs[1].Content); Assert.False(logsOutput.Logs[1].IsError); - Assert.Equal("redis", logsOutput.Logs[2].ResourceName); + Assert.Equal("apiservice-def456", logsOutput.Logs[2].ResourceName); Assert.Null(logsOutput.Logs[2].Timestamp); - Assert.Equal("Ready to accept connections", logsOutput.Logs[2].Content); + Assert.Equal("Hello from replica 2", logsOutput.Logs[2].Content); Assert.False(logsOutput.Logs[2].IsError); } @@ -542,12 +543,12 @@ public async Task LogsCommand_TextOutput_WithTimestamps_IncludesTimestampPrefix( Assert.Equal(ExitCodeConstants.Success, exitCode); - // Resources are ordered alphabetically by Name, timestamp prefix is ISO 8601 round-trip format + // Logs are sorted by timestamp, timestamp prefix is ISO 8601 round-trip format var logLines = outputWriter.Logs.Where(l => l.StartsWith("2025-", StringComparison.Ordinal)).ToList(); Assert.Equal(3, logLines.Count); - Assert.Equal("2025-01-15T10:30:01.000Z [apiservice-abc123] Hello from replica 1", logLines[0]); - Assert.Equal("2025-01-15T10:30:02.000Z [apiservice-def456] Hello from replica 2", logLines[1]); - Assert.Equal("2025-01-15T10:30:00.000Z [redis] Ready to accept connections", logLines[2]); + Assert.Equal("2025-01-15T10:30:00.000Z [redis] Ready to accept connections", logLines[0]); + Assert.Equal("2025-01-15T10:30:01.000Z [apiservice-abc123] Hello from replica 1", logLines[1]); + Assert.Equal("2025-01-15T10:30:02.000Z [apiservice-def456] Hello from replica 2", logLines[2]); } [Fact] @@ -568,11 +569,12 @@ public async Task LogsCommand_TextOutput_WithoutTimestamps_NoTimestampPrefix() Assert.Equal(ExitCodeConstants.Success, exitCode); // Without --timestamps, log lines start with "[resourceName]" with no timestamp prefix + // Logs are sorted by timestamp var logLines = outputWriter.Logs.Where(l => l.StartsWith("[", StringComparison.Ordinal)).ToList(); Assert.Equal(3, logLines.Count); - Assert.Equal("[apiservice-abc123] Hello from replica 1", logLines[0]); - Assert.Equal("[apiservice-def456] Hello from replica 2", logLines[1]); - Assert.Equal("[redis] Ready to accept connections", logLines[2]); + Assert.Equal("[redis] Ready to accept connections", logLines[0]); + Assert.Equal("[apiservice-abc123] Hello from replica 1", logLines[1]); + Assert.Equal("[apiservice-def456] Hello from replica 2", logLines[2]); } private ServiceProvider CreateLogsTestServices( @@ -617,25 +619,26 @@ private ServiceProvider CreateLogsTestServices( ], LogLines = [ + // Log lines are intentionally out of timestamp order to verify sorting new ResourceLogLine { - ResourceName = "redis", + ResourceName = "apiservice-def456", LineNumber = 1, - Content = "2025-01-15T10:30:00Z Ready to accept connections", + Content = "2025-01-15T10:30:02Z Hello from replica 2", IsError = false }, new ResourceLogLine { - ResourceName = "apiservice-abc123", + ResourceName = "redis", LineNumber = 1, - Content = "2025-01-15T10:30:01Z Hello from replica 1", + Content = "2025-01-15T10:30:00Z Ready to accept connections", IsError = false }, new ResourceLogLine { - ResourceName = "apiservice-def456", + ResourceName = "apiservice-abc123", LineNumber = 1, - Content = "2025-01-15T10:30:02Z Hello from replica 2", + Content = "2025-01-15T10:30:01Z Hello from replica 1", IsError = false } ] diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs index ac59779d551..ca09745ed9a 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs @@ -4,6 +4,7 @@ using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Hosting.Backchannel; @@ -197,4 +198,217 @@ await notificationService.PublishUpdateAsync(custom.Resource, s => s with private sealed class CustomResource(string name) : Resource(name) { } + + private sealed class FixedTimeProvider : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(2000, 12, 29, 20, 59, 59, TimeSpan.Zero); + } + + private const string TestTimestamp = "2000-12-29T20:59:59.0000000Z"; + + [Fact] + public async Task GetResourceLogsAsync_ReturnsLogs_ForSingleResource() + { + using var builder = TestDistributedApplicationBuilder.Create(outputHelper); + + builder.AddResource(new CustomResource("myresource")); + builder.AddResource(new CustomResource(KnownResourceNames.AspireDashboard)); + + using var app = builder.Build(); + + var resourceLoggerService = app.Services.GetRequiredService(); + resourceLoggerService.TimeProvider = new FixedTimeProvider(); + + await app.StartAsync(); + + var logger = resourceLoggerService.GetLogger("myresource"); + logger.LogInformation("Hello from myresource"); + resourceLoggerService.Complete("myresource"); + + var target = new AuxiliaryBackchannelRpcTarget( + NullLogger.Instance, + app.Services); + + var logs = new List(); + await foreach (var logLine in target.GetResourceLogsAsync("myresource", follow: false)) + { + logs.Add(logLine); + } + + var log = Assert.Single(logs); + Assert.Equal("myresource", log.ResourceName); + Assert.Equal($"{TestTimestamp} Hello from myresource", log.Content); + Assert.Equal(0, log.LineNumber); + Assert.False(log.IsError); + + await app.StopAsync(); + } + + [Fact] + public async Task GetResourceLogsAsync_ReturnsEmpty_WhenResourceNotFound() + { + using var builder = TestDistributedApplicationBuilder.Create(outputHelper); + builder.AddResource(new CustomResource("myresource")); + + using var app = builder.Build(); + await app.StartAsync(); + + var target = new AuxiliaryBackchannelRpcTarget( + NullLogger.Instance, + app.Services); + + var logs = new List(); + await foreach (var logLine in target.GetResourceLogsAsync("nonexistent", follow: false)) + { + logs.Add(logLine); + } + + Assert.Empty(logs); + + await app.StopAsync(); + } + + [Fact] + public async Task GetResourceLogsAsync_ReturnsLogsFromAllResources_WhenNoResourceNameSpecified() + { + using var builder = TestDistributedApplicationBuilder.Create(outputHelper); + + builder.AddResource(new CustomResource("resource1")); + builder.AddResource(new CustomResource("resource2")); + builder.AddResource(new CustomResource(KnownResourceNames.AspireDashboard)); + + using var app = builder.Build(); + + var resourceLoggerService = app.Services.GetRequiredService(); + resourceLoggerService.TimeProvider = new FixedTimeProvider(); + + await app.StartAsync(); + + var logger1 = resourceLoggerService.GetLogger("resource1"); + logger1.LogInformation("Log from resource1"); + resourceLoggerService.Complete("resource1"); + + var logger2 = resourceLoggerService.GetLogger("resource2"); + logger2.LogInformation("Log from resource2"); + resourceLoggerService.Complete("resource2"); + + var target = new AuxiliaryBackchannelRpcTarget( + NullLogger.Instance, + app.Services); + + var logs = new List(); + await foreach (var logLine in target.GetResourceLogsAsync(resourceName: null, follow: false)) + { + logs.Add(logLine); + } + + Assert.Equal(2, logs.Count); + + var log1 = Assert.Single(logs, l => l.ResourceName == "resource1"); + Assert.Equal($"{TestTimestamp} Log from resource1", log1.Content); + + var log2 = Assert.Single(logs, l => l.ResourceName == "resource2"); + Assert.Equal($"{TestTimestamp} Log from resource2", log2.Content); + + Assert.DoesNotContain(logs, l => l.ResourceName == KnownResourceNames.AspireDashboard); + + await app.StopAsync(); + } + + [Fact] + public async Task GetResourceLogsAsync_ReturnsLogsFromReplicas() + { + using var builder = TestDistributedApplicationBuilder.Create(outputHelper); + + var resourceWithReplicas = builder.AddResource(new CustomResource("myresource")); + resourceWithReplicas.WithAnnotation(new DcpInstancesAnnotation([ + new DcpInstance("myresource-abc123", "abc123", 0), + new DcpInstance("myresource-def456", "def456", 1) + ])); + + using var app = builder.Build(); + + var resourceLoggerService = app.Services.GetRequiredService(); + resourceLoggerService.TimeProvider = new FixedTimeProvider(); + + await app.StartAsync(); + + var logger1 = resourceLoggerService.GetLogger("myresource-abc123"); + logger1.LogInformation("Log from replica 1"); + resourceLoggerService.Complete("myresource-abc123"); + + var logger2 = resourceLoggerService.GetLogger("myresource-def456"); + logger2.LogInformation("Log from replica 2"); + resourceLoggerService.Complete("myresource-def456"); + + var target = new AuxiliaryBackchannelRpcTarget( + NullLogger.Instance, + app.Services); + + var logs = new List(); + await foreach (var logLine in target.GetResourceLogsAsync("myresource", follow: false)) + { + logs.Add(logLine); + } + + Assert.Equal(2, logs.Count); + + var replica1 = Assert.Single(logs, l => l.ResourceName == "myresource-abc123"); + Assert.Equal($"{TestTimestamp} Log from replica 1", replica1.Content); + + var replica2 = Assert.Single(logs, l => l.ResourceName == "myresource-def456"); + Assert.Equal($"{TestTimestamp} Log from replica 2", replica2.Content); + + await app.StopAsync(); + } + + [Fact] + public async Task GetResourceLogsAsync_FollowMode_StreamsLogs() + { + using var builder = TestDistributedApplicationBuilder.Create(outputHelper); + builder.AddResource(new CustomResource("myresource")); + + using var app = builder.Build(); + + var resourceLoggerService = app.Services.GetRequiredService(); + resourceLoggerService.TimeProvider = new FixedTimeProvider(); + + await app.StartAsync(); + + var target = new AuxiliaryBackchannelRpcTarget( + NullLogger.Instance, + app.Services); + + using var cts = new CancellationTokenSource(); + var logs = new List(); + + var collectTask = Task.Run(async () => + { + await foreach (var logLine in target.GetResourceLogsAsync("myresource", follow: true, cts.Token)) + { + logs.Add(logLine); + if (logs.Count >= 2) + { + break; + } + } + }); + + // Write logs after starting the watch + var logger = resourceLoggerService.GetLogger("myresource"); + logger.LogInformation("First log"); + logger.LogInformation("Second log"); + + await collectTask.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(2, logs.Count); + + Assert.Equal("myresource", logs[0].ResourceName); + Assert.Equal($"{TestTimestamp} First log", logs[0].Content); + + Assert.Equal("myresource", logs[1].ResourceName); + Assert.Equal($"{TestTimestamp} Second log", logs[1].Content); + + await app.StopAsync(); + } } From 89d4fc307d48c8454ff369f9749576630443c284 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 24 Feb 2026 18:09:57 -0500 Subject: [PATCH 168/256] Fix focus trap stack overflow when opening Manage Telemetry from Settings dialog (#14407) (#14654) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Components/Dialogs/SettingsDialog.razor.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor.cs index 462ab86dcb7..5977390f7e5 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor.cs @@ -30,6 +30,9 @@ public partial class SettingsDialog : IDialogContentComponent, IDisposable [Inject] public required DashboardDialogService DialogService { get; init; } + [CascadingParameter] + public FluentDialog Dialog { get; set; } = default!; + protected override void OnInitialized() { _languageOptions = GlobalizationHelpers.OrderedLocalizedCultures; @@ -88,6 +91,10 @@ private static void ValueChanged(string? value) private async Task LaunchManageDataAsync() { + // Close the Settings dialog first to avoid concurrent focus traps causing a + // "Maximum call stack size exceeded" error in the browser (see #14407). + await Dialog.CloseAsync(); + var parameters = new DialogParameters { Title = Loc[nameof(Dashboard.Resources.Dialogs.ManageDataDialogTitle)], From 7da2127604953c66c56d6fcf3afd4afef682a0fa Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:20:55 -0800 Subject: [PATCH 169/256] Fix CLI installation scripts to work when Aspire is running (#14334) Add backup/restore logic matching aspire update --self approach: - Backup existing CLI executable before extraction with timestamp - Restore from backup if extraction fails - Clean up old backup files on successful extraction Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- eng/scripts/get-aspire-cli-pr.ps1 | 176 ++++++++++++++++++++++++------ eng/scripts/get-aspire-cli.ps1 | 114 +++++++++++++++++++ 2 files changed, 257 insertions(+), 33 deletions(-) diff --git a/eng/scripts/get-aspire-cli-pr.ps1 b/eng/scripts/get-aspire-cli-pr.ps1 index 3e7258666e2..95b645e5ed1 100755 --- a/eng/scripts/get-aspire-cli-pr.ps1 +++ b/eng/scripts/get-aspire-cli-pr.ps1 @@ -369,6 +369,98 @@ function Get-RuntimeIdentifier { return "${computedTargetOS}-${computedTargetArch}" } +# Function to get the CLI executable path based on host OS +function Get-CliExecutablePath { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string]$DestinationPath + ) + + $exeName = if ($Script:HostOS -eq "win") { "aspire.exe" } else { "aspire" } + return Join-Path $DestinationPath $exeName +} + +# Function to backup existing CLI executable before overwriting +# This allows installation to proceed even when the CLI is running +# The running process still has a handle to the old file, but the file can be renamed +function Backup-ExistingCliExecutable { + [CmdletBinding(SupportsShouldProcess)] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string]$TargetExePath + ) + + if (Test-Path $TargetExePath) { + $unixTimestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $backupPath = "$TargetExePath.old.$unixTimestamp" + + if ($PSCmdlet.ShouldProcess($TargetExePath, "Backup to $backupPath")) { + Write-Message "Backing up existing CLI: $TargetExePath -> $backupPath" -Level Verbose + + # Rename existing executable to .old.[timestamp] + Move-Item -Path $TargetExePath -Destination $backupPath -Force + return $backupPath + } + } + + return $null +} + +# Function to restore CLI executable from backup if installation fails +function Restore-CliExecutableFromBackup { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$BackupPath, + + [Parameter(Mandatory = $true)] + [string]$TargetExePath + ) + + if ($PSCmdlet.ShouldProcess($BackupPath, "Restore to $TargetExePath")) { + Write-Message "Restoring CLI from backup: $BackupPath -> $TargetExePath" -Level Warning + + if (Test-Path $TargetExePath) { + Remove-Item -Path $TargetExePath -Force -ErrorAction SilentlyContinue + } + + Move-Item -Path $BackupPath -Destination $TargetExePath -Force + } +} + +# Function to clean up old backup files (aspire.exe.old.* or aspire.old.*) +function Remove-OldCliBackupFiles { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$TargetExePath + ) + + $directory = Split-Path -Parent $TargetExePath + if ([string]::IsNullOrEmpty($directory)) { + return + } + + $exeName = Split-Path -Leaf $TargetExePath + $searchPattern = "$exeName.old.*" + + $oldBackupFiles = Get-ChildItem -Path $directory -Filter $searchPattern -ErrorAction SilentlyContinue + foreach ($backupFile in $oldBackupFiles) { + if ($PSCmdlet.ShouldProcess($backupFile.FullName, "Delete old backup")) { + try { + Remove-Item -Path $backupFile.FullName -Force + Write-Message "Deleted old backup file: $($backupFile.FullName)" -Level Verbose + } + catch { + Write-Message "Failed to delete old backup file: $($backupFile.FullName) - $($_.Exception.Message)" -Level Verbose + } + } + } +} + function Expand-AspireCliArchive { [CmdletBinding(SupportsShouldProcess)] param( @@ -382,50 +474,68 @@ function Expand-AspireCliArchive { Write-Message "Unpacking archive to: $DestinationPath" -Level Verbose - # Create destination directory if it doesn't exist - if (-not (Test-Path $DestinationPath)) { - Write-Message "Creating destination directory: $DestinationPath" -Level Verbose - New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null - } + # Get the target executable path using shared function + $targetExePath = Get-CliExecutablePath -DestinationPath $DestinationPath + $backupPath = $null - Write-Message "Extracting archive: $ArchiveFile" -Level Verbose - # Check archive format based on file extension and extract accordingly - if ($ArchiveFile -match "\.zip$") { - # Use Expand-Archive for ZIP files - if (-not (Get-Command Expand-Archive -ErrorAction SilentlyContinue)) { - throw "Expand-Archive cmdlet not found. Please use PowerShell 5.0 or later to extract ZIP files." + try { + # Create destination directory if it doesn't exist + if (-not (Test-Path $DestinationPath)) { + Write-Message "Creating destination directory: $DestinationPath" -Level Verbose + New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null + } + else { + # Backup existing executable before extraction + # This allows installation to proceed even when the CLI is running + $backupPath = Backup-ExistingCliExecutable -TargetExePath $targetExePath } - try { + Write-Message "Extracting archive: $ArchiveFile" -Level Verbose + # Check archive format based on file extension and extract accordingly + if ($ArchiveFile -match "\.zip$") { + # Use Expand-Archive for ZIP files + if (-not (Get-Command Expand-Archive -ErrorAction SilentlyContinue)) { + throw "Expand-Archive cmdlet not found. Please use PowerShell 5.0 or later to extract ZIP files." + } + Expand-Archive -Path $ArchiveFile -DestinationPath $DestinationPath -Force } - catch { - throw "Failed to unpack archive: $($_.Exception.Message)" - } - } - elseif ($ArchiveFile -match "\.tar\.gz$") { - # Use tar for tar.gz files - if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { - throw "tar command not found. Please install tar to extract tar.gz files." - } + elseif ($ArchiveFile -match "\.tar\.gz$") { + # Use tar for tar.gz files + if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { + throw "tar command not found. Please install tar to extract tar.gz files." + } - $currentLocation = Get-Location - try { - Set-Location $DestinationPath - & tar -xzf $ArchiveFile - if ($LASTEXITCODE -ne 0) { - throw "Failed to extract tar.gz archive: $ArchiveFile. tar command returned exit code $LASTEXITCODE" + $currentLocation = Get-Location + try { + Set-Location $DestinationPath + & tar -xzf $ArchiveFile + if ($LASTEXITCODE -ne 0) { + throw "Failed to extract tar.gz archive: $ArchiveFile. tar command returned exit code $LASTEXITCODE" + } + } + finally { + Set-Location $currentLocation } } - finally { - Set-Location $currentLocation + else { + throw "Unsupported archive format: $ArchiveFile. Only .zip and .tar.gz files are supported." } + + # Clean up old backup files on successful extraction + if ($backupPath -and (Test-Path $targetExePath)) { + Remove-OldCliBackupFiles -TargetExePath $targetExePath + } + + Write-Message "Successfully unpacked archive" -Level Verbose } - else { - throw "Unsupported archive format: $ArchiveFile. Only .zip and .tar.gz files are supported." + catch { + # If anything goes wrong and we have a backup, restore it + if ($backupPath -and (Test-Path $backupPath)) { + Restore-CliExecutableFromBackup -BackupPath $backupPath -TargetExePath $targetExePath + } + throw "Failed to unpack archive: $($_.Exception.Message)" } - - Write-Message "Successfully unpacked archive" -Level Verbose } # Simplified installation path determination diff --git a/eng/scripts/get-aspire-cli.ps1 b/eng/scripts/get-aspire-cli.ps1 index 920bd3105d9..a8061e2f5d2 100755 --- a/eng/scripts/get-aspire-cli.ps1 +++ b/eng/scripts/get-aspire-cli.ps1 @@ -555,7 +555,103 @@ function Test-FileChecksum { } } +# Function to get the CLI executable path for a given OS +function Get-CliExecutablePath { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string]$DestinationPath, + + [Parameter(Mandatory = $true)] + [string]$OS + ) + + $exeName = if ($OS -eq "win") { "aspire.exe" } else { "aspire" } + return Join-Path $DestinationPath $exeName +} + +# Function to backup existing CLI executable before overwriting +# This allows installation to proceed even when the CLI is running +# The running process still has a handle to the old file, but the file can be renamed +function Backup-ExistingCliExecutable { + [CmdletBinding(SupportsShouldProcess)] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string]$TargetExePath + ) + + if (Test-Path $TargetExePath) { + $unixTimestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $backupPath = "$TargetExePath.old.$unixTimestamp" + + if ($PSCmdlet.ShouldProcess($TargetExePath, "Backup to $backupPath")) { + Write-Message "Backing up existing CLI: $TargetExePath -> $backupPath" -Level Verbose + + # Rename existing executable to .old.[timestamp] + Move-Item -Path $TargetExePath -Destination $backupPath -Force + return $backupPath + } + } + + return $null +} + +# Function to restore CLI executable from backup if installation fails +function Restore-CliExecutableFromBackup { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$BackupPath, + + [Parameter(Mandatory = $true)] + [string]$TargetExePath + ) + + if ($PSCmdlet.ShouldProcess($BackupPath, "Restore to $TargetExePath")) { + Write-Message "Restoring CLI from backup: $BackupPath -> $TargetExePath" -Level Warning + + if (Test-Path $TargetExePath) { + Remove-Item -Path $TargetExePath -Force -ErrorAction SilentlyContinue + } + + Move-Item -Path $BackupPath -Destination $TargetExePath -Force + } +} + +# Function to clean up old backup files (aspire.exe.old.* or aspire.old.*) +function Remove-OldCliBackupFiles { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$TargetExePath + ) + + $directory = Split-Path -Parent $TargetExePath + if ([string]::IsNullOrEmpty($directory)) { + return + } + + $exeName = Split-Path -Leaf $TargetExePath + $searchPattern = "$exeName.old.*" + + $oldBackupFiles = Get-ChildItem -Path $directory -Filter $searchPattern -ErrorAction SilentlyContinue + foreach ($backupFile in $oldBackupFiles) { + if ($PSCmdlet.ShouldProcess($backupFile.FullName, "Delete old backup")) { + try { + Remove-Item -Path $backupFile.FullName -Force + Write-Message "Deleted old backup file: $($backupFile.FullName)" -Level Verbose + } + catch { + Write-Message "Failed to delete old backup file: $($backupFile.FullName) - $($_.Exception.Message)" -Level Verbose + } + } + } +} + function Expand-AspireCliArchive { + [CmdletBinding(SupportsShouldProcess)] param( [string]$ArchiveFile, [string]$DestinationPath, @@ -564,12 +660,21 @@ function Expand-AspireCliArchive { Write-Message "Unpacking archive to: $DestinationPath" -Level Verbose + # Get the target executable path using shared function + $targetExePath = Get-CliExecutablePath -DestinationPath $DestinationPath -OS $OS + $backupPath = $null + try { # Create destination directory if it doesn't exist if (-not (Test-Path $DestinationPath)) { Write-Message "Creating destination directory: $DestinationPath" -Level Verbose New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null } + else { + # Backup existing executable before extraction + # This allows installation to proceed even when the CLI is running + $backupPath = Backup-ExistingCliExecutable -TargetExePath $targetExePath + } if ($OS -eq "win") { # Use Expand-Archive for ZIP files on Windows @@ -595,9 +700,18 @@ function Expand-AspireCliArchive { } } + # Clean up old backup files on successful extraction + if ($backupPath -and (Test-Path $targetExePath)) { + Remove-OldCliBackupFiles -TargetExePath $targetExePath + } + Write-Message "Successfully unpacked archive" -Level Verbose } catch { + # If anything goes wrong and we have a backup, restore it + if ($backupPath -and (Test-Path $backupPath)) { + Restore-CliExecutableFromBackup -BackupPath $backupPath -TargetExePath $targetExePath + } throw "Failed to unpack archive: $($_.Exception.Message)" } } From ab7b9ca15f227b08a51fbe0868891b7944e75347 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 25 Feb 2026 12:03:09 +1100 Subject: [PATCH 170/256] Add backchannel log streaming with replay buffer and concurrent subscriber support (#14512) Add server-side plumbing for TUI monitor support (13.3 preparation): - BackchannelLoggerProvider: Add circular replay buffer (1000 entries) with pub-sub subscriber model. Subscribe() atomically returns a snapshot of buffered entries plus a per-subscriber channel for live entries, enabling multiple concurrent TUI clients without entry loss or draining. - AppHostRpcTarget: Add GetAppHostLogEntriesAsync() that replays buffered entries then streams live entries via per-subscriber channel. Uses subscribe/unsubscribe lifecycle with proper cleanup in finally block. - AuxiliaryBackchannelRpcTarget: Add GetAppHostLogEntriesAsync() delegate that forwards to AppHostRpcTarget for auxiliary backchannel clients. - DistributedApplicationBuilder: Register BackchannelLoggerProvider as concrete singleton with ILoggerProvider forwarding, enabling direct resolution by AppHostRpcTarget. - Tests: 4 new tests covering replay buffer fill, eviction at capacity, snapshot isolation, and concurrent subscriber fan-out. Co-authored-by: Mitch Denny Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Backchannel/AppHostRpcTarget.cs | 40 +++--- .../AuxiliaryBackchannelRpcTarget.cs | 10 ++ .../Backchannel/BackchannelLoggerProvider.cs | 83 ++++++++----- .../DistributedApplicationBuilder.cs | 3 +- .../BackchannelLoggerProviderTests.cs | 114 ++++++++++++++++++ 5 files changed, 193 insertions(+), 57 deletions(-) create mode 100644 tests/Aspire.Hosting.Tests/Backchannel/BackchannelLoggerProviderTests.cs diff --git a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs index 06a99cb3027..08e0081bb2a 100644 --- a/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AppHostRpcTarget.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Runtime.CompilerServices; -using System.Threading.Channels; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Exec; using Aspire.Hosting.Pipelines; @@ -20,45 +19,40 @@ internal class AppHostRpcTarget( IHostApplicationLifetime lifetime, DistributedApplicationOptions options) { - private readonly TaskCompletionSource> _logChannelTcs = new(); private readonly CancellationTokenSource _shutdownCts = new(); - public void RegisterLogChannel(Channel channel) - { - ArgumentNullException.ThrowIfNull(channel); - _logChannelTcs.TrySetResult(channel); - } - public async IAsyncEnumerable GetAppHostLogEntriesAsync([EnumeratorCancellation] CancellationToken cancellationToken) { // Create a linked token source that will be cancelled when shutdown is requested using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdownCts.Token); var linkedToken = linkedCts.Token; - Channel? channel = null; - - try - { - channel = await _logChannelTcs.Task.WaitAsync(linkedToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (_shutdownCts.Token.IsCancellationRequested) + var loggerProvider = serviceProvider.GetService(); + if (loggerProvider is null) { - // Gracefully handle cancellation due to shutdown - logger.LogDebug("Log entries stream cancelled due to AppHost shutdown before channel was ready"); yield break; } - var logEntries = channel.Reader.ReadAllAsync(linkedToken); + // Subscribe atomically: snapshot + channel for new entries, no gap + var (snapshot, subscriberId, channel) = loggerProvider.Subscribe(); - await foreach (var logEntry in logEntries.WithCancellation(linkedToken).ConfigureAwait(false)) + try { - // If the log entry is null, terminate the stream - if (logEntry == null) + // Replay buffered entries first so late-connecting clients see history + foreach (var entry in snapshot) { - yield break; + yield return entry; } - yield return logEntry; + // Stream live entries + await foreach (var entry in channel.Reader.ReadAllAsync(linkedToken).ConfigureAwait(false)) + { + yield return entry; + } + } + finally + { + loggerProvider.Unsubscribe(subscriberId); } } diff --git a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index da94a18f547..2c86eacdcbe 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -911,6 +911,16 @@ private HttpClientTransport CreateHttpClientTransport(Uri endpointUri) #endregion + /// + /// Streams AppHost log entries from the hosting process. + /// Delegates to . + /// + public IAsyncEnumerable GetAppHostLogEntriesAsync(CancellationToken cancellationToken = default) + { + var rpcTarget = serviceProvider.GetRequiredService(); + return rpcTarget.GetAppHostLogEntriesAsync(cancellationToken); + } + /// /// Converts a JsonElement to its underlying CLR type for proper serialization. /// diff --git a/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs b/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs index c05564f80db..4211d04234b 100644 --- a/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs +++ b/src/Aspire.Hosting/Backchannel/BackchannelLoggerProvider.cs @@ -2,64 +2,81 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Threading.Channels; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Backchannel; internal class BackchannelLoggerProvider : ILoggerProvider { - private readonly Channel _channel = Channel.CreateUnbounded(); - private readonly IServiceProvider _serviceProvider; - private readonly object _channelRegisteredLock = new(); - private readonly CancellationTokenSource _backgroundChannelRegistrationCts = new(); - private Task? _backgroundChannelRegistrationTask; + private readonly Queue _replayBuffer = new(); + private readonly object _lock = new(); + private readonly Dictionary> _subscribers = []; + private int _nextSubscriberId; + private const int MaxReplayEntries = 1000; - public BackchannelLoggerProvider(IServiceProvider serviceProvider) + /// + /// Gets a snapshot of buffered log entries and subscribes for new entries. + /// The returned channel receives all future log entries until disposed. + /// + internal (List Snapshot, int SubscriberId, Channel Channel) Subscribe() { - ArgumentNullException.ThrowIfNull(serviceProvider); - _serviceProvider = serviceProvider; + var channel = Channel.CreateUnbounded(); + lock (_lock) + { + var id = _nextSubscriberId++; + _subscribers[id] = channel; + // Snapshot under lock so no entries are missed between snapshot and subscribe + return ([.. _replayBuffer], id, channel); + } } - private void RegisterLogChannel() + internal void Unsubscribe(int subscriberId) { - // Why do we execute this on a background task? This method is spawned on a background - // task by the CreateLogger method. The CreateLogger method is called when creating many - // of the services registered in DI - but registering the log channel requires that we - // can resolve the AppHostRpcTarget service (thus creating a circular dependency). To resolve - // this we take a dependency on IServiceProvider so that on a separate background task we - // can resolve AppHostRpcTarget which in turn would have taken a dependency on a logger - // from this provider. - var target = _serviceProvider.GetRequiredService(); - target.RegisterLogChannel(_channel); + lock (_lock) + { + if (_subscribers.Remove(subscriberId, out var channel)) + { + channel.Writer.TryComplete(); + } + } } - public ILogger CreateLogger(string categoryName) + internal void WriteEntry(BackchannelLogEntry entry) { - if (_backgroundChannelRegistrationTask == null) + lock (_lock) { - lock (_channelRegisteredLock) + if (_replayBuffer.Count >= MaxReplayEntries) { - if (_backgroundChannelRegistrationTask == null) - { - _backgroundChannelRegistrationTask = Task.Run( - RegisterLogChannel, - _backgroundChannelRegistrationCts.Token); - } + _replayBuffer.Dequeue(); + } + _replayBuffer.Enqueue(entry); + + foreach (var subscriber in _subscribers.Values) + { + subscriber.Writer.TryWrite(entry); } } + } - return new BackchannelLogger(categoryName, _channel); + public ILogger CreateLogger(string categoryName) + { + return new BackchannelLogger(categoryName, this); } public void Dispose() { - _backgroundChannelRegistrationCts.Cancel(); - _channel.Writer.Complete(); + lock (_lock) + { + foreach (var subscriber in _subscribers.Values) + { + subscriber.Writer.TryComplete(); + } + _subscribers.Clear(); + } } } -internal class BackchannelLogger(string categoryName, Channel channel) : ILogger +internal class BackchannelLogger(string categoryName, BackchannelLoggerProvider provider) : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull { @@ -84,7 +101,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except Message = formatter(state, exception), }; - channel.Writer.TryWrite(entry); + provider.WriteEntry(entry); } } } diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 6e1b56b6b90..b3d9bd63ef5 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -188,7 +188,8 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) _innerBuilder.Services.AddSingleton(TimeProvider.System); - _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton(); + _innerBuilder.Services.AddSingleton(sp => sp.GetRequiredService()); _innerBuilder.Logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning); _innerBuilder.Logging.AddFilter("Microsoft.AspNetCore.Server.Kestrel", LogLevel.Error); _innerBuilder.Logging.AddFilter("Aspire.Hosting.Dashboard", LogLevel.Error); diff --git a/tests/Aspire.Hosting.Tests/Backchannel/BackchannelLoggerProviderTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/BackchannelLoggerProviderTests.cs new file mode 100644 index 00000000000..758d32305b9 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Backchannel/BackchannelLoggerProviderTests.cs @@ -0,0 +1,114 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Backchannel; + +public class BackchannelLoggerProviderTests +{ + [Fact] + public void Subscribe_ReturnsBufferedEntries() + { + using var provider = new BackchannelLoggerProvider(); + + var logger = provider.CreateLogger("TestCategory"); + logger.LogInformation("Message 1"); + logger.LogWarning("Message 2"); + logger.LogError("Message 3"); + + var (snapshot, subscriberId, _) = provider.Subscribe(); + provider.Unsubscribe(subscriberId); + + Assert.Equal(3, snapshot.Count); + Assert.Equal("Message 1", snapshot[0].Message); + Assert.Equal("Message 2", snapshot[1].Message); + Assert.Equal("Message 3", snapshot[2].Message); + Assert.Equal("TestCategory", snapshot[0].CategoryName); + Assert.Equal(LogLevel.Information, snapshot[0].LogLevel); + Assert.Equal(LogLevel.Warning, snapshot[1].LogLevel); + Assert.Equal(LogLevel.Error, snapshot[2].LogLevel); + } + + [Fact] + public void ReplayBuffer_EvictsOldestWhenFull() + { + using var provider = new BackchannelLoggerProvider(); + + var logger = provider.CreateLogger("TestCategory"); + + // Write 1001 entries — the first should be evicted + for (var i = 0; i < 1001; i++) + { + logger.LogInformation("Message {Index}", i); + } + + var (snapshot, subscriberId, _) = provider.Subscribe(); + provider.Unsubscribe(subscriberId); + + Assert.Equal(1000, snapshot.Count); + // First entry should be "Message 1" (index 0 was evicted) + Assert.Equal("Message 1", snapshot[0].Message); + Assert.Equal("Message 1000", snapshot[999].Message); + } + + [Fact] + public void Subscribe_ReturnsIndependentSnapshot() + { + using var provider = new BackchannelLoggerProvider(); + + var logger = provider.CreateLogger("TestCategory"); + logger.LogInformation("Before snapshot"); + + var (snapshot1, sub1, _) = provider.Subscribe(); + provider.Unsubscribe(sub1); + + logger.LogInformation("After snapshot"); + + var (snapshot2, sub2, _) = provider.Subscribe(); + provider.Unsubscribe(sub2); + + // First snapshot should not be affected by subsequent writes + Assert.Single(snapshot1); + Assert.Equal(2, snapshot2.Count); + } + + [Fact] + public async Task ConcurrentSubscribers_ReceiveSameEntries() + { + using var provider = new BackchannelLoggerProvider(); + + var logger = provider.CreateLogger("TestCategory"); + logger.LogInformation("Historical"); + + // Two subscribers connect concurrently + var (snapshot1, sub1, channel1) = provider.Subscribe(); + var (snapshot2, sub2, channel2) = provider.Subscribe(); + + // Both see the historical entry + Assert.Single(snapshot1); + Assert.Single(snapshot2); + + // New entries arrive after both subscribe + logger.LogInformation("Live 1"); + logger.LogInformation("Live 2"); + + // Both subscribers receive both live entries + Assert.True(channel1.Reader.TryRead(out var entry1a)); + Assert.Equal("Live 1", entry1a!.Message); + Assert.True(channel1.Reader.TryRead(out var entry1b)); + Assert.Equal("Live 2", entry1b!.Message); + + Assert.True(channel2.Reader.TryRead(out var entry2a)); + Assert.Equal("Live 1", entry2a!.Message); + Assert.True(channel2.Reader.TryRead(out var entry2b)); + Assert.Equal("Live 2", entry2b!.Message); + + provider.Unsubscribe(sub1); + provider.Unsubscribe(sub2); + + // Channels are completed after unsubscribe + await channel1.Reader.Completion; + await channel2.Reader.Completion; + } +} From eb58e5299c0600569933f5881285e3704d29268f Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 25 Feb 2026 09:13:31 +0800 Subject: [PATCH 171/256] Always display message when no app host, share common resource strings (#14661) --- .../Backchannel/AppHostConnectionResolver.cs | 1 + src/Aspire.Cli/Commands/DescribeCommand.cs | 11 +- src/Aspire.Cli/Commands/LogsCommand.cs | 11 +- src/Aspire.Cli/Commands/PsCommand.cs | 4 +- src/Aspire.Cli/Commands/ResourceCommand.cs | 13 +- .../Commands/ResourceCommandBase.cs | 13 +- src/Aspire.Cli/Commands/StopCommand.cs | 18 +-- .../Commands/TelemetryCommandHelpers.cs | 11 +- src/Aspire.Cli/Commands/WaitCommand.cs | 12 +- .../DescribeCommandStrings.Designer.cs | 36 +---- .../Resources/DescribeCommandStrings.resx | 18 +-- .../Resources/LogsCommandStrings.Designer.cs | 36 +---- .../Resources/LogsCommandStrings.resx | 18 +-- .../Resources/PsCommandStrings.Designer.cs | 12 -- .../Resources/PsCommandStrings.resx | 6 - .../ResourceCommandStrings.Designer.cs | 28 +--- .../Resources/ResourceCommandStrings.resx | 16 +-- .../SharedCommandStrings.Designer.cs | 78 ++++++++++ .../Resources/SharedCommandStrings.resx | 135 ++++++++++++++++++ .../Resources/StopCommandStrings.Designer.cs | 22 +-- .../Resources/StopCommandStrings.resx | 13 +- .../TelemetryCommandStrings.Designer.cs | 51 +------ .../Resources/TelemetryCommandStrings.resx | 18 +-- .../Resources/WaitCommandStrings.Designer.cs | 36 +---- .../Resources/WaitCommandStrings.resx | 18 +-- .../xlf/DescribeCommandStrings.cs.xlf | 26 +--- .../xlf/DescribeCommandStrings.de.xlf | 26 +--- .../xlf/DescribeCommandStrings.es.xlf | 26 +--- .../xlf/DescribeCommandStrings.fr.xlf | 26 +--- .../xlf/DescribeCommandStrings.it.xlf | 26 +--- .../xlf/DescribeCommandStrings.ja.xlf | 26 +--- .../xlf/DescribeCommandStrings.ko.xlf | 26 +--- .../xlf/DescribeCommandStrings.pl.xlf | 26 +--- .../xlf/DescribeCommandStrings.pt-BR.xlf | 26 +--- .../xlf/DescribeCommandStrings.ru.xlf | 26 +--- .../xlf/DescribeCommandStrings.tr.xlf | 26 +--- .../xlf/DescribeCommandStrings.zh-Hans.xlf | 26 +--- .../xlf/DescribeCommandStrings.zh-Hant.xlf | 26 +--- .../Resources/xlf/LogsCommandStrings.cs.xlf | 26 +--- .../Resources/xlf/LogsCommandStrings.de.xlf | 26 +--- .../Resources/xlf/LogsCommandStrings.es.xlf | 26 +--- .../Resources/xlf/LogsCommandStrings.fr.xlf | 26 +--- .../Resources/xlf/LogsCommandStrings.it.xlf | 26 +--- .../Resources/xlf/LogsCommandStrings.ja.xlf | 26 +--- .../Resources/xlf/LogsCommandStrings.ko.xlf | 26 +--- .../Resources/xlf/LogsCommandStrings.pl.xlf | 26 +--- .../xlf/LogsCommandStrings.pt-BR.xlf | 26 +--- .../Resources/xlf/LogsCommandStrings.ru.xlf | 26 +--- .../Resources/xlf/LogsCommandStrings.tr.xlf | 26 +--- .../xlf/LogsCommandStrings.zh-Hans.xlf | 26 +--- .../xlf/LogsCommandStrings.zh-Hant.xlf | 26 +--- .../Resources/xlf/PsCommandStrings.cs.xlf | 10 -- .../Resources/xlf/PsCommandStrings.de.xlf | 10 -- .../Resources/xlf/PsCommandStrings.es.xlf | 10 -- .../Resources/xlf/PsCommandStrings.fr.xlf | 10 -- .../Resources/xlf/PsCommandStrings.it.xlf | 10 -- .../Resources/xlf/PsCommandStrings.ja.xlf | 10 -- .../Resources/xlf/PsCommandStrings.ko.xlf | 10 -- .../Resources/xlf/PsCommandStrings.pl.xlf | 10 -- .../Resources/xlf/PsCommandStrings.pt-BR.xlf | 10 -- .../Resources/xlf/PsCommandStrings.ru.xlf | 10 -- .../Resources/xlf/PsCommandStrings.tr.xlf | 10 -- .../xlf/PsCommandStrings.zh-Hans.xlf | 10 -- .../xlf/PsCommandStrings.zh-Hant.xlf | 10 -- .../xlf/ResourceCommandStrings.cs.xlf | 26 +--- .../xlf/ResourceCommandStrings.de.xlf | 26 +--- .../xlf/ResourceCommandStrings.es.xlf | 26 +--- .../xlf/ResourceCommandStrings.fr.xlf | 26 +--- .../xlf/ResourceCommandStrings.it.xlf | 26 +--- .../xlf/ResourceCommandStrings.ja.xlf | 26 +--- .../xlf/ResourceCommandStrings.ko.xlf | 26 +--- .../xlf/ResourceCommandStrings.pl.xlf | 26 +--- .../xlf/ResourceCommandStrings.pt-BR.xlf | 26 +--- .../xlf/ResourceCommandStrings.ru.xlf | 26 +--- .../xlf/ResourceCommandStrings.tr.xlf | 26 +--- .../xlf/ResourceCommandStrings.zh-Hans.xlf | 26 +--- .../xlf/ResourceCommandStrings.zh-Hant.xlf | 26 +--- .../Resources/xlf/SharedCommandStrings.cs.xlf | 32 +++++ .../Resources/xlf/SharedCommandStrings.de.xlf | 32 +++++ .../Resources/xlf/SharedCommandStrings.es.xlf | 32 +++++ .../Resources/xlf/SharedCommandStrings.fr.xlf | 32 +++++ .../Resources/xlf/SharedCommandStrings.it.xlf | 32 +++++ .../Resources/xlf/SharedCommandStrings.ja.xlf | 32 +++++ .../Resources/xlf/SharedCommandStrings.ko.xlf | 32 +++++ .../Resources/xlf/SharedCommandStrings.pl.xlf | 32 +++++ .../xlf/SharedCommandStrings.pt-BR.xlf | 32 +++++ .../Resources/xlf/SharedCommandStrings.ru.xlf | 32 +++++ .../Resources/xlf/SharedCommandStrings.tr.xlf | 32 +++++ .../xlf/SharedCommandStrings.zh-Hans.xlf | 32 +++++ .../xlf/SharedCommandStrings.zh-Hant.xlf | 32 +++++ .../Resources/xlf/StopCommandStrings.cs.xlf | 21 +-- .../Resources/xlf/StopCommandStrings.de.xlf | 21 +-- .../Resources/xlf/StopCommandStrings.es.xlf | 21 +-- .../Resources/xlf/StopCommandStrings.fr.xlf | 21 +-- .../Resources/xlf/StopCommandStrings.it.xlf | 21 +-- .../Resources/xlf/StopCommandStrings.ja.xlf | 21 +-- .../Resources/xlf/StopCommandStrings.ko.xlf | 21 +-- .../Resources/xlf/StopCommandStrings.pl.xlf | 21 +-- .../xlf/StopCommandStrings.pt-BR.xlf | 21 +-- .../Resources/xlf/StopCommandStrings.ru.xlf | 21 +-- .../Resources/xlf/StopCommandStrings.tr.xlf | 21 +-- .../xlf/StopCommandStrings.zh-Hans.xlf | 21 +-- .../xlf/StopCommandStrings.zh-Hant.xlf | 21 +-- .../xlf/TelemetryCommandStrings.cs.xlf | 26 +--- .../xlf/TelemetryCommandStrings.de.xlf | 26 +--- .../xlf/TelemetryCommandStrings.es.xlf | 26 +--- .../xlf/TelemetryCommandStrings.fr.xlf | 26 +--- .../xlf/TelemetryCommandStrings.it.xlf | 26 +--- .../xlf/TelemetryCommandStrings.ja.xlf | 26 +--- .../xlf/TelemetryCommandStrings.ko.xlf | 26 +--- .../xlf/TelemetryCommandStrings.pl.xlf | 26 +--- .../xlf/TelemetryCommandStrings.pt-BR.xlf | 26 +--- .../xlf/TelemetryCommandStrings.ru.xlf | 26 +--- .../xlf/TelemetryCommandStrings.tr.xlf | 26 +--- .../xlf/TelemetryCommandStrings.zh-Hans.xlf | 26 +--- .../xlf/TelemetryCommandStrings.zh-Hant.xlf | 26 +--- .../Resources/xlf/WaitCommandStrings.cs.xlf | 26 +--- .../Resources/xlf/WaitCommandStrings.de.xlf | 26 +--- .../Resources/xlf/WaitCommandStrings.es.xlf | 26 +--- .../Resources/xlf/WaitCommandStrings.fr.xlf | 26 +--- .../Resources/xlf/WaitCommandStrings.it.xlf | 26 +--- .../Resources/xlf/WaitCommandStrings.ja.xlf | 26 +--- .../Resources/xlf/WaitCommandStrings.ko.xlf | 26 +--- .../Resources/xlf/WaitCommandStrings.pl.xlf | 26 +--- .../xlf/WaitCommandStrings.pt-BR.xlf | 26 +--- .../Resources/xlf/WaitCommandStrings.ru.xlf | 26 +--- .../Resources/xlf/WaitCommandStrings.tr.xlf | 26 +--- .../xlf/WaitCommandStrings.zh-Hans.xlf | 26 +--- .../xlf/WaitCommandStrings.zh-Hant.xlf | 26 +--- .../PsCommandTests.cs | 2 +- .../StartStopTests.cs | 6 +- .../StopNonInteractiveTests.cs | 6 +- 132 files changed, 964 insertions(+), 2194 deletions(-) create mode 100644 src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs create mode 100644 src/Aspire.Cli/Resources/SharedCommandStrings.resx create mode 100644 src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf create mode 100644 src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index 814ccc19bbc..4c0fd2fea3e 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs @@ -17,6 +17,7 @@ internal sealed class AppHostConnectionResult public IAppHostAuxiliaryBackchannel? Connection { get; init; } [MemberNotNullWhen(true, nameof(Connection))] + [MemberNotNullWhen(false, nameof(ErrorMessage))] public bool Success => Connection is not null; public string? ErrorMessage { get; init; } diff --git a/src/Aspire.Cli/Commands/DescribeCommand.cs b/src/Aspire.Cli/Commands/DescribeCommand.cs index 1c5a4771b37..b78a9b508fe 100644 --- a/src/Aspire.Cli/Commands/DescribeCommand.cs +++ b/src/Aspire.Cli/Commands/DescribeCommand.cs @@ -79,7 +79,7 @@ internal sealed class DescribeCommand : BaseCommand }; private static readonly Option s_projectOption = new("--project") { - Description = DescribeCommandStrings.ProjectOptionDescription + Description = SharedCommandStrings.ProjectOptionDescription }; private static readonly Option s_followOption = new("--follow", "-f") { @@ -120,19 +120,20 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var format = parseResult.GetValue(s_formatOption); // When outputting JSON, suppress status messages to keep output machine-readable - var scanningMessage = format == OutputFormat.Json ? string.Empty : DescribeCommandStrings.ScanningForRunningAppHosts; + var scanningMessage = format == OutputFormat.Json ? string.Empty : SharedCommandStrings.ScanningForRunningAppHosts; var result = await _connectionResolver.ResolveConnectionAsync( passedAppHostProjectFile, scanningMessage, - DescribeCommandStrings.SelectAppHost, - DescribeCommandStrings.NoInScopeAppHostsShowingAll, - DescribeCommandStrings.AppHostNotRunning, + string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, DescribeCommandStrings.SelectAppHostAction), + SharedCommandStrings.NoInScopeAppHostsShowingAll, + SharedCommandStrings.AppHostNotRunning, cancellationToken); if (!result.Success) { // No running AppHosts is not an error - similar to Unix 'ps' returning empty + _interactionService.DisplayMessage("information", result.ErrorMessage); return ExitCodeConstants.Success; } diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs index 9483fa44cf7..08568ff31f5 100644 --- a/src/Aspire.Cli/Commands/LogsCommand.cs +++ b/src/Aspire.Cli/Commands/LogsCommand.cs @@ -86,7 +86,7 @@ internal sealed class LogsCommand : BaseCommand }; private static readonly Option s_projectOption = new("--project") { - Description = LogsCommandStrings.ProjectOptionDescription + Description = SharedCommandStrings.ProjectOptionDescription }; private static readonly Option s_followOption = new("--follow", "-f") { @@ -165,19 +165,20 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } // When outputting JSON, suppress status messages to keep output machine-readable - var scanningMessage = format == OutputFormat.Json ? string.Empty : LogsCommandStrings.ScanningForRunningAppHosts; + var scanningMessage = format == OutputFormat.Json ? string.Empty : SharedCommandStrings.ScanningForRunningAppHosts; var result = await _connectionResolver.ResolveConnectionAsync( passedAppHostProjectFile, scanningMessage, - LogsCommandStrings.SelectAppHost, - LogsCommandStrings.NoInScopeAppHostsShowingAll, - LogsCommandStrings.AppHostNotRunning, + string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, LogsCommandStrings.SelectAppHostAction), + SharedCommandStrings.NoInScopeAppHostsShowingAll, + SharedCommandStrings.AppHostNotRunning, cancellationToken); if (!result.Success) { // No running AppHosts is not an error - similar to Unix 'ps' returning empty + _interactionService.DisplayMessage("information", result.ErrorMessage); return ExitCodeConstants.Success; } diff --git a/src/Aspire.Cli/Commands/PsCommand.cs b/src/Aspire.Cli/Commands/PsCommand.cs index 7367848a590..930bae9fc30 100644 --- a/src/Aspire.Cli/Commands/PsCommand.cs +++ b/src/Aspire.Cli/Commands/PsCommand.cs @@ -88,7 +88,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell else { connections = await _interactionService.ShowStatusAsync( - PsCommandStrings.ScanningForRunningAppHosts, + SharedCommandStrings.ScanningForRunningAppHosts, async () => { await _backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); @@ -104,7 +104,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } else { - _interactionService.DisplayMessage("information", PsCommandStrings.NoRunningAppHostsFound); + _interactionService.DisplayMessage("information", SharedCommandStrings.AppHostNotRunning); } return ExitCodeConstants.Success; } diff --git a/src/Aspire.Cli/Commands/ResourceCommand.cs b/src/Aspire.Cli/Commands/ResourceCommand.cs index d96ecdc6df5..5d726d177be 100644 --- a/src/Aspire.Cli/Commands/ResourceCommand.cs +++ b/src/Aspire.Cli/Commands/ResourceCommand.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.Globalization; using Aspire.Cli.Backchannel; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; @@ -32,7 +33,7 @@ internal sealed class ResourceCommand : BaseCommand private static readonly Option s_projectOption = new("--project") { - Description = ResourceCommandStrings.ProjectOptionDescription + Description = SharedCommandStrings.ProjectOptionDescription }; public ResourceCommand( @@ -62,15 +63,15 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var result = await _connectionResolver.ResolveConnectionAsync( passedAppHostProjectFile, - ResourceCommandStrings.ScanningForRunningAppHosts, - ResourceCommandStrings.SelectAppHost, - ResourceCommandStrings.NoInScopeAppHostsShowingAll, - ResourceCommandStrings.NoRunningAppHostsFound, + SharedCommandStrings.ScanningForRunningAppHosts, + string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, ResourceCommandStrings.SelectAppHostAction), + SharedCommandStrings.NoInScopeAppHostsShowingAll, + SharedCommandStrings.AppHostNotRunning, cancellationToken); if (!result.Success) { - _interactionService.DisplayError(result.ErrorMessage ?? ResourceCommandStrings.NoRunningAppHostsFound); + _interactionService.DisplayError(result.ErrorMessage); return ExitCodeConstants.FailedToFindProject; } diff --git a/src/Aspire.Cli/Commands/ResourceCommandBase.cs b/src/Aspire.Cli/Commands/ResourceCommandBase.cs index d760cf38ac1..4f036631557 100644 --- a/src/Aspire.Cli/Commands/ResourceCommandBase.cs +++ b/src/Aspire.Cli/Commands/ResourceCommandBase.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.Globalization; using Aspire.Cli.Backchannel; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; @@ -24,7 +25,7 @@ internal abstract class ResourceCommandBase : BaseCommand protected static readonly Option s_projectOption = new("--project") { - Description = ResourceCommandStrings.ProjectOptionDescription + Description = SharedCommandStrings.ProjectOptionDescription }; /// @@ -83,15 +84,15 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var result = await ConnectionResolver.ResolveConnectionAsync( passedAppHostProjectFile, - ResourceCommandStrings.ScanningForRunningAppHosts, - ResourceCommandStrings.SelectAppHost, - ResourceCommandStrings.NoInScopeAppHostsShowingAll, - ResourceCommandStrings.NoRunningAppHostsFound, + SharedCommandStrings.ScanningForRunningAppHosts, + string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, ResourceCommandStrings.SelectAppHostAction), + SharedCommandStrings.NoInScopeAppHostsShowingAll, + SharedCommandStrings.AppHostNotRunning, cancellationToken); if (!result.Success) { - InteractionService.DisplayError(result.ErrorMessage ?? ResourceCommandStrings.NoRunningAppHostsFound); + InteractionService.DisplayError(result.ErrorMessage); return ExitCodeConstants.FailedToFindProject; } diff --git a/src/Aspire.Cli/Commands/StopCommand.cs b/src/Aspire.Cli/Commands/StopCommand.cs index 4b4c4a2dc8d..923ed3b4a32 100644 --- a/src/Aspire.Cli/Commands/StopCommand.cs +++ b/src/Aspire.Cli/Commands/StopCommand.cs @@ -114,12 +114,12 @@ private async Task ExecuteNonInteractiveAsync(FileInfo? passedAppHostProjec // Scan for all running AppHosts var allConnections = await _connectionResolver.ResolveAllConnectionsAsync( - StopCommandStrings.ScanningForRunningAppHosts, + SharedCommandStrings.ScanningForRunningAppHosts, cancellationToken); if (allConnections.Length == 0) { - _interactionService.DisplayError(StopCommandStrings.NoRunningAppHostsFound); + _interactionService.DisplayError(SharedCommandStrings.AppHostNotRunning); return ExitCodeConstants.FailedToFindProject; } @@ -150,15 +150,15 @@ private async Task ExecuteInteractiveAsync(FileInfo? passedAppHostProjectFi { var result = await _connectionResolver.ResolveConnectionAsync( passedAppHostProjectFile, - StopCommandStrings.ScanningForRunningAppHosts, - StopCommandStrings.SelectAppHostToStop, - StopCommandStrings.NoInScopeAppHostsShowingAll, - StopCommandStrings.NoRunningAppHostsFound, + SharedCommandStrings.ScanningForRunningAppHosts, + string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, StopCommandStrings.SelectAppHostAction), + SharedCommandStrings.NoInScopeAppHostsShowingAll, + SharedCommandStrings.AppHostNotRunning, cancellationToken); if (!result.Success) { - _interactionService.DisplayMessage("information", StopCommandStrings.NoRunningAppHostsFound); + _interactionService.DisplayMessage("information", result.ErrorMessage); return ExitCodeConstants.Success; } @@ -178,12 +178,12 @@ private async Task ExecuteInteractiveAsync(FileInfo? passedAppHostProjectFi private async Task StopAllAppHostsAsync(CancellationToken cancellationToken) { var allConnections = await _connectionResolver.ResolveAllConnectionsAsync( - StopCommandStrings.ScanningForRunningAppHosts, + SharedCommandStrings.ScanningForRunningAppHosts, cancellationToken); if (allConnections.Length == 0) { - _interactionService.DisplayError(StopCommandStrings.NoRunningAppHostsFound); + _interactionService.DisplayError(SharedCommandStrings.AppHostNotRunning); return ExitCodeConstants.FailedToFindProject; } diff --git a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs index 27287908a2b..b123d5096cf 100644 --- a/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs +++ b/src/Aspire.Cli/Commands/TelemetryCommandHelpers.cs @@ -41,7 +41,7 @@ internal static class TelemetryCommandHelpers /// internal static Option CreateProjectOption() => new("--project") { - Description = TelemetryCommandStrings.ProjectOptionDescription + Description = SharedCommandStrings.ProjectOptionDescription }; /// @@ -111,18 +111,19 @@ public static bool HasJsonContentType(HttpResponseMessage response) CancellationToken cancellationToken) { // When outputting JSON, suppress status messages to keep output machine-readable - var scanningMessage = format == OutputFormat.Json ? string.Empty : TelemetryCommandStrings.ScanningForRunningAppHosts; + var scanningMessage = format == OutputFormat.Json ? string.Empty : SharedCommandStrings.ScanningForRunningAppHosts; var result = await connectionResolver.ResolveConnectionAsync( projectFile, scanningMessage, - TelemetryCommandStrings.SelectAppHost, - TelemetryCommandStrings.NoInScopeAppHostsShowingAll, - TelemetryCommandStrings.AppHostNotRunning, + string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, TelemetryCommandStrings.SelectAppHostAction), + SharedCommandStrings.NoInScopeAppHostsShowingAll, + SharedCommandStrings.AppHostNotRunning, cancellationToken); if (!result.Success) { + interactionService.DisplayMessage("information", result.ErrorMessage); return (false, null, null, null, ExitCodeConstants.Success); } diff --git a/src/Aspire.Cli/Commands/WaitCommand.cs b/src/Aspire.Cli/Commands/WaitCommand.cs index 76deca53183..3b421b31ff7 100644 --- a/src/Aspire.Cli/Commands/WaitCommand.cs +++ b/src/Aspire.Cli/Commands/WaitCommand.cs @@ -41,7 +41,7 @@ internal sealed class WaitCommand : BaseCommand private static readonly Option s_projectOption = new("--project") { - Description = WaitCommandStrings.ProjectOptionDescription + Description = SharedCommandStrings.ProjectOptionDescription }; public WaitCommand( @@ -92,15 +92,15 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell // Resolve connection to a running AppHost var result = await _connectionResolver.ResolveConnectionAsync( passedAppHostProjectFile, - WaitCommandStrings.ScanningForRunningAppHosts, - WaitCommandStrings.SelectAppHost, - WaitCommandStrings.NoInScopeAppHostsShowingAll, - WaitCommandStrings.NoRunningAppHostsFound, + SharedCommandStrings.ScanningForRunningAppHosts, + string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, WaitCommandStrings.SelectAppHostAction), + SharedCommandStrings.NoInScopeAppHostsShowingAll, + SharedCommandStrings.AppHostNotRunning, cancellationToken); if (!result.Success) { - _interactionService.DisplayError(result.ErrorMessage ?? WaitCommandStrings.NoRunningAppHostsFound); + _interactionService.DisplayError(result.ErrorMessage); return ExitCodeConstants.FailedToFindProject; } diff --git a/src/Aspire.Cli/Resources/DescribeCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DescribeCommandStrings.Designer.cs index 39da716f1f3..a026f4fd306 100644 --- a/src/Aspire.Cli/Resources/DescribeCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/DescribeCommandStrings.Designer.cs @@ -51,12 +51,6 @@ public static string Description { } } - public static string ProjectOptionDescription { - get { - return ResourceManager.GetString("ProjectOptionDescription", resourceCulture); - } - } - public static string FollowOptionDescription { get { return ResourceManager.GetString("FollowOptionDescription", resourceCulture); @@ -75,30 +69,6 @@ public static string NoAppHostFound { } } - public static string AppHostNotRunning { - get { - return ResourceManager.GetString("AppHostNotRunning", resourceCulture); - } - } - - public static string ScanningForRunningAppHosts { - get { - return ResourceManager.GetString("ScanningForRunningAppHosts", resourceCulture); - } - } - - public static string SelectAppHost { - get { - return ResourceManager.GetString("SelectAppHost", resourceCulture); - } - } - - public static string NoInScopeAppHostsShowingAll { - get { - return ResourceManager.GetString("NoInScopeAppHostsShowingAll", resourceCulture); - } - } - public static string ResourceArgumentDescription { get { return ResourceManager.GetString("ResourceArgumentDescription", resourceCulture); @@ -110,5 +80,11 @@ public static string ResourceNotFound { return ResourceManager.GetString("ResourceNotFound", resourceCulture); } } + + public static string SelectAppHostAction { + get { + return ResourceManager.GetString("SelectAppHostAction", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/DescribeCommandStrings.resx b/src/Aspire.Cli/Resources/DescribeCommandStrings.resx index b345d1b1845..7b4543e04f0 100644 --- a/src/Aspire.Cli/Resources/DescribeCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DescribeCommandStrings.resx @@ -120,9 +120,6 @@ Describe resources in a running apphost. - - The path to the Aspire AppHost project file. - Continuously stream resource state changes. @@ -132,22 +129,13 @@ No AppHost project found. - - No running AppHost found. Use 'aspire run' to start one first. - - - Scanning for running AppHosts... - - - Select an AppHost: - - - No AppHosts found in current directory. Showing all running AppHosts. - The name of the resource to display. If not specified, all resources are shown. Resource '{0}' not found. + + describe + diff --git a/src/Aspire.Cli/Resources/LogsCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/LogsCommandStrings.Designer.cs index 74af5822262..68db9e3b8f5 100644 --- a/src/Aspire.Cli/Resources/LogsCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/LogsCommandStrings.Designer.cs @@ -57,12 +57,6 @@ public static string ResourceArgumentDescription { } } - public static string ProjectOptionDescription { - get { - return ResourceManager.GetString("ProjectOptionDescription", resourceCulture); - } - } - public static string FollowOptionDescription { get { return ResourceManager.GetString("FollowOptionDescription", resourceCulture); @@ -75,30 +69,6 @@ public static string JsonOptionDescription { } } - public static string AppHostNotRunning { - get { - return ResourceManager.GetString("AppHostNotRunning", resourceCulture); - } - } - - public static string ScanningForRunningAppHosts { - get { - return ResourceManager.GetString("ScanningForRunningAppHosts", resourceCulture); - } - } - - public static string SelectAppHost { - get { - return ResourceManager.GetString("SelectAppHost", resourceCulture); - } - } - - public static string NoInScopeAppHostsShowingAll { - get { - return ResourceManager.GetString("NoInScopeAppHostsShowingAll", resourceCulture); - } - } - public static string ResourceRequiredWithoutFollow { get { return ResourceManager.GetString("ResourceRequiredWithoutFollow", resourceCulture); @@ -134,5 +104,11 @@ public static string TimestampsOptionDescription { return ResourceManager.GetString("TimestampsOptionDescription", resourceCulture); } } + + public static string SelectAppHostAction { + get { + return ResourceManager.GetString("SelectAppHostAction", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/LogsCommandStrings.resx b/src/Aspire.Cli/Resources/LogsCommandStrings.resx index 6bd0d1f5831..4f7f0fdfbab 100644 --- a/src/Aspire.Cli/Resources/LogsCommandStrings.resx +++ b/src/Aspire.Cli/Resources/LogsCommandStrings.resx @@ -123,27 +123,12 @@ The name of the resource to get logs for. If not specified, logs from all resources are shown. - - The path to the Aspire AppHost project file. - Stream logs in real-time as they are written. Output format (Table or Json). - - No running AppHost found. Use 'aspire run' to start one first. - - - Scanning for running AppHosts... - - - Select an AppHost: - - - No AppHosts found in current directory. Showing all running AppHosts. - A resource name is required when not using --follow. Use --follow to stream logs from all resources. @@ -165,4 +150,7 @@ Show timestamps for each log line. + + stream logs from + diff --git a/src/Aspire.Cli/Resources/PsCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/PsCommandStrings.Designer.cs index 12f31141c46..cdcae4fb2c9 100644 --- a/src/Aspire.Cli/Resources/PsCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/PsCommandStrings.Designer.cs @@ -57,18 +57,6 @@ public static string JsonOptionDescription { } } - public static string ScanningForRunningAppHosts { - get { - return ResourceManager.GetString("ScanningForRunningAppHosts", resourceCulture); - } - } - - public static string NoRunningAppHostsFound { - get { - return ResourceManager.GetString("NoRunningAppHostsFound", resourceCulture); - } - } - public static string HeaderPath { get { return ResourceManager.GetString("HeaderPath", resourceCulture); diff --git a/src/Aspire.Cli/Resources/PsCommandStrings.resx b/src/Aspire.Cli/Resources/PsCommandStrings.resx index 9dfa01a78ce..d229f166fb7 100644 --- a/src/Aspire.Cli/Resources/PsCommandStrings.resx +++ b/src/Aspire.Cli/Resources/PsCommandStrings.resx @@ -123,12 +123,6 @@ Output format (Table or Json). - - Scanning for running AppHosts... - - - No running AppHosts found. - PATH diff --git a/src/Aspire.Cli/Resources/ResourceCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/ResourceCommandStrings.Designer.cs index 2d00376b88f..ec4e6db17c0 100644 --- a/src/Aspire.Cli/Resources/ResourceCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/ResourceCommandStrings.Designer.cs @@ -45,33 +45,9 @@ internal static System.Globalization.CultureInfo Culture { } } - internal static string ScanningForRunningAppHosts { + internal static string SelectAppHostAction { get { - return ResourceManager.GetString("ScanningForRunningAppHosts", resourceCulture); - } - } - - internal static string SelectAppHost { - get { - return ResourceManager.GetString("SelectAppHost", resourceCulture); - } - } - - internal static string NoInScopeAppHostsShowingAll { - get { - return ResourceManager.GetString("NoInScopeAppHostsShowingAll", resourceCulture); - } - } - - internal static string NoRunningAppHostsFound { - get { - return ResourceManager.GetString("NoRunningAppHostsFound", resourceCulture); - } - } - - internal static string ProjectOptionDescription { - get { - return ResourceManager.GetString("ProjectOptionDescription", resourceCulture); + return ResourceManager.GetString("SelectAppHostAction", resourceCulture); } } diff --git a/src/Aspire.Cli/Resources/ResourceCommandStrings.resx b/src/Aspire.Cli/Resources/ResourceCommandStrings.resx index 87360ae7821..929c46d2db8 100644 --- a/src/Aspire.Cli/Resources/ResourceCommandStrings.resx +++ b/src/Aspire.Cli/Resources/ResourceCommandStrings.resx @@ -58,20 +58,8 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - Scanning for running AppHosts... - - - Select which AppHost to connect to: - - - No in-scope AppHosts found. Showing all running AppHosts. - - - No running AppHosts found. - - - The path to the Aspire AppHost project file. + + connect to Start a stopped resource. diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs new file mode 100644 index 00000000000..89ef55539f7 --- /dev/null +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs @@ -0,0 +1,78 @@ +//------------------------------------------------------------------------------ +// +// 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.Cli.Resources { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class SharedCommandStrings { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal SharedCommandStrings() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Aspire.Cli.Resources.SharedCommandStrings", typeof(SharedCommandStrings).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + internal static string ScanningForRunningAppHosts { + get { + return ResourceManager.GetString("ScanningForRunningAppHosts", resourceCulture); + } + } + + internal static string SelectAppHost { + get { + return ResourceManager.GetString("SelectAppHost", resourceCulture); + } + } + + internal static string NoInScopeAppHostsShowingAll { + get { + return ResourceManager.GetString("NoInScopeAppHostsShowingAll", resourceCulture); + } + } + + internal static string AppHostNotRunning { + get { + return ResourceManager.GetString("AppHostNotRunning", resourceCulture); + } + } + + internal static string ProjectOptionDescription { + get { + return ResourceManager.GetString("ProjectOptionDescription", resourceCulture); + } + } + } +} diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.resx b/src/Aspire.Cli/Resources/SharedCommandStrings.resx new file mode 100644 index 00000000000..b99053964ba --- /dev/null +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.resx @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Scanning for running AppHosts... + + + Select an AppHost to {0}: + + + No running AppHosts found in the current directory. Showing all running AppHosts: + + + No running AppHost found. Use 'aspire run' to start one first. + + + The path to the Aspire AppHost project file. + + diff --git a/src/Aspire.Cli/Resources/StopCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/StopCommandStrings.Designer.cs index 521cafe1c7d..c4e6da1a2d1 100644 --- a/src/Aspire.Cli/Resources/StopCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/StopCommandStrings.Designer.cs @@ -69,12 +69,6 @@ public static string AppHostStoppedSuccessfully { } } - public static string NoRunningAppHostsFound { - get { - return ResourceManager.GetString("NoRunningAppHostsFound", resourceCulture); - } - } - public static string MultipleAppHostsRunning { get { return ResourceManager.GetString("MultipleAppHostsRunning", resourceCulture); @@ -87,21 +81,9 @@ public static string FailedToStopAppHost { } } - public static string SelectAppHostToStop { - get { - return ResourceManager.GetString("SelectAppHostToStop", resourceCulture); - } - } - - public static string ScanningForRunningAppHosts { - get { - return ResourceManager.GetString("ScanningForRunningAppHosts", resourceCulture); - } - } - - public static string NoInScopeAppHostsShowingAll { + public static string SelectAppHostAction { get { - return ResourceManager.GetString("NoInScopeAppHostsShowingAll", resourceCulture); + return ResourceManager.GetString("SelectAppHostAction", resourceCulture); } } diff --git a/src/Aspire.Cli/Resources/StopCommandStrings.resx b/src/Aspire.Cli/Resources/StopCommandStrings.resx index 9771cbcbb7b..32238596dbe 100644 --- a/src/Aspire.Cli/Resources/StopCommandStrings.resx +++ b/src/Aspire.Cli/Resources/StopCommandStrings.resx @@ -129,23 +129,14 @@ AppHost stopped successfully. - - No running AppHosts found in scope. - Multiple AppHosts are running. Use --project to specify which one to stop, or select one: Failed to stop the AppHost. - - Select an AppHost to stop: - - - Scanning for running AppHosts... - - - No running AppHosts found in the current directory. Showing all running AppHosts: + + stop Stop all running AppHosts. diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs index cd9c891b389..57641a5e232 100644 --- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.Designer.cs @@ -105,15 +105,6 @@ internal static string ResourceArgumentDescription { } } - /// - /// Looks up a localized string similar to The path to the Aspire AppHost project file.. - /// - internal static string ProjectOptionDescription { - get { - return ResourceManager.GetString("ProjectOptionDescription", resourceCulture); - } - } - /// /// Looks up a localized string similar to Stream telemetry in real-time as it arrives.. /// @@ -186,42 +177,6 @@ internal static string LimitMustBePositive { } } - /// - /// Looks up a localized string similar to Scanning for running AppHosts.... - /// - internal static string ScanningForRunningAppHosts { - get { - return ResourceManager.GetString("ScanningForRunningAppHosts", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Select an AppHost:. - /// - internal static string SelectAppHost { - get { - return ResourceManager.GetString("SelectAppHost", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to No AppHosts found in current directory. Showing all running AppHosts.. - /// - internal static string NoInScopeAppHostsShowingAll { - get { - return ResourceManager.GetString("NoInScopeAppHostsShowingAll", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to No running AppHost found. Use 'aspire run' to start one first.. - /// - internal static string AppHostNotRunning { - get { - return ResourceManager.GetString("AppHostNotRunning", resourceCulture); - } - } - /// /// Looks up a localized string similar to Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled.. /// @@ -257,5 +212,11 @@ internal static string UnexpectedContentType { return ResourceManager.GetString("UnexpectedContentType", resourceCulture); } } + + internal static string SelectAppHostAction { + get { + return ResourceManager.GetString("SelectAppHostAction", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx index 03fef3f6d81..743683909e4 100644 --- a/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx +++ b/src/Aspire.Cli/Resources/TelemetryCommandStrings.resx @@ -132,9 +132,6 @@ Filter by resource name. - - The path to the Aspire AppHost project file. - Stream telemetry in real-time as it arrives. @@ -159,18 +156,6 @@ The --limit value must be a positive number. - - Scanning for running AppHosts... - - - Select an AppHost: - - - No AppHosts found in current directory. Showing all running AppHosts. - - - No running AppHost found. Use 'aspire run' to start one first. - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. @@ -183,4 +168,7 @@ Dashboard API returned unexpected content type. Expected JSON response. + + view telemetry for + diff --git a/src/Aspire.Cli/Resources/WaitCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/WaitCommandStrings.Designer.cs index 30352787146..72f45818cc3 100644 --- a/src/Aspire.Cli/Resources/WaitCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/WaitCommandStrings.Designer.cs @@ -69,36 +69,6 @@ public static string TimeoutOptionDescription { } } - public static string ProjectOptionDescription { - get { - return ResourceManager.GetString("ProjectOptionDescription", resourceCulture); - } - } - - public static string ScanningForRunningAppHosts { - get { - return ResourceManager.GetString("ScanningForRunningAppHosts", resourceCulture); - } - } - - public static string SelectAppHost { - get { - return ResourceManager.GetString("SelectAppHost", resourceCulture); - } - } - - public static string NoInScopeAppHostsShowingAll { - get { - return ResourceManager.GetString("NoInScopeAppHostsShowingAll", resourceCulture); - } - } - - public static string NoRunningAppHostsFound { - get { - return ResourceManager.GetString("NoRunningAppHostsFound", resourceCulture); - } - } - public static string WaitingForResource { get { return ResourceManager.GetString("WaitingForResource", resourceCulture); @@ -140,5 +110,11 @@ public static string TimeoutMustBePositive { return ResourceManager.GetString("TimeoutMustBePositive", resourceCulture); } } + + public static string SelectAppHostAction { + get { + return ResourceManager.GetString("SelectAppHostAction", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/WaitCommandStrings.resx b/src/Aspire.Cli/Resources/WaitCommandStrings.resx index a93ab28f02b..416a19bacca 100644 --- a/src/Aspire.Cli/Resources/WaitCommandStrings.resx +++ b/src/Aspire.Cli/Resources/WaitCommandStrings.resx @@ -129,21 +129,6 @@ Maximum time to wait in seconds. Defaults to 120. - - The path to the Aspire AppHost project file. - - - Scanning for running AppHosts... - - - Select an AppHost: - - - No running AppHosts found in the current directory. Showing all running AppHosts: - - - No running AppHosts found. - Waiting for resource '{0}' to be {1}... @@ -165,4 +150,7 @@ Timeout must be a positive number of seconds. + + connect to + diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.cs.xlf index 3feab3a1a4f..2f2fd4e5eb4 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.cs.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Nenašel se žádný spuštěný hostitel aplikací. Nejprve spusťte spuštění pomocí příkazu „aspire run“. - - Describe resources in a running apphost. Umožňuje zobrazit snímky prostředků ze spuštěného hostitele aplikací Aspire. @@ -27,16 +22,6 @@ Nenašel se žádný projekt hostitele aplikací. - - No AppHosts found in current directory. Showing all running AppHosts. - V aktuálním adresáři se nenašli žádní hostitelé aplikací. Zobrazují se všichni spuštění hostitelé aplikací. - - - - The path to the Aspire AppHost project file. - Cesta k souboru projektu Aspire AppHost. - - The name of the resource to display. If not specified, all resources are shown. Název prostředku, který se má zobrazit. Pokud se nezadá, zobrazí se všechny prostředky. @@ -47,14 +32,9 @@ Prostředek {0} nebyl nalezen. - - Scanning for running AppHosts... - Vyhledávání spuštěných hostitelů aplikací... - - - - Select an AppHost: - Vyberte hostitele aplikací: + + describe + describe diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.de.xlf index 75f974ade14..f993e176ddc 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.de.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Es wurde kein ausgeführter AppHost gefunden. Verwenden Sie zuerst „aspire run“, um einen zu starten. - - Describe resources in a running apphost. Zeigen Sie Ressourcenmomentaufnahmen von einem laufenden Aspire-AppHost an. @@ -27,16 +22,6 @@ Kein AppHost-Projekt gefunden. - - No AppHosts found in current directory. Showing all running AppHosts. - Im aktuellen Verzeichnis wurden keine AppHosts gefunden. Es werden alle aktiven AppHosts angezeigt. - - - - The path to the Aspire AppHost project file. - Der Pfad zur Aspire AppHost-Projektdatei. - - The name of the resource to display. If not specified, all resources are shown. Der Name der anzuzeigenden Ressource. Wenn keine Angabe erfolgt, werden alle Ressourcen angezeigt. @@ -47,14 +32,9 @@ Die Ressource "{0}" wurde nicht gefunden. - - Scanning for running AppHosts... - Suche nach aktiven AppHosts … - - - - Select an AppHost: - AppHost auswählen: + + describe + describe diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.es.xlf index 2aab00c2be8..d716125c1bf 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.es.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - No se encontró ningún AppHost en ejecución. Usa 'aspire run' para iniciar uno primero. - - Describe resources in a running apphost. Muestra instantáneas de recursos de un apphost de Aspire en ejecución. @@ -27,16 +22,6 @@ No se encontró ningún proyecto de AppHost. - - No AppHosts found in current directory. Showing all running AppHosts. - No se encontró ningún AppHosts en el directorio actual. Mostrando todos los AppHosts en ejecución. - - - - The path to the Aspire AppHost project file. - La ruta de acceso al archivo del proyecto host de la AppHost Aspire. - - The name of the resource to display. If not specified, all resources are shown. Nombre del recurso que se va a mostrar. Si no se especifica, se muestran todos los recursos. @@ -47,14 +32,9 @@ No se encuentra el recurso '{0}'. - - Scanning for running AppHosts... - Buscando AppHosts en ejecución... - - - - Select an AppHost: - Seleccione un AppHost: + + describe + describe diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.fr.xlf index 3578d436ac2..9e00171f040 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.fr.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Désolé, aucun AppHost en cours d’exécution n’a été trouvé. Utilisez « aspire run » pour en démarrer un. - - Describe resources in a running apphost. Afficher les instantanés de ressource d’un Apphost Aspire en cours d’exécution. @@ -27,16 +22,6 @@ Désolé, aucun projet AppHost n’a été trouvé. - - No AppHosts found in current directory. Showing all running AppHosts. - Désolé, aucun AppHosts n’a été trouvé dans le répertoire actif. Affichage de tous les AppHosts en cours d’exécution. - - - - The path to the Aspire AppHost project file. - Chemin d’accès au fichier projet AppHost Aspire. - - The name of the resource to display. If not specified, all resources are shown. Le nom de la ressource à afficher. Si aucun nom n’est spécifié, toutes les ressources sont affichées. @@ -47,14 +32,9 @@ Ressource « {0} » introuvable. - - Scanning for running AppHosts... - Recherche des AppHosts en cours d’exécution... - - - - Select an AppHost: - Sélectionner un AppHost : + + describe + describe diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.it.xlf index febb49dcc37..9f1bfa0dbc1 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.it.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Non è stato trovato alcun AppHost in esecuzione. Usare prima di tutto "aspire run" per avviarne uno. - - Describe resources in a running apphost. Consente di visualizzare gli snapshot delle risorse da un apphost Aspire in esecuzione. @@ -27,16 +22,6 @@ Nessun progetto AppHost trovato. - - No AppHosts found in current directory. Showing all running AppHosts. - Nessun AppHost trovato nella directory corrente. Visualizzazione di tutti gli AppHost in esecuzione. - - - - The path to the Aspire AppHost project file. - Percorso del file di un progetto AppHost di Aspire. - - The name of the resource to display. If not specified, all resources are shown. Nome della risorsa da visualizzare. Se non è specificato, vengono visualizzate tutte le risorse. @@ -47,14 +32,9 @@ Risorsa '{0}' non trovata. - - Scanning for running AppHosts... - Analisi per l'esecuzione di AppHosts in corso... - - - - Select an AppHost: - Selezionare un AppHost: + + describe + describe diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ja.xlf index 1767e395415..6d6d3c7faa5 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ja.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - 実行中の AppHost は見つかりません。最初に 'aspire run' を使って起動してください。 - - Describe resources in a running apphost. 実行中の Aspire apphost からのリソース スナップショットを表示します。 @@ -27,16 +22,6 @@ AppHost プロジェクトは見つかりません。 - - No AppHosts found in current directory. Showing all running AppHosts. - 現在のディレクトリ内に AppHost が見つかりません。実行中のすべての AppHost を表示しています。 - - - - The path to the Aspire AppHost project file. - Aspire AppHost プロジェクト ファイルへのパス。 - - The name of the resource to display. If not specified, all resources are shown. 表示するリソースの名前。指定しない場合は、すべてのリソースが表示されます。 @@ -47,14 +32,9 @@ リソース '{0}' が見つかりません。 - - Scanning for running AppHosts... - 実行中の AppHost をスキャンしています... - - - - Select an AppHost: - AppHost を選択: + + describe + describe diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ko.xlf index e65dbcfe84d..f124e228f8d 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ko.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - 실행 중인 AppHost를 찾을 수 없습니다. 'aspire run'을 사용하여 먼저 하나를 시작합니다. - - Describe resources in a running apphost. 실행 중인 Aspire AppHost의 리소스 스냅샷을 표시합니다. @@ -27,16 +22,6 @@ AppHost 프로젝트를 찾을 수 없습니다. - - No AppHosts found in current directory. Showing all running AppHosts. - 현재 디렉터리에 AppHost가 없습니다. 실행 중인 모든 AppHost를 표시하는 중입니다. - - - - The path to the Aspire AppHost project file. - Aspire AppHost 프로젝트 파일의 경로입니다. - - The name of the resource to display. If not specified, all resources are shown. 표시할 리소스의 이름입니다. 지정하지 않으면 모든 리소스가 표시됩니다. @@ -47,14 +32,9 @@ '{0}' 리소스를 찾을 수 없습니다. - - Scanning for running AppHosts... - 실행 중인 AppHost를 검색하는 중... - - - - Select an AppHost: - AppHost 선택: + + describe + describe diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pl.xlf index a76b5b568a6..0ab45d8d99f 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pl.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Nie znaleziono działającego hosta AppHost. Najpierw uruchom go poleceniem „aspire run”. - - Describe resources in a running apphost. Wyświetl migawki zasobów z działającego hosta aplikacji Aspire. @@ -27,16 +22,6 @@ Nie znaleziono projektu AppHost. - - No AppHosts found in current directory. Showing all running AppHosts. - Nie znaleziono hostów aplikacji w bieżącym katalogu. Wyświetlanie wszystkich uruchomionych hostów aplikacji. - - - - The path to the Aspire AppHost project file. - Ścieżka do pliku projektu hosta AppHost platformy Aspire. - - The name of the resource to display. If not specified, all resources are shown. Nazwa zasobu do wyświetlenia. Jeśli nie zostanie określony, zostaną wyświetlone wszystkie zasoby. @@ -47,14 +32,9 @@ Nie znaleziono „{0}” zasobu. - - Scanning for running AppHosts... - Skanowanie w poszukiwaniu uruchomionych hostów aplikacji... - - - - Select an AppHost: - Wybierz hosta aplikacji: + + describe + describe diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pt-BR.xlf index 409dc0ce0ad..33066cbb42a 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.pt-BR.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Nenhum AppHost em execução encontrado. Use "aspire run" para iniciar um primeiro. - - Describe resources in a running apphost. Exiba instantâneos de recursos de um apphost do Aspire em execução. @@ -27,16 +22,6 @@ Nenhum projeto AppHost encontrado. - - No AppHosts found in current directory. Showing all running AppHosts. - Nenhum AppHosts encontrado no diretório atual. Mostrando todos os AppHosts em execução. - - - - The path to the Aspire AppHost project file. - O caminho para o arquivo de projeto do Aspire AppHost. - - The name of the resource to display. If not specified, all resources are shown. O nome do recurso a ser exibido. Se não for especificado, todos os recursos serão mostrados. @@ -47,14 +32,9 @@ O recurso “{0}” não foi encontrado. - - Scanning for running AppHosts... - Verificando se há AppHosts em execução... - - - - Select an AppHost: - Selecione um AppHost: + + describe + describe diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ru.xlf index f43cfbbf742..32164c6b30e 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.ru.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Запущенные хосты приложений не найдены. Сначала запустите один из них с помощью команды "aspire run". - - Describe resources in a running apphost. Отображать моментальные снимки ресурсов из запущенного хоста приложений Aspire. @@ -27,16 +22,6 @@ Проект хоста приложений не найден. - - No AppHosts found in current directory. Showing all running AppHosts. - Хосты приложений не найдены в текущем каталоге. Отображаются все запущенные хосты приложений. - - - - The path to the Aspire AppHost project file. - Путь к файлу проекта Aspire AppHost. - - The name of the resource to display. If not specified, all resources are shown. Имя ресурса для отображения. Если не указано, отображаются все ресурсы. @@ -47,14 +32,9 @@ Ресурс "{0}" не найден. - - Scanning for running AppHosts... - Выполняется сканирование на наличие запущенных хостов приложений... - - - - Select an AppHost: - Выберите хост приложения: + + describe + describe diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.tr.xlf index 60e28a8c601..9b3524d0403 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.tr.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Çalışan AppHost bulunamadı. Önce birini başlatmak için 'aspire run' komutunu kullanın. - - Describe resources in a running apphost. Çalışan bir Aspire apphost'tan kaynak anlık görüntülerini göster. @@ -27,16 +22,6 @@ AppHost projesi bulunamadı. - - No AppHosts found in current directory. Showing all running AppHosts. - Geçerli dizinde AppHost bulunamadı. Tüm çalışan AppHost'lar gösteriliyor. - - - - The path to the Aspire AppHost project file. - Aspire AppHost proje dosyasının yolu. - - The name of the resource to display. If not specified, all resources are shown. Görüntülenecek kaynağın adı. Belirtilmezse tüm kaynaklar gösterilir. @@ -47,14 +32,9 @@ '{0}' kaynağı bulunamadı. - - Scanning for running AppHosts... - Çalışan AppHost'lar taranıyor... - - - - Select an AppHost: - AppHost seçin: + + describe + describe diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hans.xlf index c31c1e4a7cb..17fd7b1040a 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hans.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - 找不到正在运行的 AppHost。请先使用 "aspire run" 启动一个。 - - Describe resources in a running apphost. 显示正在运行的 Aspire 应用主机中的资源快照。 @@ -27,16 +22,6 @@ 找不到 AppHost 项目。 - - No AppHosts found in current directory. Showing all running AppHosts. - 当前目录中未找到 AppHost。显示所有正在运行的 AppHost。 - - - - The path to the Aspire AppHost project file. - Aspire AppHost 项目文件的路径。 - - The name of the resource to display. If not specified, all resources are shown. 要显示的资源名称。如果未指定,则显示所有资源。 @@ -47,14 +32,9 @@ 找不到资源“{0}”。 - - Scanning for running AppHosts... - 正在扫描处于运行状态的 AppHost... - - - - Select an AppHost: - 选择 AppHost: + + describe + describe diff --git a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hant.xlf index f4fc0ce158d..4e9c1ddce06 100644 --- a/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DescribeCommandStrings.zh-Hant.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - 找不到正在執行的 AppHost。請先使用 'aspire run' 啟動一個。 - - Describe resources in a running apphost. 顯示正在執行的 Aspire AppHost 的資源快照集。 @@ -27,16 +22,6 @@ 找不到 AppHost 專案。 - - No AppHosts found in current directory. Showing all running AppHosts. - 在目前的目錄中找不到 AppHost。顯示所有正在執行的 AppHost。 - - - - The path to the Aspire AppHost project file. - Aspire AppHost 專案檔案的路徑。 - - The name of the resource to display. If not specified, all resources are shown. 要顯示的資源名稱。如果未指定,則會顯示所有資源。 @@ -47,14 +32,9 @@ 找不到資源 '{0}'。 - - Scanning for running AppHosts... - 正在掃描執行中的 AppHost... - - - - Select an AppHost: - 選取 AppHost: + + describe + describe diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf index 3570d122da1..a987c78facc 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.cs.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Nenašel se žádný spuštěný hostitel aplikací. Nejprve spusťte spuštění pomocí příkazu „aspire run“. - - Display logs from resources in a running apphost. Umožňuje zobrazit protokoly z prostředků v běžícím hostiteli aplikací Aspire. @@ -22,21 +17,11 @@ Výstupní protokoly ve formátu JSON (NDJSON). - - No AppHosts found in current directory. Showing all running AppHosts. - V aktuálním adresáři se nenašli žádní hostitelé aplikací. Zobrazují se všichni spuštění hostitelé aplikací. - - No resources found. Nenašly se žádné prostředky. - - The path to the Aspire AppHost project file. - Cesta k souboru projektu Aspire AppHost. - - The name of the resource to get logs for. If not specified, logs from all resources are shown. Název prostředku, pro který se mají načíst protokoly. Pokud se nezadá, zobrazí se protokoly ze všech prostředků. @@ -52,14 +37,9 @@ Pokud nepoužíváte --follow, vyžaduje se název prostředku. Pokud chcete streamovat protokoly ze všech prostředků, použijte --follow. - - Scanning for running AppHosts... - Vyhledávání spuštěných hostitelů aplikací... - - - - Select an AppHost: - Vyberte hostitele aplikací: + + stream logs from + stream logs from diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf index 0506b7af164..d66d2758a3a 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.de.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Es wurde kein aktiver AppHost gefunden. Verwenden Sie zuerst „aspire run“, um einen zu starten. - - Display logs from resources in a running apphost. Protokolle von Ressourcen in einem laufenden Aspire-AppHost anzeigen. @@ -22,21 +17,11 @@ Protokolle im JSON-Format (NDJSON) ausgeben. - - No AppHosts found in current directory. Showing all running AppHosts. - Im aktuellen Verzeichnis wurden keine AppHosts gefunden. Es werden alle aktiven AppHosts angezeigt. - - No resources found. Keine Ressourcen gefunden. - - The path to the Aspire AppHost project file. - Der Pfad zur Aspire AppHost-Projektdatei. - - The name of the resource to get logs for. If not specified, logs from all resources are shown. Name der Ressource, für die Protokolle abgerufen werden sollen. Wenn keine Angabe erfolgt, werden Protokolle von allen Ressourcen angezeigt. @@ -52,14 +37,9 @@ Ein Ressourcenname ist erforderlich, wenn --follow nicht verwendet wird. Verwenden Sie --follow, um Protokolle aller Ressourcen zu streamen. - - Scanning for running AppHosts... - Suche nach aktiven AppHosts … - - - - Select an AppHost: - AppHost auswählen: + + stream logs from + stream logs from diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf index 69b69d68e25..2ca6b45534e 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.es.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - No se encontró ningún AppHost en ejecución. Use "ejecutar aspire" para iniciar uno primero. - - Display logs from resources in a running apphost. Muestra los registros de los recursos en un host de aplicaciones Aspire en ejecución. @@ -22,21 +17,11 @@ Registros de salida en formato JSON (NDJSON). - - No AppHosts found in current directory. Showing all running AppHosts. - No se encontró ningún AppHosts en el directorio actual. Mostrando todos los AppHosts en ejecución. - - No resources found. No se encontraron recursos. - - The path to the Aspire AppHost project file. - La ruta de acceso al archivo del proyecto host de la AppHost Aspire. - - The name of the resource to get logs for. If not specified, logs from all resources are shown. Nombre del recurso para el que se van a obtener registros. Si no se especifica, se muestran los registros de todos los recursos. @@ -52,14 +37,9 @@ Se requiere un nombre de recurso cuando no se usa --follow. Use --follow para transmitir registros de todos los recursos. - - Scanning for running AppHosts... - Buscando AppHosts en ejecución... - - - - Select an AppHost: - Seleccione un AppHost: + + stream logs from + stream logs from diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf index ab190ddba81..ffbba0123e0 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.fr.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Désolé, aucun AppHost en cours d’exécution n’a été trouvé. Utilisez « aspire run » pour en démarrer un. - - Display logs from resources in a running apphost. Afficher les journaux des ressources dans un Apphost Aspire en cours d’exécution. @@ -22,21 +17,11 @@ Générer les journaux au format JSON (NDJSON). - - No AppHosts found in current directory. Showing all running AppHosts. - Désolé, aucun AppHosts n’a été trouvé dans le répertoire actif. Affichage de tous les AppHosts en cours d’exécution. - - No resources found. Ressources introuvables. - - The path to the Aspire AppHost project file. - Chemin d’accès au fichier projet AppHost Aspire. - - The name of the resource to get logs for. If not specified, logs from all resources are shown. Le nom de la ressource pour laquelle obtenir les journaux. Si aucun nom n’est spécifié, les journaux d’activité de toutes les ressources sont affichées. @@ -52,14 +37,9 @@ Un nom de ressource est requis si vous n’utilisez pas --follow. Utilisez --follow pour diffuser en continu les journaux de toutes les ressources. - - Scanning for running AppHosts... - Recherche des AppHosts en cours d’exécution... - - - - Select an AppHost: - Sélectionner un AppHost : + + stream logs from + stream logs from diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf index 27999b6fd2f..0aa3d27738a 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.it.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Non è stato trovato alcun AppHost in esecuzione. Usare prima di tutto "aspire run" per avviarne uno. - - Display logs from resources in a running apphost. Consente di visualizzare i log delle risorse in un apphost Aspire in esecuzione. @@ -22,21 +17,11 @@ Log di output in formato JSON (NDJSON). - - No AppHosts found in current directory. Showing all running AppHosts. - Nessun AppHost trovato nella directory corrente. Visualizzazione di tutti gli AppHost in esecuzione. - - No resources found. Non sono state trovate risorse. - - The path to the Aspire AppHost project file. - Percorso del file di un progetto AppHost di Aspire. - - The name of the resource to get logs for. If not specified, logs from all resources are shown. Nome della risorsa per cui ottenere i log. Se non specificato, vengono visualizzati i log di tutte le risorse. @@ -52,14 +37,9 @@ Quando non si usa --follow, è necessario specificare un nome di risorsa. Usare --follow per trasmettere i log da tutte le risorse. - - Scanning for running AppHosts... - Analisi per l'esecuzione di AppHosts in corso... - - - - Select an AppHost: - Selezionare un AppHost: + + stream logs from + stream logs from diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf index 875087f00bb..fdaa7bf5ab7 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ja.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - 実行中の AppHost は見つかりません。最初に 'aspire run' を使って起動してください。 - - Display logs from resources in a running apphost. 実行中の Aspire apphost のリソースからログを表示します。 @@ -22,21 +17,11 @@ ログを JSON 形式 (NDJSON) で出力します。 - - No AppHosts found in current directory. Showing all running AppHosts. - 現在のディレクトリ内に AppHost が見つかりません。実行中のすべての AppHost を表示しています。 - - No resources found. リソースが見つかりませんでした。 - - The path to the Aspire AppHost project file. - Aspire AppHost プロジェクト ファイルへのパス。 - - The name of the resource to get logs for. If not specified, logs from all resources are shown. ログ取得の対象とするリソースの名前。指定しない場合は、すべてのリソースのログが表示されます。 @@ -52,14 +37,9 @@ --follow を使わない場合はリソース名の指定が必須です。--follow を使う場合、すべてのリソースのログがストリーミングされます。 - - Scanning for running AppHosts... - 実行中の AppHost をスキャンしています... - - - - Select an AppHost: - AppHost を選択: + + stream logs from + stream logs from diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf index 1f99f1d1a82..8cecd63f5eb 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ko.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - 실행 중인 AppHost를 찾을 수 없습니다. 'aspire run'을 사용하여 먼저 하나를 시작합니다. - - Display logs from resources in a running apphost. 실행 중인 Aspire AppHost에서 리소스의 로그를 표시합니다. @@ -22,21 +17,11 @@ JSON 형식(NDJSON)으로 로그를 출력합니다. - - No AppHosts found in current directory. Showing all running AppHosts. - 현재 디렉터리에 AppHost가 없습니다. 실행 중인 모든 AppHost를 표시하는 중입니다. - - No resources found. 리소스를 찾을 수 없습니다. - - The path to the Aspire AppHost project file. - Aspire AppHost 프로젝트 파일의 경로입니다. - - The name of the resource to get logs for. If not specified, logs from all resources are shown. 로그를 가져올 리소스의 이름입니다. 지정하지 않으면 모든 리소스의 로그가 표시됩니다. @@ -52,14 +37,9 @@ --follow를 사용하지 않을 경우 리소스 이름은 필수 항목입니다. --follow를 사용하여 모든 리소스의 로그를 스트리밍합니다. - - Scanning for running AppHosts... - 실행 중인 AppHost를 검색하는 중... - - - - Select an AppHost: - AppHost 선택: + + stream logs from + stream logs from diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf index c3ff4fd50eb..ca63877422c 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pl.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Nie znaleziono uruchomionego hosta aplikacji. Najpierw uruchom go poleceniem „aspire run”. - - Display logs from resources in a running apphost. Wyświetl dzienniki z zasobów w działającym hoście usługi Aspire. @@ -22,21 +17,11 @@ Dzienniki wyjściowe w formacie JSON (NDJSON). - - No AppHosts found in current directory. Showing all running AppHosts. - Nie znaleziono hostów aplikacji w bieżącym katalogu. Wyświetlanie wszystkich uruchomionych hostów aplikacji. - - No resources found. Nie znaleziono żadnych zasobów. - - The path to the Aspire AppHost project file. - Ścieżka do pliku projektu hosta AppHost platformy Aspire. - - The name of the resource to get logs for. If not specified, logs from all resources are shown. Nazwa zasobu, którego dzienniki mają zostać pobrane. Jeśli nie podasz nazwy, pokażą się dzienniki ze wszystkich zasobów. @@ -52,14 +37,9 @@ Nazwa zasobu jest wymagana, jeśli nie używasz opcji --follow. Użyj opcji --follow, aby przesyłać strumieniowo dzienniki ze wszystkich zasobów. - - Scanning for running AppHosts... - Skanowanie w poszukiwaniu uruchomionych hostów aplikacji... - - - - Select an AppHost: - Wybierz hosta aplikacji: + + stream logs from + stream logs from diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf index cda7ef34669..f9aa994a85a 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.pt-BR.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Nenhum AppHost em execução encontrado. Use "aspire run" para iniciar um primeiro. - - Display logs from resources in a running apphost. Exiba logs de recursos em um apphost do Aspire em execução. @@ -22,21 +17,11 @@ Logs de saída no formato JSON (NDJSON). - - No AppHosts found in current directory. Showing all running AppHosts. - Nenhum AppHosts encontrado no diretório atual. Mostrando todos os AppHosts em execução. - - No resources found. Nenhum recurso encontrado. - - The path to the Aspire AppHost project file. - O caminho para o arquivo de projeto do Aspire AppHost. - - The name of the resource to get logs for. If not specified, logs from all resources are shown. O nome do recurso para o qual obter logs. Se não for especificado, os logs de todos os recursos serão mostrados. @@ -52,14 +37,9 @@ Um nome de recurso é necessário ao não usar --follow. Use --follow para transmitir logs de todos os recursos. - - Scanning for running AppHosts... - Verificando se há AppHosts em execução... - - - - Select an AppHost: - Selecione um AppHost: + + stream logs from + stream logs from diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf index 445bdd48294..120c8ea4dcc 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.ru.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Запущенные хосты приложений не найдены. Сначала запустите один из них с помощью команды "aspire run". - - Display logs from resources in a running apphost. Отображать журналы ресурсов в запущенном хосте приложений Aspire. @@ -22,21 +17,11 @@ Вывод журналов в формате JSON (NDJSON). - - No AppHosts found in current directory. Showing all running AppHosts. - Хосты приложений не найдены в текущем каталоге. Отображаются все запущенные хосты приложений. - - No resources found. Ресурсы не найдены. - - The path to the Aspire AppHost project file. - Путь к файлу проекта Aspire AppHost. - - The name of the resource to get logs for. If not specified, logs from all resources are shown. Имя ресурса, для которого нужно получить журналы. Если не указано, отображаются журналы из всех ресурсов. @@ -52,14 +37,9 @@ При отсутствии параметра --follow необходимо указать имя ресурса. Используйте --follow для потоковой передачи журналов из всех ресурсов. - - Scanning for running AppHosts... - Выполняется сканирование на наличие запущенных хостов приложений... - - - - Select an AppHost: - Выберите хост приложения: + + stream logs from + stream logs from diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf index d3a450aa5e8..70d5ab49e06 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.tr.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Çalışan AppHost bulunamadı. Önce birini başlatmak için 'aspire run' komutunu kullanın. - - Display logs from resources in a running apphost. Çalışan bir Aspire apphost'taki kaynakların günlüklerini görüntüle. @@ -22,21 +17,11 @@ Günlükleri JSON biçiminde (NDJSON) çıkar. - - No AppHosts found in current directory. Showing all running AppHosts. - Geçerli dizinde AppHost bulunamadı. Tüm çalışan AppHost'lar gösteriliyor. - - No resources found. Kaynak bulunamadı. - - The path to the Aspire AppHost project file. - Aspire AppHost proje dosyasının yolu. - - The name of the resource to get logs for. If not specified, logs from all resources are shown. Günlükleri alınacak kaynağın adı. Belirtilmezse tüm kaynakların günlükleri gösterilir. @@ -52,14 +37,9 @@ --follow kullanılmadığında kaynak adı gereklidir. Tüm kaynaklardan günlük akışı yapmak için --follow kullanın. - - Scanning for running AppHosts... - Çalışan AppHost'lar taranıyor... - - - - Select an AppHost: - AppHost seçin: + + stream logs from + stream logs from diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf index f2e6a1eea79..388cd8fdcbe 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hans.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - 找不到正在运行的 AppHost。请先使用 "aspire run" 启动一个。 - - Display logs from resources in a running apphost. 显示正在运行的 Aspire 应用主机中资源的日志。 @@ -22,21 +17,11 @@ 以 JSON 格式输出日志(NDJSON)。 - - No AppHosts found in current directory. Showing all running AppHosts. - 当前目录中未找到 AppHost。显示所有正在运行的 AppHost。 - - No resources found. 未找到资源。 - - The path to the Aspire AppHost project file. - Aspire AppHost 项目文件的路径。 - - The name of the resource to get logs for. If not specified, logs from all resources are shown. 要获取其日志的资源的名称。如果未指定,则显示所有资源的日志。 @@ -52,14 +37,9 @@ 不使用 --follow 时必须指定资源名称。使用 --follow 流式传输所有资源的日志。 - - Scanning for running AppHosts... - 正在扫描处于运行状态的 AppHost... - - - - Select an AppHost: - 选择 AppHost: + + stream logs from + stream logs from diff --git a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf index afaf1ff2bd7..babbe1c7392 100644 --- a/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/LogsCommandStrings.zh-Hant.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - 找不到正在執行的 AppHost。請先使用 'aspire run' 啟動一個。 - - Display logs from resources in a running apphost. 顯示正在執行 Aspire Apphost 中的資源記錄。 @@ -22,21 +17,11 @@ 輸出記錄採用 JSON 格式 (NDJSON)。 - - No AppHosts found in current directory. Showing all running AppHosts. - 在目前的目錄中找不到 AppHost。顯示所有正在執行的 AppHost。 - - No resources found. 找不到任何資源。 - - The path to the Aspire AppHost project file. - Aspire AppHost 專案檔案的路徑。 - - The name of the resource to get logs for. If not specified, logs from all resources are shown. 要取得其記錄的資源名稱。如果未指定,則顯示所有資源的記錄。 @@ -52,14 +37,9 @@ 當未使用 --follow 時,必須指定資源名稱。使用 --follow 從所有資源串流記錄。 - - Scanning for running AppHosts... - 正在掃描執行中的 AppHost... - - - - Select an AppHost: - 選取 AppHost: + + stream logs from + stream logs from diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf index 2a355cde589..52aac86adba 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf @@ -32,16 +32,6 @@ Výstup ve formátu JSON - - No running AppHosts found. - Nebyl nalezen žádný spuštěný hostitel aplikací. - - - - Scanning for running AppHosts... - Vyhledávání spuštěných hostitelů aplikací... - - Unknown Neznámé diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf index fde09a8b071..b2561e7068d 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf @@ -32,16 +32,6 @@ Ausgabe im JSON-Format. - - No running AppHosts found. - Es wurden keine ausgeführten AppHosts gefunden. - - - - Scanning for running AppHosts... - Suche nach aktiven AppHosts … - - Unknown Unbekannt diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf index 3ad54273a7a..8c5106711b0 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf @@ -32,16 +32,6 @@ Salida en formato JSON. - - No running AppHosts found. - No se encontraron AppHosts en ejecución. - - - - Scanning for running AppHosts... - Buscando AppHosts en ejecución... - - Unknown Desconocido diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf index fd68f6538be..aa6420a6fc0 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf @@ -32,16 +32,6 @@ Sortie au format JSON. - - No running AppHosts found. - Désolé, aucun AppHost en cours d’exécution n’a été trouvé. - - - - Scanning for running AppHosts... - Recherche des AppHosts en cours d’exécution... - - Unknown Inconnu diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf index 24daf99b63a..759905eb87d 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf @@ -32,16 +32,6 @@ Output in formato JSON. - - No running AppHosts found. - Non sono stati trovati AppHost in esecuzione. - - - - Scanning for running AppHosts... - Analisi per l'esecuzione di AppHosts in corso... - - Unknown Sconosciuto diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf index 8657a3107f1..23c3f1a25b0 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf @@ -32,16 +32,6 @@ JSON 形式で出力します。 - - No running AppHosts found. - 実行中の AppHost が見つかりません。 - - - - Scanning for running AppHosts... - 実行中の AppHost をスキャンしています... - - Unknown 不明 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf index 44746ceaf82..e6d6e9422ac 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf @@ -32,16 +32,6 @@ JSON 형식의 출력입니다. - - No running AppHosts found. - 실행 중인 AppHost를 찾을 수 없습니다. - - - - Scanning for running AppHosts... - 실행 중인 AppHost를 검색하는 중... - - Unknown 알 수 없음 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf index 8c664b20778..fc24855931d 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf @@ -32,16 +32,6 @@ Wynik w formacie JSON. - - No running AppHosts found. - Nie znaleziono uruchomionych hostów aplikacji. - - - - Scanning for running AppHosts... - Skanowanie w poszukiwaniu uruchomionych hostów aplikacji... - - 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 c8a1b0fb28b..95d88f1f8a6 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf @@ -32,16 +32,6 @@ Saída no formato JSON. - - No running AppHosts found. - Nenhum AppHosts em execução encontrado. - - - - Scanning for running AppHosts... - Verificando se há AppHosts em execução... - - Unknown Desconhecido diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf index 10bc8d9d98c..2dd34feb8f1 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf @@ -32,16 +32,6 @@ Вывод в формате JSON. - - No running AppHosts found. - Запущенные appHosts не найдены. - - - - Scanning for running AppHosts... - Выполняется сканирование на наличие запущенных AppHosts... - - Unknown Неизвестно diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf index 4b82d4ad750..b09121ccfb6 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf @@ -32,16 +32,6 @@ Çıkışı JSON biçiminde oluşturun. - - No running AppHosts found. - Çalışan AppHost bulunamadı. - - - - Scanning for running AppHosts... - Çalışan AppHost'lar taranıyor... - - 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 a3f8a2bb1d2..a5d92c4c9b7 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf @@ -32,16 +32,6 @@ 以 JSON 格式输出。 - - No running AppHosts found. - 未找到正在运行的 AppHost。 - - - - Scanning for running AppHosts... - 正在扫描处于运行状态的 AppHost... - - Unknown 未知 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf index b8a2ec48b01..78faa190478 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf @@ -32,16 +32,6 @@ 以 JSON 格式輸出。 - - No running AppHosts found. - 找不到正在執行的 AppHost。 - - - - Scanning for running AppHosts... - 正在掃描執行中的 AppHost... - - Unknown 未知 diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf index 0609c666d11..88281284b2d 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf @@ -17,21 +17,6 @@ Název prostředku, na kterém se má příkaz provést - - No in-scope AppHosts found. Showing all running AppHosts. - Nebyli nalezeni žádní hostitelé aplikací v daném oboru. Zobrazují se všichni spuštění hostitelé aplikací. - - - - No running AppHosts found. - Nebyl nalezen žádný spuštěný hostitel aplikací. - - - - The path to the Aspire AppHost project file. - Cesta k souboru projektu Aspire AppHost. - - Restart a running resource. Restartujte spuštěný prostředek. @@ -42,14 +27,9 @@ Název prostředku, který se má restartovat - - Scanning for running AppHosts... - Vyhledávání spuštěných hostitelů aplikací... - - - - Select which AppHost to connect to: - Vyberte hostitele aplikace, ke kterému se chcete připojit: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf index 4af139bfded..0474293a670 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf @@ -17,21 +17,6 @@ Der Name der Ressource, für die der Befehl ausgeführt werden soll. - - No in-scope AppHosts found. Showing all running AppHosts. - Es wurden keine AppHosts im Geltungsbereich gefunden. Es werden alle aktiven AppHosts angezeigt. - - - - No running AppHosts found. - Es wurden keine ausgeführten AppHosts gefunden. - - - - The path to the Aspire AppHost project file. - Der Pfad zur Aspire AppHost-Projektdatei. - - Restart a running resource. Starten Sie eine laufende Ressource neu. @@ -42,14 +27,9 @@ Der Name der Ressource, die neu gestartet werden soll. - - Scanning for running AppHosts... - Suche nach aktiven AppHosts … - - - - Select which AppHost to connect to: - Wählen Sie den AppHost aus, mit dem eine Verbindung hergestellt werden soll: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf index 897ed879f4c..fb0511f74c0 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf @@ -17,21 +17,6 @@ El nombre del recurso de destino en el que se ejecutará el comando. - - No in-scope AppHosts found. Showing all running AppHosts. - No se encontraron AppHosts dentro del ámbito. Mostrando todos los AppHosts en ejecución. - - - - No running AppHosts found. - No se encontraron AppHosts en ejecución. - - - - The path to the Aspire AppHost project file. - La ruta de acceso al archivo del proyecto host de la AppHost Aspire. - - Restart a running resource. Reinicie un recurso en ejecución. @@ -42,14 +27,9 @@ Nombre del recurso que se va a reiniciar. - - Scanning for running AppHosts... - Buscando AppHosts en ejecución... - - - - Select which AppHost to connect to: - Seleccione a qué AppHost conectarse: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf index aabcc91a0be..83f8c10a082 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf @@ -17,21 +17,6 @@ Nom de la ressource sur laquelle exécuter la commande. - - No in-scope AppHosts found. Showing all running AppHosts. - Aucun AppHost dans le périmètre n’a été trouvé. Affichage de tous les AppHosts en cours d’exécution. - - - - No running AppHosts found. - Désolé, aucun AppHost en cours d’exécution n’a été trouvé. - - - - The path to the Aspire AppHost project file. - Chemin d’accès au fichier projet AppHost Aspire. - - Restart a running resource. Redémarrer une ressource en cours d’exécution. @@ -42,14 +27,9 @@ Le nom de la ressource à redémarrer. - - Scanning for running AppHosts... - Recherche des AppHosts en cours d’exécution... - - - - Select which AppHost to connect to: - Sélectionnez l’AppHost auquel vous connecter : + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf index 1547750f42e..4cee2b76cd0 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf @@ -17,21 +17,6 @@ Nome della risorsa di destinazione rispetto al quale eseguire il comando. - - No in-scope AppHosts found. Showing all running AppHosts. - Nessun AppHost in ambito trovato. Visualizzazione di tutti gli AppHost in esecuzione. - - - - No running AppHosts found. - Non sono stati trovati AppHost in esecuzione. - - - - The path to the Aspire AppHost project file. - Percorso del file di un progetto AppHost di Aspire. - - Restart a running resource. Riavviare una risorsa in esecuzione. @@ -42,14 +27,9 @@ Nome della risorsa da riavviare. - - Scanning for running AppHosts... - Analisi per l'esecuzione di AppHosts in corso... - - - - Select which AppHost to connect to: - Selezionare l'AppHost a cui connettersi: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf index 98ff796b7f6..c756ac645ec 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf @@ -17,21 +17,6 @@ コマンドを実行する対象のリソースの名前。 - - No in-scope AppHosts found. Showing all running AppHosts. - スコープ内の AppHost が見つかりません。実行中のすべての AppHost を表示しています。 - - - - No running AppHosts found. - 実行中の AppHost が見つかりません。 - - - - The path to the Aspire AppHost project file. - Aspire AppHost プロジェクト ファイルへのパス。 - - Restart a running resource. 実行中のリソースを再起動します。 @@ -42,14 +27,9 @@ 再起動するリソースの名前。 - - Scanning for running AppHosts... - 実行中の AppHost をスキャンしています... - - - - Select which AppHost to connect to: - 接続する AppHost を選択してください: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf index 3b6f05348bd..ab1d2740f04 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf @@ -17,21 +17,6 @@ 명령을 실행할 대상 리소스의 이름입니다. - - No in-scope AppHosts found. Showing all running AppHosts. - 범위 내 AppHost를 찾을 수 없습니다. 실행 중인 모든 AppHost를 표시하는 중입니다. - - - - No running AppHosts found. - 실행 중인 AppHost를 찾을 수 없습니다. - - - - The path to the Aspire AppHost project file. - Aspire AppHost 프로젝트 파일의 경로입니다. - - Restart a running resource. 실행 중인 리소스를 다시 시작합니다. @@ -42,14 +27,9 @@ 다시 시작할 리소스의 이름입니다. - - Scanning for running AppHosts... - 실행 중인 AppHost를 검색하는 중... - - - - Select which AppHost to connect to: - 연결할 AppHost 선택: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf index d01faaa7581..3d6de3c32e4 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf @@ -17,21 +17,6 @@ Nazwa zasobu, na którym ma zostać wykonane polecenie. - - No in-scope AppHosts found. Showing all running AppHosts. - Nie znaleziono hostów aplikacji w zakresie. Wyświetlanie wszystkich uruchomionych hostów aplikacji. - - - - No running AppHosts found. - Nie znaleziono uruchomionych hostów aplikacji. - - - - The path to the Aspire AppHost project file. - Ścieżka do pliku projektu hosta AppHost platformy Aspire. - - Restart a running resource. Uruchom ponownie uruchomiony zasób. @@ -42,14 +27,9 @@ Nazwa zasobu do ponownego uruchomienia. - - Scanning for running AppHosts... - Skanowanie w poszukiwaniu uruchomionych hostów aplikacji... - - - - Select which AppHost to connect to: - Wybierz host aplikacji, z którym chcesz nawiązać połączenie: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf index fe259e5a1d4..46f3fabd4e5 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf @@ -17,21 +17,6 @@ O nome do recurso no qual executar o comando. - - No in-scope AppHosts found. Showing all running AppHosts. - Nenhum AppHosts no escopo encontrado. Mostrando todos os AppHosts em execução. - - - - No running AppHosts found. - Nenhum AppHosts em execução encontrado. - - - - The path to the Aspire AppHost project file. - O caminho para o arquivo de projeto do Aspire AppHost. - - Restart a running resource. Reinicie um recurso em execução. @@ -42,14 +27,9 @@ O nome do recurso a ser reiniciado. - - Scanning for running AppHosts... - Verificando se há AppHosts em execução... - - - - Select which AppHost to connect to: - Selecione a qual AppHost se conectar: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf index 6338b30ecfd..fe5a61fa4df 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf @@ -17,21 +17,6 @@ Имя целевого ресурса, на котором будет выполняться команда. - - No in-scope AppHosts found. Showing all running AppHosts. - Не найдено ни одного AppHost в области действия. Показаны все выполняющиеся AppHost. - - - - No running AppHosts found. - Запущенные appHosts не найдены. - - - - The path to the Aspire AppHost project file. - Путь к файлу проекта Aspire AppHost. - - Restart a running resource. Перезапустить уже запущенный ресурс. @@ -42,14 +27,9 @@ Имя ресурса, который нужно перезапустить. - - Scanning for running AppHosts... - Выполняется сканирование на наличие запущенных хостов приложений... - - - - Select which AppHost to connect to: - Выберите AppHost, к которому нужно подключиться: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf index aba0c8f33d2..b4849ab7b0e 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf @@ -17,21 +17,6 @@ Komutu yürütecek kaynağın adı. - - No in-scope AppHosts found. Showing all running AppHosts. - Kapsam dahilinde AppHost bulunamadı. Tüm çalışan AppHost'lar gösteriliyor. - - - - No running AppHosts found. - Çalışan AppHost bulunamadı. - - - - The path to the Aspire AppHost project file. - Aspire AppHost proje dosyasının yolu. - - Restart a running resource. Çalışan bir kaynağı yeniden başlatın. @@ -42,14 +27,9 @@ Yeniden başlatılacak kaynağın adı. - - Scanning for running AppHosts... - Çalışan AppHost'lar taranıyor... - - - - Select which AppHost to connect to: - Bağlanmak istediğiniz AppHost'u seçin: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf index 0fd237787d8..3f0219bbd27 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf @@ -17,21 +17,6 @@ 要在其上执行命令的资源的名称。 - - No in-scope AppHosts found. Showing all running AppHosts. - 未找到范围内的 AppHost。显示所有正在运行的 AppHost。 - - - - No running AppHosts found. - 未找到正在运行的 AppHost。 - - - - The path to the Aspire AppHost project file. - Aspire AppHost 项目文件的路径。 - - Restart a running resource. 重启正在运行的资源。 @@ -42,14 +27,9 @@ 要重启的资源的名称。 - - Scanning for running AppHosts... - 正在扫描处于运行状态的 AppHost... - - - - Select which AppHost to connect to: - 选择要连接的 AppHost: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf index 04d14e0d03c..84a54b1a00d 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf @@ -17,21 +17,6 @@ 要執行命令的資源名稱。 - - No in-scope AppHosts found. Showing all running AppHosts. - 未找到符合範圍的 AppHost。顯示所有正在執行的 AppHost。 - - - - No running AppHosts found. - 找不到正在執行的 AppHost。 - - - - The path to the Aspire AppHost project file. - Aspire AppHost 專案檔案的路徑。 - - Restart a running resource. 重新啟動正在執行的資源。 @@ -42,14 +27,9 @@ 要重新啟動的資源名稱。 - - Scanning for running AppHosts... - 正在掃描執行中的 AppHost... - - - - Select which AppHost to connect to: - 選取要連接的 AppHost: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf new file mode 100644 index 00000000000..3ee8a6a6ecb --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf @@ -0,0 +1,32 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost to {0}: + Select an AppHost to {0}: + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf new file mode 100644 index 00000000000..bf2f0af8d6a --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf @@ -0,0 +1,32 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost to {0}: + Select an AppHost to {0}: + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf new file mode 100644 index 00000000000..996b521487f --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf @@ -0,0 +1,32 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost to {0}: + Select an AppHost to {0}: + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf new file mode 100644 index 00000000000..6d1d03e4473 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf @@ -0,0 +1,32 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost to {0}: + Select an AppHost to {0}: + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf new file mode 100644 index 00000000000..3c1ead19010 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf @@ -0,0 +1,32 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost to {0}: + Select an AppHost to {0}: + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf new file mode 100644 index 00000000000..e5afe74a3bc --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf @@ -0,0 +1,32 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost to {0}: + Select an AppHost to {0}: + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf new file mode 100644 index 00000000000..dad5a836341 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf @@ -0,0 +1,32 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost to {0}: + Select an AppHost to {0}: + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf new file mode 100644 index 00000000000..338bdf5cc98 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf @@ -0,0 +1,32 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost to {0}: + Select an AppHost to {0}: + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf new file mode 100644 index 00000000000..19c429cb178 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf @@ -0,0 +1,32 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost to {0}: + Select an AppHost to {0}: + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf new file mode 100644 index 00000000000..db6c97cf0d3 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf @@ -0,0 +1,32 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost to {0}: + Select an AppHost to {0}: + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf new file mode 100644 index 00000000000..7957e903703 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf @@ -0,0 +1,32 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost to {0}: + Select an AppHost to {0}: + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf new file mode 100644 index 00000000000..a9c18e3ccac --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf @@ -0,0 +1,32 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost to {0}: + Select an AppHost to {0}: + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf new file mode 100644 index 00000000000..6468463ed38 --- /dev/null +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf @@ -0,0 +1,32 @@ + + + + + + No running AppHost found. Use 'aspire run' to start one first. + No running AppHost found. Use 'aspire run' to start one first. + + + + No running AppHosts found in the current directory. Showing all running AppHosts: + No running AppHosts found in the current directory. Showing all running AppHosts: + + + + The path to the Aspire AppHost project file. + The path to the Aspire AppHost project file. + + + + Scanning for running AppHosts... + Scanning for running AppHosts... + + + + Select an AppHost to {0}: + Select an AppHost to {0}: + + + + + \ No newline at end of file diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.cs.xlf index f16f5e7fab5..051cce6029b 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.cs.xlf @@ -32,29 +32,14 @@ Je spuštěno více hostitelů aplikací. Pomocí --project určete, který z nich se má zastavit, nebo vyberte jednoho z nich: - - No running AppHosts found in the current directory. Showing all running AppHosts: - V aktuálním adresáři se nenašli žádní spuštění hostitelé aplikací. Zobrazují se všichni spuštění hostitelé aplikací: - - - - No running AppHosts found in scope. - V oboru se nenašli žádní spuštění hostitelé aplikací. - - The path to the Aspire AppHost project file. Cesta k souboru projektu Aspire AppHost. - - Scanning for running AppHosts... - Vyhledávání spuštěných hostitelů aplikací... - - - - Select an AppHost to stop: - Vyberte hostitele aplikací, kterého chcete zastavit: + + stop + stop diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.de.xlf index 3e8aea07d7c..3f97f4de6f7 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.de.xlf @@ -32,29 +32,14 @@ Mehrere AppHosts sind aktiv. Verwenden Sie --project, um anzugeben, welcher AppHost gestoppt werden soll, oder wählen Sie einen aus: - - No running AppHosts found in the current directory. Showing all running AppHosts: - Im aktuellen Verzeichnis wurden keine aktiven AppHosts gefunden. Es werden alle aktiven AppHosts angezeigt: - - - - No running AppHosts found in scope. - Im Bereich wurden keine aktiven AppHosts gefunden. - - The path to the Aspire AppHost project file. Der Pfad zur Aspire AppHost-Projektdatei. - - Scanning for running AppHosts... - Suche nach aktiven AppHosts … - - - - Select an AppHost to stop: - AppHost auswählen, der beendet werden soll: + + stop + stop diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.es.xlf index 0adcc347afe..ca982b45db1 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.es.xlf @@ -32,29 +32,14 @@ Se están ejecutando varios AppHost. Use --project para especificar cuál se va a detener o seleccione uno: - - No running AppHosts found in the current directory. Showing all running AppHosts: - No se encontró ningún AppHosts en ejecución en el directorio actual. Mostrando todos los AppHosts en ejecución: - - - - No running AppHosts found in scope. - No se encontraron AppHosts en ejecución en el ámbito. - - The path to the Aspire AppHost project file. La ruta de acceso al archivo del proyecto host de la AppHost Aspire. - - Scanning for running AppHosts... - Buscando AppHosts en ejecución... - - - - Select an AppHost to stop: - Seleccione un AppHost para detener: + + stop + stop diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.fr.xlf index e4796337a42..63051526d4a 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.fr.xlf @@ -32,29 +32,14 @@ Plusieurs AppHosts sont en cours d’exécution. Utilisez --project pour spécifier celui à arrêter ou sélectionnez-en un : - - No running AppHosts found in the current directory. Showing all running AppHosts: - Désolé, aucun AppHost en cours d’exécution n’a été trouvé dans le répertoire actif. Affichage de tous les AppHosts en cours d’exécution : - - - - No running AppHosts found in scope. - Désolé, aucun AppHost en cours d’exécution n’a été trouvé dans l’étendue. - - The path to the Aspire AppHost project file. Chemin d’accès au fichier projet AppHost Aspire. - - Scanning for running AppHosts... - Recherche des AppHosts en cours d’exécution... - - - - Select an AppHost to stop: - Sélectionnez un AppHost à arrêter : + + stop + stop diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.it.xlf index 4517aa8cda3..8006be0fa0d 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.it.xlf @@ -32,29 +32,14 @@ Sono in esecuzione più AppHost. Usare --project per specificare quale interrompere o selezionarne uno: - - No running AppHosts found in the current directory. Showing all running AppHosts: - Non sono stati trovati AppHost in esecuzione nella directory corrente. Visualizzazione di tutti gli AppHost in esecuzione: - - - - No running AppHosts found in scope. - Non sono stati trovati AppHost in esecuzione nell'ambito. - - The path to the Aspire AppHost project file. Percorso del file di un progetto AppHost di Aspire. - - Scanning for running AppHosts... - Analisi per l'esecuzione di AppHosts in corso... - - - - Select an AppHost to stop: - Selezionare un AppHost da interrompere: + + stop + stop diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ja.xlf index 2713ffa4796..c60b628311b 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ja.xlf @@ -32,29 +32,14 @@ 複数の AppHost が実行されています。--project を使用して停止するものを指定するか、次のいずれかを選択します。 - - No running AppHosts found in the current directory. Showing all running AppHosts: - 現在のディレクトリに実行中の AppHost が見つかりません。実行中のすべての AppHost を表示しています: - - - - No running AppHosts found in scope. - スコープ内に実行中の AppHost が見つかりません。 - - The path to the Aspire AppHost project file. Aspire AppHost プロジェクト ファイルへのパス。 - - Scanning for running AppHosts... - 実行中の AppHost をスキャンしています... - - - - Select an AppHost to stop: - 停止する AppHost を選択します。 + + stop + stop diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ko.xlf index b69ab73a1ba..367c48ea7ad 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ko.xlf @@ -32,29 +32,14 @@ 여러 AppHost가 실행 중입니다. --project를 사용해 중지할 AppHost를 지정하거나 하나를 선택하세요. - - No running AppHosts found in the current directory. Showing all running AppHosts: - 현재 디렉터리에서 실행 중인 AppHost가 없습니다. 실행 중인 모든 AppHost를 표시하는 중입니다. - - - - No running AppHosts found in scope. - 범위 내에서 실행 중인 AppHost를 찾을 수 없습니다. - - The path to the Aspire AppHost project file. Aspire AppHost 프로젝트 파일의 경로입니다. - - Scanning for running AppHosts... - 실행 중인 AppHost를 검색하는 중... - - - - Select an AppHost to stop: - 중지할 apphost를 선택하세요. + + stop + stop diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pl.xlf index 27771d85e00..b679410d82d 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pl.xlf @@ -32,29 +32,14 @@ Uruchomiono wiele hostów aplikacji. Użyj opcji --project, aby określić, która ma zostać zatrzymana, lub wybierz jedną z nich: - - No running AppHosts found in the current directory. Showing all running AppHosts: - Nie znaleziono uruchomionych hostów aplikacji w bieżącym katalogu. Wyświetlanie wszystkich uruchomionych hostów aplikacji: - - - - No running AppHosts found in scope. - Nie znaleziono uruchomionych hostów aplikacji w zakresie. - - The path to the Aspire AppHost project file. Ścieżka do pliku projektu hosta AppHost platformy Aspire. - - Scanning for running AppHosts... - Trwa skanowanie pod kątem uruchamiania hostów aplikacji... - - - - Select an AppHost to stop: - Wybierz hosta aplikacji, aby zatrzymać: + + stop + stop diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pt-BR.xlf index ca258adf2e8..08ba8bb3461 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.pt-BR.xlf @@ -32,29 +32,14 @@ Vários AppHosts estão em execução. Use --project para especificar qual parar ou escolha um: - - No running AppHosts found in the current directory. Showing all running AppHosts: - Nenhum AppHost em execução foi encontrado no diretório atual. Mostrando todos os AppHosts em execução: - - - - No running AppHosts found in scope. - Nenhum AppHosts em execução foi encontrado no escopo. - - The path to the Aspire AppHost project file. O caminho para o arquivo de projeto do Aspire AppHost. - - Scanning for running AppHosts... - Verificando se há AppHosts em execução... - - - - Select an AppHost to stop: - Selecione um AppHost para parar: + + stop + stop diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ru.xlf index fc37708b67e..d505faddf5f 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.ru.xlf @@ -32,29 +32,14 @@ Запущено несколько хостов приложений. Используйте параметр --project, чтобы указать, какой остановить, или выберите один из них: - - No running AppHosts found in the current directory. Showing all running AppHosts: - В текущем каталоге не найдены запущенные хосты приложений. Отображаются все запущенные хосты приложений: - - - - No running AppHosts found in scope. - Запущенные хосты приложений не найдены в области. - - The path to the Aspire AppHost project file. Путь к файлу проекта Aspire AppHost. - - Scanning for running AppHosts... - Выполняется сканирование на наличие запущенных хостов приложений... - - - - Select an AppHost to stop: - Выберите хост приложений для остановки: + + stop + stop diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.tr.xlf index b93c6e54303..e981af2b9a0 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.tr.xlf @@ -32,29 +32,14 @@ Birden çok AppHost çalışıyor. Durdurulacak AppHost'u belirtmek için --project komutunu kullanın veya birini seçin: - - No running AppHosts found in the current directory. Showing all running AppHosts: - Geçerli dizinde çalışan AppHost bulunamadı. Tüm çalışan AppHost'lar gösteriliyor: - - - - No running AppHosts found in scope. - Kapsamda çalışan AppHost bulunamadı. - - The path to the Aspire AppHost project file. Aspire AppHost proje dosyasının yolu. - - Scanning for running AppHosts... - Çalışan AppHost'lar taranıyor... - - - - Select an AppHost to stop: - Durdurulacak AppHost'u seçin: + + stop + stop diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hans.xlf index 07c4a6649a9..c1c5c9d28a9 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hans.xlf @@ -32,29 +32,14 @@ 多个 AppHost 正在运行。使用 --project 指定要停止哪一个,或者选择一个: - - No running AppHosts found in the current directory. Showing all running AppHosts: - 当前目录中未找到正在运行的 AppHost。显示所有正在运行的 AppHost: - - - - No running AppHosts found in scope. - 未在范围内找到正在运行的 AppHost。 - - The path to the Aspire AppHost project file. Aspire AppHost 项目文件的路径。 - - Scanning for running AppHosts... - 正在扫描处于运行状态的 AppHost... - - - - Select an AppHost to stop: - 选择要停止的 AppHost: + + stop + stop diff --git a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hant.xlf index 61441b9ab59..757e0d6f4d9 100644 --- a/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/StopCommandStrings.zh-Hant.xlf @@ -32,29 +32,14 @@ 多個 AppHost 正在執行。請使用 --project 指定要停止的 AppHost,或從中選取一個: - - No running AppHosts found in the current directory. Showing all running AppHosts: - 目前目錄中找不到正在執行的 AppHost。顯示所有正在執行的 AppHost: - - - - No running AppHosts found in scope. - 在範圍內找不到正在執行的 AppHost。 - - The path to the Aspire AppHost project file. Aspire AppHost 專案檔案的路徑。 - - Scanning for running AppHosts... - 正在掃描執行中的 AppHost... - - - - Select an AppHost to stop: - 選取要停止的 AppHost: + + stop + stop diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf index 9444d733e94..5a85464ebae 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.cs.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Nenašel se žádný spuštěný hostitel aplikací. Nejprve spusťte spuštění pomocí příkazu „aspire run“. - - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. Rozhraní API řídicího panelu není k dispozici. Ujistěte se, že hostitel aplikací běží s povoleným řídicím panelem. @@ -52,29 +47,14 @@ Umožňuje zobrazit strukturované protokoly z rozhraní API telemetrie řídicího panelu. - - No AppHosts found in current directory. Showing all running AppHosts. - V aktuálním adresáři se nenašli žádní hostitelé aplikací. Zobrazují se všichni spuštění hostitelé aplikací. - - - - The path to the Aspire AppHost project file. - Cesta k souboru projektu Aspire AppHost. - - Filter by resource name. Filtrovat podle názvu prostředku. - - Scanning for running AppHosts... - Vyhledávání spuštěných hostitelů aplikací... - - - - Select an AppHost: - Vyberte hostitele aplikací: + + view telemetry for + view telemetry for diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf index d298a522dd4..fdcd6597b75 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.de.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Es wurde kein aktiver AppHost gefunden. Verwenden Sie zuerst „aspire run“, um einen zu starten. - - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. Die Dashboard-API ist nicht verfügbar. Stellen Sie sicher, dass der AppHost mit aktiviertem Dashboard ausgeführt wird. @@ -52,29 +47,14 @@ Zeigen Sie strukturierte Protokolle aus der Dashboard-Telemetrie-API an. - - No AppHosts found in current directory. Showing all running AppHosts. - Im aktuellen Verzeichnis wurden keine AppHosts gefunden. Es werden alle aktiven AppHosts angezeigt. - - - - The path to the Aspire AppHost project file. - Der Pfad zur Aspire AppHost-Projektdatei. - - Filter by resource name. Filtern Sie nach Ressourcennamen. - - Scanning for running AppHosts... - Suche nach aktiven AppHosts … - - - - Select an AppHost: - AppHost auswählen: + + view telemetry for + view telemetry for diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf index bc7df265145..de95f01120f 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.es.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - No se encontró ningún AppHost en ejecución. Usa 'aspire run' para iniciar uno primero. - - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. La API de panel no está disponible. Asegúrese de que AppHost se esté ejecutando con Panel habilitado. @@ -52,29 +47,14 @@ Ver los registros estructurados desde la API de telemetría del panel. - - No AppHosts found in current directory. Showing all running AppHosts. - No se encontró ningún AppHosts en el directorio actual. Mostrando todos los AppHosts en ejecución. - - - - The path to the Aspire AppHost project file. - La ruta de acceso al archivo del proyecto host de la AppHost Aspire. - - Filter by resource name. Filtrar por nombre de recurso. - - Scanning for running AppHosts... - Buscando AppHosts en ejecución... - - - - Select an AppHost: - Seleccione un AppHost: + + view telemetry for + view telemetry for diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf index 4c269154cc4..b5a59f791af 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.fr.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Désolé, aucun AppHost en cours d’exécution n’a été trouvé. Utilisez « aspire run » pour en démarrer un. - - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. Désolé, l’API du tableau de bord n’est pas disponible. Vérifiez que AppHost fonctionne avec le tableau de bord activé. @@ -52,29 +47,14 @@ Afficher les journaux structurés via l’API de télémétrie du tableau de bord. - - No AppHosts found in current directory. Showing all running AppHosts. - Désolé, aucun AppHosts n’a été trouvé dans le répertoire actif. Affichage de tous les AppHosts en cours d’exécution. - - - - The path to the Aspire AppHost project file. - Chemin d’accès au fichier projet AppHost Aspire. - - Filter by resource name. Filtrer par nom de ressource. - - Scanning for running AppHosts... - Recherche des AppHosts en cours d’exécution... - - - - Select an AppHost: - Sélectionner un AppHost : + + view telemetry for + view telemetry for diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf index ec4e6ba8145..44eac7104c1 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.it.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Non è stato trovato alcun AppHost in esecuzione. Usare prima di tutto "aspire run" per avviarne uno. - - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. L'API del dashboard non è disponibile. Verificare che AppHost sia in esecuzione con il dashboard abilitato. @@ -52,29 +47,14 @@ Visualizza i log strutturati dall'API di telemetria del dashboard. - - No AppHosts found in current directory. Showing all running AppHosts. - Nessun AppHost trovato nella directory corrente. Visualizzazione di tutti gli AppHost in esecuzione. - - - - The path to the Aspire AppHost project file. - Percorso del file di un progetto AppHost di Aspire. - - Filter by resource name. Filtrare per nome della risorsa. - - Scanning for running AppHosts... - Analisi per l'esecuzione di AppHosts in corso... - - - - Select an AppHost: - Selezionare un AppHost: + + view telemetry for + view telemetry for diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf index f6f1856db7c..0f5a7e82e67 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ja.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - 実行中の AppHost は見つかりません。最初に 'aspire run' を使って起動してください。 - - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. ダッシュボード API は利用できません。ダッシュボードが有効になっている状態で AppHost が実行されていることを確認します。 @@ -52,29 +47,14 @@ ダッシュボード テレメトリ API から構造化ログを表示します。 - - No AppHosts found in current directory. Showing all running AppHosts. - 現在のディレクトリ内に AppHost が見つかりません。実行中のすべての AppHost を表示しています。 - - - - The path to the Aspire AppHost project file. - Aspire AppHost プロジェクト ファイルへのパス。 - - Filter by resource name. リソース名でフィルター処理します。 - - Scanning for running AppHosts... - 実行中の AppHost をスキャンしています... - - - - Select an AppHost: - AppHost を選択: + + view telemetry for + view telemetry for diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf index c29af0f83f0..af40922beb5 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ko.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - 실행 중인 AppHost를 찾을 수 없습니다. 'aspire run'을 사용하여 먼저 하나를 시작합니다. - - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. 대시보드 API를 사용할 수 없습니다. AppHost가 대시보드를 활성화한 상태로 실행 중인지 확인하세요. @@ -52,29 +47,14 @@ 대시보드 원격 분석 API에서 구조화된 로그를 확인합니다. - - No AppHosts found in current directory. Showing all running AppHosts. - 현재 디렉터리에 AppHost가 없습니다. 실행 중인 모든 AppHost를 표시하는 중입니다. - - - - The path to the Aspire AppHost project file. - Aspire AppHost 프로젝트 파일의 경로입니다. - - Filter by resource name. 리소스 이름으로 필터링 - - Scanning for running AppHosts... - 실행 중인 AppHost를 검색하는 중... - - - - Select an AppHost: - AppHost 선택: + + view telemetry for + view telemetry for diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf index 7a296f4b0ff..9185ce3a462 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pl.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Nie znaleziono uruchomionego hosta aplikacji. Najpierw uruchom go poleceniem „aspire run”. - - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. Interfejs API pulpitu nawigacyjnego jest niedostępny. Upewnij się, że host aplikacji działa z włączonym pulpitem nawigacyjnym. @@ -52,29 +47,14 @@ Wyświetl strukturalne logi z interfejsu API telemetrii pulpitu nawigacyjnego. - - No AppHosts found in current directory. Showing all running AppHosts. - Nie znaleziono hostów aplikacji w bieżącym katalogu. Wyświetlanie wszystkich uruchomionych hostów aplikacji. - - - - The path to the Aspire AppHost project file. - Ścieżka do pliku projektu hosta AppHost platformy Aspire. - - Filter by resource name. Filtruj według nazwy zasobu. - - Scanning for running AppHosts... - Skanowanie w poszukiwaniu uruchomionych hostów aplikacji... - - - - Select an AppHost: - Wybierz hosta aplikacji: + + view telemetry for + view telemetry for diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf index 46213469067..33c8b1eee8c 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.pt-BR.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Nenhum AppHost em execução encontrado. Use "aspire run" para iniciar um primeiro. - - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. A API do painel não está disponível. Verifique se o AppHost está em execução com o Painel habilitado. @@ -52,29 +47,14 @@ Veja os logs estruturados da API de telemetria do Painel. - - No AppHosts found in current directory. Showing all running AppHosts. - Nenhum AppHosts encontrado no diretório atual. Mostrando todos os AppHosts em execução. - - - - The path to the Aspire AppHost project file. - O caminho para o arquivo de projeto do Aspire AppHost. - - Filter by resource name. Filtre por nome de recurso. - - Scanning for running AppHosts... - Verificando se há AppHosts em execução... - - - - Select an AppHost: - Selecione um AppHost: + + view telemetry for + view telemetry for diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf index 839264940bd..eaa0836756d 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.ru.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Запущенные хосты приложений не найдены. Сначала запустите один из них с помощью команды "aspire run". - - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. API панели мониторинга недоступен. Убедитесь, что AppHost запущен с включенной панелью мониторинга. @@ -52,29 +47,14 @@ Просмотр структурированных журналов через API телеметрии панели мониторинга. - - No AppHosts found in current directory. Showing all running AppHosts. - Хосты приложений не найдены в текущем каталоге. Отображаются все запущенные хосты приложений. - - - - The path to the Aspire AppHost project file. - Путь к файлу проекта Aspire AppHost. - - Filter by resource name. Фильтровать по имени ресурса. - - Scanning for running AppHosts... - Выполняется сканирование на наличие запущенных хостов приложений... - - - - Select an AppHost: - Выберите хост приложения: + + view telemetry for + view telemetry for diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf index 14ca0675a86..10c2d8ef457 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.tr.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - Çalışan AppHost bulunamadı. Önce birini başlatmak için 'aspire run' komutunu kullanın. - - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. Pano API'si kullanılamıyor. AppHost'un Pano özelliği etkin olarak çalıştığından emin olun. @@ -52,29 +47,14 @@ Pano telemetri API'sinden yapılandırılmış günlükleri görüntüleyin. - - No AppHosts found in current directory. Showing all running AppHosts. - Geçerli dizinde AppHost bulunamadı. Tüm çalışan AppHost'lar gösteriliyor. - - - - The path to the Aspire AppHost project file. - Aspire AppHost proje dosyasının yolu. - - Filter by resource name. Kaynak adına göre filtreleyin. - - Scanning for running AppHosts... - Çalışan AppHost'lar taranıyor... - - - - Select an AppHost: - AppHost seçin: + + view telemetry for + view telemetry for diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf index ae605f40628..52cbb89845a 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hans.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - 找不到正在运行的 AppHost。请先使用 "aspire run" 启动一个。 - - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. 仪表板 API 不可用。请确保 AppHost 在运行时启用了仪表板。 @@ -52,29 +47,14 @@ 查看仪表板遥测 API 中的结构化日志。 - - No AppHosts found in current directory. Showing all running AppHosts. - 当前目录中未找到 AppHost。显示所有正在运行的 AppHost。 - - - - The path to the Aspire AppHost project file. - Aspire AppHost 项目文件的路径。 - - Filter by resource name. 按资源名称筛选。 - - Scanning for running AppHosts... - 正在扫描处于运行状态的 AppHost... - - - - Select an AppHost: - 选择 AppHost: + + view telemetry for + view telemetry for diff --git a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf index 5bdb57d0b3a..afc0955ee60 100644 --- a/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/TelemetryCommandStrings.zh-Hant.xlf @@ -2,11 +2,6 @@ - - No running AppHost found. Use 'aspire run' to start one first. - 找不到正在執行的 AppHost。請先使用 'aspire run' 啟動一個。 - - Dashboard API is not available. Ensure the AppHost is running with Dashboard enabled. 儀表板 API 無法使用。確保 AppHost 執行中且儀表板功能已啟用。 @@ -52,29 +47,14 @@ 從儀表板遙測 API 檢視結構化記錄。 - - No AppHosts found in current directory. Showing all running AppHosts. - 在目前的目錄中找不到 AppHost。顯示所有正在執行的 AppHost。 - - - - The path to the Aspire AppHost project file. - Aspire AppHost 專案檔案的路徑。 - - Filter by resource name. 依資源名稱篩選。 - - Scanning for running AppHosts... - 正在掃描執行中的 AppHost... - - - - Select an AppHost: - 選取 AppHost: + + view telemetry for + view telemetry for diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.cs.xlf index 24dfa963182..39aefcbfa04 100644 --- a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.cs.xlf @@ -12,21 +12,6 @@ Invalid status value '{0}'. Valid values are: healthy, up, down. - - No running AppHosts found in the current directory. Showing all running AppHosts: - No running AppHosts found in the current directory. Showing all running AppHosts: - - - - No running AppHosts found. - No running AppHosts found. - - - - The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. - - The name of the resource to wait for. The name of the resource to wait for. @@ -47,14 +32,9 @@ Resource '{0}' is {1}. ({2:F1}s) - - Scanning for running AppHosts... - Scanning for running AppHosts... - - - - Select an AppHost: - Select an AppHost: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.de.xlf index fa566fd6d81..589d9dd244e 100644 --- a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.de.xlf @@ -12,21 +12,6 @@ Invalid status value '{0}'. Valid values are: healthy, up, down. - - No running AppHosts found in the current directory. Showing all running AppHosts: - No running AppHosts found in the current directory. Showing all running AppHosts: - - - - No running AppHosts found. - No running AppHosts found. - - - - The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. - - The name of the resource to wait for. The name of the resource to wait for. @@ -47,14 +32,9 @@ Resource '{0}' is {1}. ({2:F1}s) - - Scanning for running AppHosts... - Scanning for running AppHosts... - - - - Select an AppHost: - Select an AppHost: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.es.xlf index a8e64cf4f14..b4d09f677b5 100644 --- a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.es.xlf @@ -12,21 +12,6 @@ Invalid status value '{0}'. Valid values are: healthy, up, down. - - No running AppHosts found in the current directory. Showing all running AppHosts: - No running AppHosts found in the current directory. Showing all running AppHosts: - - - - No running AppHosts found. - No running AppHosts found. - - - - The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. - - The name of the resource to wait for. The name of the resource to wait for. @@ -47,14 +32,9 @@ Resource '{0}' is {1}. ({2:F1}s) - - Scanning for running AppHosts... - Scanning for running AppHosts... - - - - Select an AppHost: - Select an AppHost: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.fr.xlf index 7bd17b7aca2..a6cccb8b994 100644 --- a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.fr.xlf @@ -12,21 +12,6 @@ Invalid status value '{0}'. Valid values are: healthy, up, down. - - No running AppHosts found in the current directory. Showing all running AppHosts: - No running AppHosts found in the current directory. Showing all running AppHosts: - - - - No running AppHosts found. - No running AppHosts found. - - - - The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. - - The name of the resource to wait for. The name of the resource to wait for. @@ -47,14 +32,9 @@ Resource '{0}' is {1}. ({2:F1}s) - - Scanning for running AppHosts... - Scanning for running AppHosts... - - - - Select an AppHost: - Select an AppHost: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.it.xlf index a0e330107e2..1631266f589 100644 --- a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.it.xlf @@ -12,21 +12,6 @@ Invalid status value '{0}'. Valid values are: healthy, up, down. - - No running AppHosts found in the current directory. Showing all running AppHosts: - No running AppHosts found in the current directory. Showing all running AppHosts: - - - - No running AppHosts found. - No running AppHosts found. - - - - The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. - - The name of the resource to wait for. The name of the resource to wait for. @@ -47,14 +32,9 @@ Resource '{0}' is {1}. ({2:F1}s) - - Scanning for running AppHosts... - Scanning for running AppHosts... - - - - Select an AppHost: - Select an AppHost: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ja.xlf index 8688c957d7c..0da125fd8fb 100644 --- a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ja.xlf @@ -12,21 +12,6 @@ Invalid status value '{0}'. Valid values are: healthy, up, down. - - No running AppHosts found in the current directory. Showing all running AppHosts: - No running AppHosts found in the current directory. Showing all running AppHosts: - - - - No running AppHosts found. - No running AppHosts found. - - - - The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. - - The name of the resource to wait for. The name of the resource to wait for. @@ -47,14 +32,9 @@ Resource '{0}' is {1}. ({2:F1}s) - - Scanning for running AppHosts... - Scanning for running AppHosts... - - - - Select an AppHost: - Select an AppHost: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ko.xlf index c1297400695..c13d609f816 100644 --- a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ko.xlf @@ -12,21 +12,6 @@ Invalid status value '{0}'. Valid values are: healthy, up, down. - - No running AppHosts found in the current directory. Showing all running AppHosts: - No running AppHosts found in the current directory. Showing all running AppHosts: - - - - No running AppHosts found. - No running AppHosts found. - - - - The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. - - The name of the resource to wait for. The name of the resource to wait for. @@ -47,14 +32,9 @@ Resource '{0}' is {1}. ({2:F1}s) - - Scanning for running AppHosts... - Scanning for running AppHosts... - - - - Select an AppHost: - Select an AppHost: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pl.xlf index b2e7f34aa9d..6db72d611a0 100644 --- a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pl.xlf @@ -12,21 +12,6 @@ Invalid status value '{0}'. Valid values are: healthy, up, down. - - No running AppHosts found in the current directory. Showing all running AppHosts: - No running AppHosts found in the current directory. Showing all running AppHosts: - - - - No running AppHosts found. - No running AppHosts found. - - - - The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. - - The name of the resource to wait for. The name of the resource to wait for. @@ -47,14 +32,9 @@ Resource '{0}' is {1}. ({2:F1}s) - - Scanning for running AppHosts... - Scanning for running AppHosts... - - - - Select an AppHost: - Select an AppHost: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pt-BR.xlf index 521e49348f7..f8782dfe0af 100644 --- a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.pt-BR.xlf @@ -12,21 +12,6 @@ Invalid status value '{0}'. Valid values are: healthy, up, down. - - No running AppHosts found in the current directory. Showing all running AppHosts: - No running AppHosts found in the current directory. Showing all running AppHosts: - - - - No running AppHosts found. - No running AppHosts found. - - - - The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. - - The name of the resource to wait for. The name of the resource to wait for. @@ -47,14 +32,9 @@ Resource '{0}' is {1}. ({2:F1}s) - - Scanning for running AppHosts... - Scanning for running AppHosts... - - - - Select an AppHost: - Select an AppHost: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ru.xlf index 13c35c770fc..63998c50b33 100644 --- a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.ru.xlf @@ -12,21 +12,6 @@ Invalid status value '{0}'. Valid values are: healthy, up, down. - - No running AppHosts found in the current directory. Showing all running AppHosts: - No running AppHosts found in the current directory. Showing all running AppHosts: - - - - No running AppHosts found. - No running AppHosts found. - - - - The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. - - The name of the resource to wait for. The name of the resource to wait for. @@ -47,14 +32,9 @@ Resource '{0}' is {1}. ({2:F1}s) - - Scanning for running AppHosts... - Scanning for running AppHosts... - - - - Select an AppHost: - Select an AppHost: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.tr.xlf index 8d3bf57318d..2ba9f993810 100644 --- a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.tr.xlf @@ -12,21 +12,6 @@ Invalid status value '{0}'. Valid values are: healthy, up, down. - - No running AppHosts found in the current directory. Showing all running AppHosts: - No running AppHosts found in the current directory. Showing all running AppHosts: - - - - No running AppHosts found. - No running AppHosts found. - - - - The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. - - The name of the resource to wait for. The name of the resource to wait for. @@ -47,14 +32,9 @@ Resource '{0}' is {1}. ({2:F1}s) - - Scanning for running AppHosts... - Scanning for running AppHosts... - - - - Select an AppHost: - Select an AppHost: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hans.xlf index 4478e63df2d..6cdc75edec9 100644 --- a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hans.xlf @@ -12,21 +12,6 @@ Invalid status value '{0}'. Valid values are: healthy, up, down. - - No running AppHosts found in the current directory. Showing all running AppHosts: - No running AppHosts found in the current directory. Showing all running AppHosts: - - - - No running AppHosts found. - No running AppHosts found. - - - - The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. - - The name of the resource to wait for. The name of the resource to wait for. @@ -47,14 +32,9 @@ Resource '{0}' is {1}. ({2:F1}s) - - Scanning for running AppHosts... - Scanning for running AppHosts... - - - - Select an AppHost: - Select an AppHost: + + connect to + connect to diff --git a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hant.xlf index a3d15fa2d57..9bfe2702d91 100644 --- a/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/WaitCommandStrings.zh-Hant.xlf @@ -12,21 +12,6 @@ Invalid status value '{0}'. Valid values are: healthy, up, down. - - No running AppHosts found in the current directory. Showing all running AppHosts: - No running AppHosts found in the current directory. Showing all running AppHosts: - - - - No running AppHosts found. - No running AppHosts found. - - - - The path to the Aspire AppHost project file. - The path to the Aspire AppHost project file. - - The name of the resource to wait for. The name of the resource to wait for. @@ -47,14 +32,9 @@ Resource '{0}' is {1}. ({2:F1}s) - - Scanning for running AppHosts... - Scanning for running AppHosts... - - - - Select an AppHost: - Select an AppHost: + + connect to + connect to diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs index 78a98c7d392..cafff69de44 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs @@ -74,7 +74,7 @@ public async Task PsCommandListsRunningAppHost() // Pattern for aspire ps when no AppHosts running var waitForNoRunningAppHosts = new CellPatternSearcher() - .Find("No running AppHosts found"); + .Find("No running AppHost found"); var counter = new SequenceCounter(); var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs index 65519da5a55..e1d8092b158 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs @@ -144,7 +144,7 @@ public async Task StopWithNoRunningAppHostExitsSuccessfully() // Pattern searcher for the informational message (not an error) var waitForNoRunningAppHosts = new CellPatternSearcher() - .Find("No running AppHosts found in scope."); + .Find("No running AppHost found"); var counter = new SequenceCounter(); var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); @@ -274,7 +274,7 @@ public async Task AddPackageWhileAppHostRunningDetached() // aspire stop may return a non-zero exit code if no instances are found // (already stopped by aspire add), so wait for known output patterns. var waitForStopResult = new CellPatternSearcher() - .Find("No running AppHosts found"); + .Find("No running AppHost found"); var waitForStoppedSuccessfully = new CellPatternSearcher() .Find("AppHost stopped successfully."); @@ -404,7 +404,7 @@ public async Task AddPackageInteractiveWhileAppHostRunningDetached() // aspire stop may return a non-zero exit code if no instances are found // (already stopped by aspire add), so wait for known output patterns. var waitForStopResult2 = new CellPatternSearcher() - .Find("No running AppHosts found"); + .Find("No running AppHost found"); var waitForStoppedSuccessfully2 = new CellPatternSearcher() .Find("AppHost stopped successfully."); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs index a4e3dab3fff..2f12c568511 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs @@ -65,7 +65,7 @@ public async Task StopNonInteractiveSingleAppHost() .Find("AppHost stopped successfully."); var waitForNoRunningAppHostsFound = new CellPatternSearcher() - .Find("No running AppHosts found"); + .Find("No running AppHost found"); var counter = new SequenceCounter(); var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); @@ -187,7 +187,7 @@ public async Task StopAllAppHostsFromAppHostDirectory() .Find("AppHost stopped successfully."); var waitForNoRunningAppHostsFound = new CellPatternSearcher() - .Find("No running AppHosts found"); + .Find("No running AppHost found"); var counter = new SequenceCounter(); var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); @@ -334,7 +334,7 @@ public async Task StopAllAppHostsFromUnrelatedDirectory() .Find("AppHost stopped successfully."); var waitForNoRunningAppHostsFound = new CellPatternSearcher() - .Find("No running AppHosts found"); + .Find("No running AppHost found"); var counter = new SequenceCounter(); var sequenceBuilder = new Hex1bTerminalInputSequenceBuilder(); From 0b5acf03376263c962f91d723af9368a6955f7e2 Mon Sep 17 00:00:00 2001 From: Karol Zadora-Przylecki Date: Tue, 24 Feb 2026 17:16:58 -0800 Subject: [PATCH 172/256] Enable container tunnel by default (#14557) * Dependencies of multiple resources * DCP object creation as set of tasks ... to distinguish between "regular" and "tunnel" services * Enable tunnel by default * Clean up startup performance script * Bug fixes * Fix Kafka tests * Fix WaitFor test for failing resource * Remove unnecessary references from Hosting.Maui project * Feedback from Eric --- .../Properties/launchSettings.json | 6 +- .../KafkaBuilderExtensions.cs | 3 +- .../KafkaServerResource.cs | 4 +- .../ApplicationModel/HostUrl.cs | 5 +- .../ApplicationModel/ResourceExtensions.cs | 86 ++++- src/Aspire.Hosting/Aspire.Hosting.csproj | 1 + src/Aspire.Hosting/AspireEventSource.cs | 18 + .../Dashboard/DashboardEventHandlers.cs | 10 +- src/Aspire.Hosting/Dcp/DcpExecutor.cs | 319 ++++++++++++++---- src/Aspire.Hosting/Dcp/DcpOptions.cs | 2 +- src/Aspire.Hosting/Dcp/KubernetesService.cs | 2 +- .../Dcp/OtlpEndpointReferenceGatherer.cs | 81 +++++ .../OtlpConfigurationExtensions.cs | 21 +- src/Shared/KnownEndpointNames.cs | 10 + .../Aspire.Hosting.Tests.csproj | 1 + .../ContainerTunnelTests.cs | 49 ++- .../Dashboard/DashboardLifecycleHookTests.cs | 2 +- .../Dashboard/DashboardResourceTests.cs | 4 +- .../Dcp/DcpExecutorTests.cs | 25 +- .../DistributedApplicationTests.cs | 30 +- tests/Aspire.Hosting.Tests/Helpers/Network.cs | 65 ++++ .../ResourceDependencyTests.cs | 285 ++++++++++++++++ tests/Aspire.Hosting.Tests/WaitForTests.cs | 7 +- tools/perf/Measure-StartupPerformance.ps1 | 22 +- 24 files changed, 907 insertions(+), 151 deletions(-) create mode 100644 src/Aspire.Hosting/Dcp/OtlpEndpointReferenceGatherer.cs create mode 100644 src/Shared/KnownEndpointNames.cs create mode 100644 tests/Aspire.Hosting.Tests/Helpers/Network.cs diff --git a/playground/yarp/Yarp.AppHost/Properties/launchSettings.json b/playground/yarp/Yarp.AppHost/Properties/launchSettings.json index 7a511945b21..67f69e81dc7 100644 --- a/playground/yarp/Yarp.AppHost/Properties/launchSettings.json +++ b/playground/yarp/Yarp.AppHost/Properties/launchSettings.json @@ -12,8 +12,7 @@ "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:18100", //"ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "https://localhost:17299", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17299", - "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", - "ASPIRE_ENABLE_CONTAINER_TUNNEL": "true" + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" } }, "http": { @@ -29,8 +28,7 @@ //"ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "http://localhost:17300", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17300", "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", - "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true", - "ASPIRE_ENABLE_CONTAINER_TUNNEL": "true" + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" } }, "generate-manifest": { diff --git a/src/Aspire.Hosting.Kafka/KafkaBuilderExtensions.cs b/src/Aspire.Hosting.Kafka/KafkaBuilderExtensions.cs index cbb91e49c5e..b29d53b4924 100644 --- a/src/Aspire.Hosting.Kafka/KafkaBuilderExtensions.cs +++ b/src/Aspire.Hosting.Kafka/KafkaBuilderExtensions.cs @@ -1,7 +1,6 @@ // 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 Aspire.Hosting.ApplicationModel; using Confluent.Kafka; using HealthChecks.Kafka; @@ -215,7 +214,7 @@ private static void ConfigureKafkaContainer(EnvironmentCallbackContext context, var advertisedListeners = context.ExecutionContext.IsRunMode // In run mode, PLAINTEXT_INTERNAL assumes kafka is being accessed over a default Aspire container network and hardcodes the resource address // This will need to be refactored once updated service discovery APIs are available - ? ReferenceExpression.Create($"PLAINTEXT://localhost:29092,PLAINTEXT_HOST://localhost:{primaryEndpoint.Port.ToString(CultureInfo.InvariantCulture)},PLAINTEXT_INTERNAL://{resource.Name}:{internalEndpoint.Property(EndpointProperty.TargetPort)}") + ? ReferenceExpression.Create($"PLAINTEXT://localhost:29092,PLAINTEXT_HOST://localhost:{primaryEndpoint.Property(EndpointProperty.Port)},PLAINTEXT_INTERNAL://{resource.Name}:{internalEndpoint.Property(EndpointProperty.TargetPort)}") : ReferenceExpression.Create($"PLAINTEXT://{primaryEndpoint.Property(EndpointProperty.Host)}:29092,PLAINTEXT_HOST://{primaryEndpoint.Property(EndpointProperty.HostAndPort)},PLAINTEXT_INTERNAL://{internalEndpoint.Property(EndpointProperty.HostAndPort)}"); context.EnvironmentVariables["KAFKA_ADVERTISED_LISTENERS"] = advertisedListeners; diff --git a/src/Aspire.Hosting.Kafka/KafkaServerResource.cs b/src/Aspire.Hosting.Kafka/KafkaServerResource.cs index bfd04519b4f..8875427a8d5 100644 --- a/src/Aspire.Hosting.Kafka/KafkaServerResource.cs +++ b/src/Aspire.Hosting.Kafka/KafkaServerResource.cs @@ -23,7 +23,7 @@ public class KafkaServerResource(string name) : ContainerResource(name), IResour /// Gets the primary endpoint for the Kafka broker. This endpoint is used for host processes to Kafka broker communication. /// To connect to the Kafka broker from a container, use . /// - public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName); + public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName, KnownNetworkIdentifiers.LocalhostNetwork); /// /// Gets the host endpoint reference for the primary endpoint. @@ -39,7 +39,7 @@ public class KafkaServerResource(string name) : ContainerResource(name), IResour /// Gets the internal endpoint for the Kafka broker. This endpoint is used for container to broker communication. /// To connect to the Kafka broker from a host process, use . /// - public EndpointReference InternalEndpoint => _internalEndpoint ??= new(this, InternalEndpointName); + public EndpointReference InternalEndpoint => _internalEndpoint ??= new(this, InternalEndpointName, KnownNetworkIdentifiers.DefaultAspireContainerNetwork); /// /// Gets the connection string expression for the Kafka broker. diff --git a/src/Aspire.Hosting/ApplicationModel/HostUrl.cs b/src/Aspire.Hosting/ApplicationModel/HostUrl.cs index ef29422c12c..1a3d4a5fc49 100644 --- a/src/Aspire.Hosting/ApplicationModel/HostUrl.cs +++ b/src/Aspire.Hosting/ApplicationModel/HostUrl.cs @@ -55,12 +55,13 @@ public record HostUrl(string Url) : IValueProvider, IManifestExpressionProvider // Determine what hostname means that we want to contact the host machine from the container. If using the new tunnel feature, this needs to be the address of the tunnel instance. // Otherwise we want to try and determine the container runtime appropriate hostname (host.docker.internal or host.containers.internal). uri.Host = options.Value.EnableAspireContainerTunnel? KnownHostNames.DefaultContainerTunnelHostName : dcpInfo?.Containers?.ContainerHostName ?? KnownHostNames.DockerDesktopHostBridge; + var model = context.ExecutionContext.ServiceProvider.GetService(); - if (options.Value.EnableAspireContainerTunnel) + if (options.Value.EnableAspireContainerTunnel && model is { }) { // If we're running with the container tunnel enabled, we need to lookup the port on the tunnel that corresponds to the // target port on the host machine. - var model = context.ExecutionContext.ServiceProvider.GetRequiredService(); + var targetEndpoint = model.Resources.Where(r => !r.IsContainer()) .OfType() .Select(r => diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs index fec5e55d6c7..b5269eb3613 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs @@ -1254,39 +1254,89 @@ internal static ILogger GetLogger(this IResource resource, IServiceProvider serv /// This method invokes environment variable and command-line argument callbacks to discover all references. The context resource () is not considered a dependency (even if it is transitively referenced). /// /// - public static async Task> GetResourceDependenciesAsync( + public static Task> GetResourceDependenciesAsync( this IResource resource, DistributedApplicationExecutionContext executionContext, ResourceDependencyDiscoveryMode mode = ResourceDependencyDiscoveryMode.Recursive, CancellationToken cancellationToken = default) + { + return GetDependenciesAsync([resource], executionContext, mode, cancellationToken); + } + + /// + /// Efficiently computes the set of resources that the specified source set of resources depends on. + /// + /// The source set of resources to compute dependencies for. + /// The execution context for resolving environment variables and arguments. + /// Specifies whether to discover only direct dependencies or the full transitive closure. + /// A cancellation token to observe while computing dependencies. + /// A set of all resources that the specified resource depends on. + /// + /// + /// Dependencies are computed from multiple sources: + /// + /// Parent resources via + /// Wait dependencies via + /// Connection string redirects via + /// References to endpoints in environment variables and command-line arguments (via ) + /// + /// + /// + /// When is , only the immediate + /// dependencies are returned. When is , + /// all transitive dependencies are included. + /// + /// + /// This method invokes environment variable and command-line argument callbacks to discover all references. + /// + /// + internal static async Task> GetDependenciesAsync( + IEnumerable resources, + DistributedApplicationExecutionContext executionContext, + ResourceDependencyDiscoveryMode mode = ResourceDependencyDiscoveryMode.Recursive, + CancellationToken cancellationToken = default) { var dependencies = new HashSet(); var newDependencies = new HashSet(); - await GatherDirectDependenciesAsync(resource, dependencies, newDependencies, executionContext, cancellationToken).ConfigureAwait(false); + var toProcess = new Queue(); - if (mode == ResourceDependencyDiscoveryMode.Recursive) + foreach (var resource in resources) { - // Compute transitive closure by recursively processing dependencies - var toProcess = new Queue(dependencies); - while (toProcess.Count > 0) + newDependencies.Clear(); + await GatherDirectDependenciesAsync(resource, dependencies, newDependencies, executionContext, cancellationToken).ConfigureAwait(false); + + if (mode == ResourceDependencyDiscoveryMode.Recursive) { - var dep = toProcess.Dequeue(); - newDependencies.Clear(); + // Compute transitive closure by recursively processing dependencies - await GatherDirectDependenciesAsync(dep, dependencies, newDependencies, executionContext, cancellationToken).ConfigureAwait(false); + foreach(var nd in newDependencies) + { + toProcess.Enqueue(nd); + } - foreach (var newDep in newDependencies) + while (toProcess.Count > 0) { - if (newDep != resource) + var dep = toProcess.Dequeue(); + newDependencies.Clear(); + + await GatherDirectDependenciesAsync(dep, dependencies, newDependencies, executionContext, cancellationToken).ConfigureAwait(false); + + foreach (var newDep in newDependencies) { - toProcess.Enqueue(newDep); + if (newDep != resource) + { + toProcess.Enqueue(newDep); + } } } } } - // Ensure the input resource is not in its own dependency set, even if referenced transitively. - dependencies.Remove(resource); + // Ensure the input resources are not in its own dependency set, even if referenced transitively. + foreach (var resource in resources) + { + dependencies.Remove(resource); + } return dependencies; } @@ -1294,9 +1344,11 @@ public static async Task> GetResourceDependenciesAsync( /// /// Gathers direct dependencies of a given resource. /// - /// - /// Newly discovered dependencies (not already in ). - /// + /// The resource to gather dependencies for. + /// The set of dependencies (where dependency resources will be placed). + /// The set of newly discovered dependencies in this invocation (not present in at the moment of invocation). + /// The execution context for resolving environment variables and arguments. + /// A cancellation token to observe while gathering dependencies. private static async Task GatherDirectDependenciesAsync( IResource resource, HashSet dependencies, diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 6d65b91a9d4..f9fb038fe3d 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Aspire.Hosting/AspireEventSource.cs b/src/Aspire.Hosting/AspireEventSource.cs index cdea12bded7..0450292a4f8 100644 --- a/src/Aspire.Hosting/AspireEventSource.cs +++ b/src/Aspire.Hosting/AspireEventSource.cs @@ -370,4 +370,22 @@ public void StartResourceStop(string kind, string resourceName) WriteEvent(42, kind, resourceName); } } + + [Event(43, Level = EventLevel.Informational, Message = "DCP Service object preparation starting...")] + public void DcpServiceObjectPreparationStart() + { + if (IsEnabled()) + { + WriteEvent(43); + } + } + + [Event(44, Level = EventLevel.Informational, Message = "DCP Service object preparation completed")] + public void DcpServiceObjectPreparationStop() + { + if (IsEnabled()) + { + WriteEvent(44); + } + } } diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 6f20bb6a086..daf45ac1e86 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -44,8 +44,6 @@ IFileSystemService directoryService ) : IDistributedApplicationEventingSubscriber, IAsyncDisposable { // Internal for testing - internal const string OtlpGrpcEndpointName = "otlp-grpc"; - internal const string OtlpHttpEndpointName = "otlp-http"; internal const string McpEndpointName = "mcp"; // Fallback defaults for framework versions and TFM @@ -436,7 +434,7 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) if (otlpGrpcEndpointUrl != null) { var address = BindingAddress.Parse(otlpGrpcEndpointUrl); - dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: OtlpGrpcEndpointName, uriScheme: address.Scheme, port: address.Port, isProxied: true, transport: "http2") + dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: KnownEndpointNames.OtlpGrpcEndpointName, uriScheme: address.Scheme, port: address.Port, isProxied: true, transport: "http2") { TargetHost = address.Host }); @@ -445,7 +443,7 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) if (otlpHttpEndpointUrl != null) { var address = BindingAddress.Parse(otlpHttpEndpointUrl); - dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: OtlpHttpEndpointName, uriScheme: address.Scheme, port: address.Port, isProxied: true) + dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: KnownEndpointNames.OtlpHttpEndpointName, uriScheme: address.Scheme, port: address.Port, isProxied: true) { TargetHost = address.Host }); @@ -660,13 +658,13 @@ private static void PopulateDashboardUrls(EnvironmentCallbackContext context) static ReferenceExpression GetTargetUrlExpression(EndpointReference e) => ReferenceExpression.Create($"{e.Property(EndpointProperty.Scheme)}://{e.EndpointAnnotation.TargetHost}:{e.Property(EndpointProperty.TargetPort)}"); - var otlpGrpc = dashboardResource.GetEndpoint(OtlpGrpcEndpointName, KnownNetworkIdentifiers.LocalhostNetwork); + var otlpGrpc = dashboardResource.GetEndpoint(KnownEndpointNames.OtlpGrpcEndpointName, KnownNetworkIdentifiers.LocalhostNetwork); if (otlpGrpc.Exists) { context.EnvironmentVariables[DashboardConfigNames.DashboardOtlpGrpcUrlName.EnvVarName] = GetTargetUrlExpression(otlpGrpc); } - var otlpHttp = dashboardResource.GetEndpoint(OtlpHttpEndpointName, KnownNetworkIdentifiers.LocalhostNetwork); + var otlpHttp = dashboardResource.GetEndpoint(KnownEndpointNames.OtlpHttpEndpointName, KnownNetworkIdentifiers.LocalhostNetwork); if (otlpHttp.Exists) { context.EnvironmentVariables[DashboardConfigNames.DashboardOtlpHttpUrlName.EnvVarName] = GetTargetUrlExpression(otlpHttp); diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 4edf0ce335d..24c12e01e38 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -140,9 +140,9 @@ public DcpExecutor(ILogger logger, private string ContainerHostName => _configuration["AppHost:ContainerHostname"] ?? (_options.Value.EnableAspireContainerTunnel ? KnownHostNames.DefaultContainerTunnelHostName : _dcpInfo?.Containers?.HostName ?? KnownHostNames.DockerDesktopHostBridge); - public async Task RunApplicationAsync(CancellationToken cancellationToken = default) + public async Task RunApplicationAsync(CancellationToken ct = default) { - _dcpInfo = await _dcpDependencyCheckService.GetDcpInfoAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + _dcpInfo = await _dcpDependencyCheckService.GetDcpInfoAsync(cancellationToken: ct).ConfigureAwait(false); Debug.Assert(_dcpInfo is not null, "DCP info should not be null at this point"); @@ -159,56 +159,120 @@ public async Task RunApplicationAsync(CancellationToken cancellationToken = defa AspireEventSource.Instance.DcpModelCreationStart(); PrepareContainerNetworks(); - PrepareServices(); + + AspireEventSource.Instance.DcpServiceObjectPreparationStart(); + try + { + await PrepareServicesAsync(ct).ConfigureAwait(false); + } + finally + { + AspireEventSource.Instance.DcpServiceObjectPreparationStop(); + } + PrepareContainers(); PrepareExecutables(); - await _executorEvents.PublishAsync(new OnResourcesPreparedContext(cancellationToken)).ConfigureAwait(false); + await _executorEvents.PublishAsync(new OnResourcesPreparedContext(ct)).ConfigureAwait(false); WatchResourceChanges(); - await Task.WhenAll( - Task.Run(() => CreateAllDcpObjectsAsync(cancellationToken), cancellationToken), - Task.Run(() => CreateAllDcpObjectsAsync(cancellationToken), cancellationToken) - ).WaitAsync(cancellationToken).ConfigureAwait(false); + // Ensure we fire the event only once for each app model resource. There may be multiple physical replicas of + // the same app model resource which can result in the event being fired multiple times. + HashSet endpointsAdvertised = new(StringComparers.ResourceName); + + var createServices = Task.Run(() => CreateAllDcpObjectsAsync(ct), ct); + + var getProxyAddresses = Task.Run(async () => + { + await createServices.ConfigureAwait(false); - var proxiedWithNoAddress = _appResources.Where(r => r.DcpResource is Service { }).Select(r => (Service)r.DcpResource) + var proxiedWithNoAddress = _appResources.Where(r => r.DcpResource is Service { }).Select(r => (Service)r.DcpResource) .Where(sr => !sr.HasCompleteAddress && sr.Spec.AddressAllocationMode != AddressAllocationModes.Proxyless); - await UpdateWithEffectiveAddressInfo(proxiedWithNoAddress, cancellationToken).ConfigureAwait(false); - await CreateAllDcpObjectsAsync(cancellationToken).ConfigureAwait(false); - await EnsureContainerServiceAddressInfo(cancellationToken).ConfigureAwait(false); + await UpdateWithEffectiveAddressInfo(proxiedWithNoAddress, ct, TimeSpan.FromMinutes(1)).ConfigureAwait(false); + }, ct); + + var createContainerNetworks = Task.Run(() => CreateAllDcpObjectsAsync(ct), ct); var executables = _appResources.OfType().Where(ar => ar.DcpResource is Executable); - AddAllocatedEndpointInfo(executables, AllocatedEndpointsMode.All); - var containers = _appResources.OfType().Where(ar => ar.DcpResource is Container); - AddAllocatedEndpointInfo(containers, AllocatedEndpointsMode.Workload); - var containerExes = _appResources.OfType().Where(ar => ar.DcpResource is ContainerExec); - await _executorEvents.PublishAsync(new OnEndpointsAllocatedContext(cancellationToken)).ConfigureAwait(false); + var (regular, tunnelDependent, regularContainerExes, tunnelDependentContainerExes) = await GetContainerCreationSetsAsync(ct).ConfigureAwait(false); - // Ensure we fire the event only once for each app model resource. There may be multiple physical replicas of - // the same app model resource which can result in the event being fired multiple times. - HashSet allocatedEndpointsAdvertised = new(StringComparers.ResourceName); + var createExecutableEndpoints = Task.Run(async () => + { + await getProxyAddresses.ConfigureAwait(false); + + AddAllocatedEndpointInfo(executables, AllocatedEndpointsMode.Workload); + await PublishEndpointAllocatedEventAsync(endpointsAdvertised, executables, ct).ConfigureAwait(false); + }, ct); + + var createExecutables = Task.Run(async () => + { + await createExecutableEndpoints.ConfigureAwait(false); + + await CreateExecutablesAsync(executables, ct).ConfigureAwait(false); + }, ct); + + var createRegularContainers = Task.Run(async () => + { + await Task.WhenAll([getProxyAddresses, createContainerNetworks]).ConfigureAwait(false); + + AddAllocatedEndpointInfo(regular, AllocatedEndpointsMode.Workload); + await PublishEndpointAllocatedEventAsync(endpointsAdvertised, regular, ct).ConfigureAwait(false); + + await Task.WhenAll( + CreateContainersAsync(regular, ct), + CreateContainerExecutablesAsync(regularContainerExes, ct) + ).WaitAsync(ct).ConfigureAwait(false); + }, ct); - foreach (var resource in executables.Concat(containers)) + var createTunnel = Task.Run(async () => { - if (allocatedEndpointsAdvertised.Add(resource.ModelResource.Name)) + if (!tunnelDependent.Any()) { - await _distributedApplicationEventing.PublishAsync( - new ResourceEndpointsAllocatedEvent(resource.ModelResource, _executionContext.ServiceProvider), - EventDispatchBehavior.NonBlockingConcurrent, - cancellationToken - ).ConfigureAwait(false); + return; // No tunnel-dependent containers, nothing to do. } - } + await Task.WhenAll([getProxyAddresses, createContainerNetworks]).ConfigureAwait(false); + + await CreateAllDcpObjectsAsync(ct).ConfigureAwait(false); + await EnsureContainerServiceAddressInfo(ct).ConfigureAwait(false); + + AddAllocatedEndpointInfo(executables, AllocatedEndpointsMode.ContainerTunnel); + }, ct); + + var createTunnelDependentContainers = Task.Run(async () => + { + if (!tunnelDependent.Any()) + { + return; // No tunnel-dependent containers, nothing to do. + } + + await Task.WhenAll([getProxyAddresses, createContainerNetworks, createExecutableEndpoints]).ConfigureAwait(false); + + // There is no need to wait with creating tunnel-dependent containers till container tunnel is created. + // The containers will not be started until the tunnel endpoints they use are ready, but this is handled internally by DCP. + + AddAllocatedEndpointInfo(tunnelDependent, AllocatedEndpointsMode.Workload); + await PublishEndpointAllocatedEventAsync(endpointsAdvertised, tunnelDependent, ct).ConfigureAwait(false); + + await Task.WhenAll( + CreateContainersAsync(tunnelDependent, ct), + CreateContainerExecutablesAsync(tunnelDependentContainerExes, ct) + ).WaitAsync(ct).ConfigureAwait(false); + }, ct); + + // Now wait for all creations to complete. await Task.WhenAll( - CreateExecutablesAsync(executables, cancellationToken), - CreateContainersAsync(containers, cancellationToken), - CreateContainerExecutablesAsync(containerExes, cancellationToken) - ).WaitAsync(cancellationToken).ConfigureAwait(false); + createTunnel, + createExecutables, + createRegularContainers, + createTunnelDependentContainers + ).WaitAsync(ct).ConfigureAwait(false); + + await _executorEvents.PublishAsync(new OnEndpointsAllocatedContext(ct)).ConfigureAwait(false); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (ct.IsCancellationRequested) { // This is here so hosting does not throw an exception when CTRL+C during startup. _logger.LogDebug("Cancellation received during application startup."); @@ -997,10 +1061,26 @@ private void AddAllocatedEndpointInfo(IEnumerable resourc sp.EndpointAnnotation.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(allocatedEndpoint.NetworkID, allocatedEndpoint); } } + + // If we are not using the tunnel, we can project Executable endpoints into container networks via ContainerHostName. + // This really only works for Docker Desktop, but it is useful for testing too. + if (appResource.DcpResource is Executable && !_options.Value.EnableAspireContainerTunnel) + { + var port = sp.EndpointAnnotation.TargetPort!; + var allocatedEndpoint = new AllocatedEndpoint( + sp.EndpointAnnotation, + ContainerHostName, + (int)svc.AllocatedPort!, + EndpointBindingMode.SingleAddress, + targetPortExpression: $$$"""{{- portForServing "{{{svc.Metadata.Name}}}" -}}""", + KnownNetworkIdentifiers.DefaultAspireContainerNetwork + ); + sp.EndpointAnnotation.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint(KnownNetworkIdentifiers.DefaultAspireContainerNetwork, allocatedEndpoint); + } } } - if ((mode & AllocatedEndpointsMode.ContainerTunnel) != 0) + if ((mode & AllocatedEndpointsMode.ContainerTunnel) != 0 && _options.Value.EnableAspireContainerTunnel) { // If there are any additional services that are not directly produced by this resource, // but leverage its endpoints via container tunnel, we want to add allocated endpoint info for them as well. @@ -1089,7 +1169,10 @@ private void PrepareContainerNetworks() _appResources.Add(new AppResource(network)); } - private void PrepareServices() + /// + /// Creates DCP Service objects that represent services exposed by resources in the model via endpoints (EndpointAnnotations). + /// + private async Task PrepareServicesAsync(CancellationToken cancellationToken) { _logger.LogDebug("Preparing services. Ports randomized: {RandomizePorts}", _options.Value.RandomizePorts); @@ -1146,7 +1229,8 @@ private void PrepareServices() svc.Annotate(CustomResource.ResourceNameAnnotation, sp.ModelResource.Name); svc.Annotate(CustomResource.EndpointNameAnnotation, endpoint.Name); - _appResources.Add(new ServiceWithModelResource(sp.ModelResource, svc, endpoint)); + var smr = new ServiceWithModelResource(sp.ModelResource, svc, endpoint); + _appResources.Add(smr); } } @@ -1158,16 +1242,29 @@ private void PrepareServices() return; // No container resources--no need to set up container-to-host tunnels. } - var hostResourcesWithEndpoints = _model.Resources.Where(r => r is IResourceWithEndpoints && !r.IsContainer()) - .Select(r => ( - Resource: r, - Endpoints: r.Annotations.OfType() - )) - .Where(re => re.Endpoints.Any()).ToImmutableArray(); + var containerDependencies = await ResourceExtensions.GetDependenciesAsync(containers, _executionContext, ResourceDependencyDiscoveryMode.DirectOnly, cancellationToken).ConfigureAwait(false); + + // Host dependencies are host network resources with endpoints that containers depend on. + List hostDependencies = containerDependencies.Select(AsHostResourceWithEndpoints).OfType().ToList(); - if (!hostResourcesWithEndpoints.Any()) + // Aspire dashboard is special in the context of Open Telemetry ingestion. + // OTLP exporters do not refer to the OTLP ingestion endpoint via EndpointReference when the model is constructed + // by the Aspire app host; the endpoint URL is just read from configuration. + // If there are containers that are OTLP exporters in the model, we need to project dashboard endpoints into container space. + if (containers.Any(c => c.TryGetAnnotationsOfType(out _))) { - return; // No host resources referenced by container resources--nothing more to do. + var maybeDashboard = _model.Resources.Where(r => StringComparers.ResourceName.Equals(r.Name, KnownResourceNames.AspireDashboard)) + .Select(AsHostResourceWithEndpoints).FirstOrDefault(); + if (maybeDashboard is HostResourceWithEndpoints dashboardResource) + { + hostDependencies.Add(dashboardResource); + } + } + + if (!hostDependencies.Any()) + { + // There are no containers that reference host resource endpoints, so no need for container tunnel. + return; } // Eventually we might want to support multiple container networks, including user-defined ones, @@ -1185,10 +1282,10 @@ private void PrepareServices() _appResources.Add(tunnelAppResource); } - // If multiple Containers take a reference to the same host endpoint, we should only create one Service for it. + // If multiple Containers take a reference to the same host resource, we should only create one Service per each endpoint. HashSet<(string HostResourceName, string OriginalEndpointName)> processedEndpoints = new(); - foreach (var re in hostResourcesWithEndpoints) + foreach (var re in hostDependencies) { var resourceLogger = _loggerService.GetLogger(re.Resource); @@ -1209,12 +1306,6 @@ private void PrepareServices() endpoint.Protocol); continue; } - if (!endpoint.IsProxied) - { - resourceLogger.LogWarning("Host endpoint '{EndpointName}' on resource '{HostResource}' is referenced by a container resource, but the endpoint is not configured to use a proxy. This may cause application startup failure due to circular dependencies.", - endpoint.Name, - re.Resource.Name); - } } var hasManyEndpoints = re.Resource.Annotations.OfType().Count() > 1; @@ -1326,7 +1417,7 @@ private void PreparePlainExecutables() if (executable.SupportsDebugging(_configuration, out var supportsDebuggingAnnotation)) { exe.Spec.ExecutionType = ExecutionType.IDE; - exe.Spec.FallbackExecutionTypes = [ ExecutionType.Process ]; + exe.Spec.FallbackExecutionTypes = [ExecutionType.Process]; supportsDebuggingAnnotation.LaunchConfigurationAnnotator(exe, _configuration[KnownConfigNames.DebugSessionRunMode] ?? ExecutableLaunchMode.NoDebug); } else @@ -1379,7 +1470,7 @@ private void PrepareProjectExecutables() if (project.SupportsDebugging(_configuration, out _)) { exe.Spec.ExecutionType = ExecutionType.IDE; - exe.Spec.FallbackExecutionTypes = [ ExecutionType.Process ]; + exe.Spec.FallbackExecutionTypes = [ExecutionType.Process]; projectLaunchConfiguration.DisableLaunchProfile = project.TryGetLastAnnotation(out _); // Use the effective launch profile which has fallback logic @@ -1791,7 +1882,7 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou var argSeparator = appHostArgs.Select((a, i) => (index: i, value: a.Value)) .FirstOrDefault(x => x.value == DotnetToolResourceExtensions.ArgumentSeparator); - var args = appHostArgs.Select((a, i) => (arg : a, display : i > argSeparator.index)); + var args = appHostArgs.Select((a, i) => (arg: a, display: i > argSeparator.index)); launchArgs.AddRange(args.Select(x => (x.arg.Value, x.arg.IsSensitive, true, x.display))); return launchArgs; @@ -2037,6 +2128,7 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour KeyPath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{cert.Thumbprint}.key"), PfxPath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{cert.Thumbprint}.pfx"), }) + .AddExecutionConfigurationGatherer(new OtlpEndpointReferenceGatherer()) .BuildAsync(_executionContext, resourceLogger, cancellationToken) .ConfigureAwait(false); @@ -2101,14 +2193,14 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour var publicCertificatePem = tlsCertificateConfiguration.Certificate.ExportCertificatePem(); (var keyPem, var pfxBytes) = await GetCertificateKeyMaterialAsync(tlsCertificateConfiguration, cancellationToken).ConfigureAwait(false); var certificateFiles = new List() - { - new ContainerFileSystemEntry { - Name = thumbprint + ".crt", - Type = ContainerFileSystemEntryType.File, - Contents = new string(publicCertificatePem), - } - }; + new ContainerFileSystemEntry + { + Name = thumbprint + ".crt", + Type = ContainerFileSystemEntryType.File, + Contents = new string(publicCertificatePem), + } + }; if (keyPem is not null) { @@ -2858,4 +2950,107 @@ private static bool TryGetEndpoint(IResource resource, string? endpointName, [No } return endpoint is not null; } + + private record struct HostResourceWithEndpoints + ( + IResourceWithEndpoints Resource, + IEnumerable Endpoints + ); + + private static HostResourceWithEndpoints? AsHostResourceWithEndpoints(IResource resource) + { + if (resource is IResourceWithEndpoints rwe && !resource.IsContainer()) + { + var endpoints = resource.Annotations.OfType(); + if (endpoints.Any()) + { + return new HostResourceWithEndpoints(rwe, endpoints); + } + } + + return null; + } + + private record struct ContainerCreationSets + ( + IEnumerable RegularContainers, + IEnumerable TunnelDependentContainers, + IEnumerable RegularContainerExecutables, + IEnumerable TunnelDependentContainerExecutables + ); + /// + /// Determines which containers, and container executables, can be created immediately, + /// and which ones depend on a tunnel to the host network. + /// + /// Cancellation token that can be used to cancel the whole operation. + /// + /// A record grouping container-related resources into sets, dependent on whether they + /// require network tunnels to host resources. + /// + private async Task GetContainerCreationSetsAsync(CancellationToken cancellationToken) + { + List regular = new(); + List tunnelDependent = new(); + + var containers = _appResources.OfType().Where(ar => ar.DcpResource is Container); + + if (!_options.Value.EnableAspireContainerTunnel) + { + regular.AddRange(containers); + } + else + { + foreach (var cr in containers) + { + cancellationToken.ThrowIfCancellationRequested(); + + var dependencies = await cr.ModelResource.GetResourceDependenciesAsync(_executionContext, ResourceDependencyDiscoveryMode.DirectOnly, cancellationToken).ConfigureAwait(false); + + if (dependencies.Any(dep => AsHostResourceWithEndpoints(dep) is { })) + { + tunnelDependent.Add(cr); + } + else + { + regular.Add(cr); + } + } + } + + var persistentTunnelDependent = tunnelDependent.Where(td => td.DcpResource is Container c && c.Spec.Persistent is true); + if (persistentTunnelDependent.Any()) + { + var containerNames = persistentTunnelDependent.Select(td => td.ModelResource.Name).Aggregate(string.Empty, (acc, next) => acc + " '" + next + "'"); + throw new InvalidOperationException($"The follwing containers are marked as persistent and rely on resources on the host network:{containerNames}. This is not supported."); + } + + return new ContainerCreationSets( + RegularContainers: regular, + TunnelDependentContainers: tunnelDependent, + RegularContainerExecutables: _appResources.OfType() + .Where(ar => ar.DcpResource is ContainerExec ce && regular.Any(td => td.DcpResource is Container c && c.Metadata.Name == ce.Spec.ContainerName)), + TunnelDependentContainerExecutables: _appResources.OfType() + .Where(ar => ar.DcpResource is ContainerExec ce && tunnelDependent.Any(td => td.DcpResource is Container c && c.Metadata.Name == ce.Spec.ContainerName)) + ); + } + + private async Task PublishEndpointAllocatedEventAsync( + HashSet endpointsAdvertisedFor, + IEnumerable resource, + CancellationToken ct) + { + foreach (var r in resource) + { + lock (endpointsAdvertisedFor) + { + if (!endpointsAdvertisedFor.Add(r.ModelResource.Name)) + { + continue; // Already published for this resource + } + } + + var ev = new ResourceEndpointsAllocatedEvent(r.ModelResource, _executionContext.ServiceProvider); + await _distributedApplicationEventing.PublishAsync(ev, EventDispatchBehavior.NonBlockingConcurrent, ct).ConfigureAwait(false); + } + } } diff --git a/src/Aspire.Hosting/Dcp/DcpOptions.cs b/src/Aspire.Hosting/Dcp/DcpOptions.cs index fd2191c96ca..ed9d18df59f 100644 --- a/src/Aspire.Hosting/Dcp/DcpOptions.cs +++ b/src/Aspire.Hosting/Dcp/DcpOptions.cs @@ -109,7 +109,7 @@ internal sealed class DcpOptions /// /// Enables Aspire container tunnel for container-to-host connectivity across all container orchestrators. /// - public bool EnableAspireContainerTunnel { get; set; } + public bool EnableAspireContainerTunnel { get; set; } = true; } internal class ValidateDcpOptions : IValidateOptions diff --git a/src/Aspire.Hosting/Dcp/KubernetesService.cs b/src/Aspire.Hosting/Dcp/KubernetesService.cs index 8d808bcc3f3..d5472421291 100644 --- a/src/Aspire.Hosting/Dcp/KubernetesService.cs +++ b/src/Aspire.Hosting/Dcp/KubernetesService.cs @@ -535,7 +535,7 @@ private ResiliencePipeline CreateReadKubeconfigResiliencePipeline() { // Handle exceptions caused by races between writing and reading the configuration file. // If the file is loaded while it is still being written, this can result in a YamlException being thrown. - ShouldHandle = new PredicateBuilder().Handle().Handle(), + ShouldHandle = new PredicateBuilder().Handle().Handle().Handle(), BackoffType = DelayBackoffType.Constant, MaxRetryAttempts = dcpOptions.Value.KubernetesConfigReadRetryCount, MaxDelay = TimeSpan.FromMilliseconds(dcpOptions.Value.KubernetesConfigReadRetryIntervalMilliseconds), diff --git a/src/Aspire.Hosting/Dcp/OtlpEndpointReferenceGatherer.cs b/src/Aspire.Hosting/Dcp/OtlpEndpointReferenceGatherer.cs new file mode 100644 index 00000000000..95d21c760ac --- /dev/null +++ b/src/Aspire.Hosting/Dcp/OtlpEndpointReferenceGatherer.cs @@ -0,0 +1,81 @@ +// 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 Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.Dcp; + +/// +/// For containers, it replaces OTLP endpoint environemnt variable value with a reference to dashboard OTLP ingestion endpoint. +/// +/// +/// In run mode, the dashboard plays the role of an OTLP collector, but the dashboard resouce is added dynamically, +/// just before the application started. That is why the OTLP configuration extension methods use configuration only. +/// OTOH, DCP has full model to work with, and can replace the OTLP endpoint environment variables with references +/// to the dashboard OTLP ingestion endpoint. For containers this allows DCP to tunnel these properly into container networks. +/// +internal class OtlpEndpointReferenceGatherer : IExecutionConfigurationGatherer +{ + public async ValueTask GatherAsync(IExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default) + { + if (!resource.IsContainer() || !resource.TryGetLastAnnotation(out var oea)) + { + // This gatherer is only relevant for container resources that emit OTEL telemetry. + return; + } + + if (!context.EnvironmentVariables.TryGetValue(OtlpConfigurationExtensions.OtlpEndpointEnvironmentVariableName, out _)) + { + // If the OTLP endpoint is not set, do not try to set it. + return; + } + + var model = executionContext.ServiceProvider.GetService(); + if (model is null) + { + // Tests may not have a full model + return; + } + + var dashboardResource = model.Resources.SingleOrDefault(r => StringComparers.ResourceName.Equals(r.Name, KnownResourceNames.AspireDashboard)) as IResourceWithEndpoints; + if (dashboardResource == null) + { + // Most test runs do not include the dashboard, and that's ok. If the dashboard is not present, do not try to set the OTLP endpoint. + return; + } + + if (!dashboardResource.TryGetEndpoints(out var dashboardEndpoints)) + { + Debug.Fail("Dashboard does not have any endpoints??"); + return; + } + + var grpcEndpoint = dashboardEndpoints.FirstOrDefault(e => e.Name == KnownEndpointNames.OtlpGrpcEndpointName); + var httpEndpoint = dashboardEndpoints.FirstOrDefault(e => e.Name == KnownEndpointNames.OtlpHttpEndpointName); + var resourceNetwork = resource.GetDefaultResourceNetwork(); + + var endpointReference = (oea.RequiredProtocol, grpcEndpoint, httpEndpoint) switch + { + (OtlpProtocol.Grpc, not null, _) => new EndpointReference(dashboardResource, grpcEndpoint, resourceNetwork), + (OtlpProtocol.HttpProtobuf or OtlpProtocol.HttpJson, _, not null) => new EndpointReference(dashboardResource, httpEndpoint, resourceNetwork), + (_, not null, _) => new EndpointReference(dashboardResource, grpcEndpoint, resourceNetwork), + (_, _, not null) => new EndpointReference(dashboardResource, httpEndpoint, resourceNetwork), + _ => null + }; + Debug.Assert(endpointReference != null, "Dashboard should have at least one matching OTLP endpoint"); + + if (endpointReference is not null) + { + ValueProviderContext vpc = new() { ExecutionContext = executionContext, Caller = resource, Network = resourceNetwork }; + var url = await endpointReference.GetValueAsync(vpc, cancellationToken).ConfigureAwait(false); + Debug.Assert(url is not null, $"We should be able to get a URL value from the reference dashboard endpoint '{endpointReference.EndpointName}'"); + if (url is not null) + { + context.EnvironmentVariables[OtlpConfigurationExtensions.OtlpEndpointEnvironmentVariableName] = url; + } + } + } +} diff --git a/src/Aspire.Hosting/OtlpConfigurationExtensions.cs b/src/Aspire.Hosting/OtlpConfigurationExtensions.cs index 481ce5f8b2e..2aa1c869035 100644 --- a/src/Aspire.Hosting/OtlpConfigurationExtensions.cs +++ b/src/Aspire.Hosting/OtlpConfigurationExtensions.cs @@ -13,6 +13,11 @@ namespace Aspire.Hosting; /// public static class OtlpConfigurationExtensions { + /// + /// The name of the environment variable for configuring the OTLP exporter ingestion URL. This is used by OpenTelemetry SDKs to determine where to send telemetry data. + /// + public static readonly string OtlpEndpointEnvironmentVariableName = "OTEL_EXPORTER_OTLP_ENDPOINT"; + /// /// Configures OpenTelemetry in projects using environment variables. /// @@ -68,7 +73,9 @@ private static void RegisterOtlpEnvironment(IResource resource, IConfiguration c return; } - SetOtel(context, configuration, otlpExporterAnnotation.RequiredProtocol); + var (url, protocol) = OtlpEndpointResolver.ResolveOtlpEndpoint(configuration, otlpExporterAnnotation.RequiredProtocol); + context.EnvironmentVariables[OtlpEndpointEnvironmentVariableName] = new HostUrl(url); + context.EnvironmentVariables["OTEL_EXPORTER_OTLP_PROTOCOL"] = protocol; // Set the service name and instance id to the resource name and UID. Values are injected by DCP. context.EnvironmentVariables["OTEL_RESOURCE_ATTRIBUTES"] = "service.instance.id={{- index .Annotations \"" + CustomResource.OtelServiceInstanceIdAnnotation + "\" -}}"; @@ -104,18 +111,6 @@ private static void RegisterOtlpEnvironment(IResource resource, IConfiguration c })); } - private static void SetOtel(EnvironmentCallbackContext context, IConfiguration configuration, OtlpProtocol? requiredProtocol) - { - var (url, protocol) = OtlpEndpointResolver.ResolveOtlpEndpoint(configuration, requiredProtocol); - SetOtelEndpointAndProtocol(context.EnvironmentVariables, url, protocol); - } - - private static void SetOtelEndpointAndProtocol(Dictionary environmentVariables, string url, string protocol) - { - environmentVariables["OTEL_EXPORTER_OTLP_ENDPOINT"] = new HostUrl(url); - environmentVariables["OTEL_EXPORTER_OTLP_PROTOCOL"] = protocol; - } - /// /// Injects the appropriate environment variables to allow the resource to enable sending telemetry to the dashboard. /// diff --git a/src/Shared/KnownEndpointNames.cs b/src/Shared/KnownEndpointNames.cs new file mode 100644 index 00000000000..930d3b7f839 --- /dev/null +++ b/src/Shared/KnownEndpointNames.cs @@ -0,0 +1,10 @@ +// 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; + +internal static class KnownEndpointNames +{ + public static string OtlpGrpcEndpointName = "otlp-grpc"; + public static string OtlpHttpEndpointName = "otlp-http"; +} diff --git a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj index 7d0d8aa8df5..43a94e0757e 100644 --- a/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj +++ b/tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj @@ -30,6 +30,7 @@ + diff --git a/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs b/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs index b35b2459fb0..448677b4b26 100644 --- a/tests/Aspire.Hosting.Tests/ContainerTunnelTests.cs +++ b/tests/Aspire.Hosting.Tests/ContainerTunnelTests.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 Aspire.Hosting.Testing; using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; using Aspire.Hosting.Yarp.Transforms; using Aspire.TestUtilities; -using Aspire.Hosting.Testing; using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Hosting.Tests; @@ -21,13 +21,55 @@ public async Task ContainerTunnelWorksWithYarp() var servicea = builder.AddProject($"{testName}-servicea"); - var yarp = builder.AddYarp(testName).WithConfiguration(conf => + var yarp = builder.AddYarp($"{testName}-yarp").WithConfiguration(conf => { conf.AddRoute("/servicea/{**catch-all}", servicea).WithTransformPathRemovePrefix("/servicea"); }); using var app = builder.Build(); - await app.StartAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); + + // Use extra long timeout because if this is first time the tunnel is being used, + // getting the base images and building the tunnel (client) proxy image may take a while. + await app.StartAsync().DefaultTimeout(TestConstants.ExtraLongTimeoutDuration); + await app.WaitForTextAsync("Application started.").DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); + + using var clientA = app.CreateHttpClient(yarp.Resource.Name, "http"); + var response = await clientA.GetAsync("/servicea/").DefaultTimeout(TestConstants.DefaultOrchestratorTestTimeout); + Assert.True(response.IsSuccessStatusCode); + var body = await response.Content.ReadAsStringAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestTimeout); + Assert.Equal("Hello World!", body); + + await app.StopAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task ProxylessEndpointWorksWithContainerTunnel() + { + var port = await Helpers.Network.GetAvailablePortAsync(); + + const string testName = "proxyless-endpoint-works-with-container-tunnel"; + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + builder.Configuration[KnownConfigNames.EnableContainerTunnel] = "true"; + + var servicea = builder.AddProject($"{testName}-servicea") + .WithEndpoint("http", e => + { + e.Port = port; + e.TargetPort = port; + e.IsProxied = false; + }); + + var yarp = builder.AddYarp($"{testName}-yarp").WithConfiguration(conf => + { + conf.AddRoute("/servicea/{**catch-all}", servicea).WithTransformPathRemovePrefix("/servicea"); + }); + + await using var app = builder.Build(); + + // Use extra long timeout because if this is first time the tunnel is being used, + // getting the base images and building the tunnel (client) proxy image may take a while. + await app.StartAsync().DefaultTimeout(TestConstants.ExtraLongTimeoutDuration); await app.WaitForTextAsync("Application started.").DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); using var clientA = app.CreateHttpClient(yarp.Resource.Name, "http"); @@ -38,4 +80,5 @@ public async Task ContainerTunnelWorksWithYarp() await app.StopAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); } + } diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs index 9f28ed4d74d..2c8a0142664 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs @@ -139,7 +139,7 @@ public async Task BeforeStartAsync_DashboardContainsDebugSessionInfo(string? deb var httpEndpoint = new EndpointReference(dashboardResource, "http"); httpEndpoint.EndpointAnnotation.AllocatedEndpoint = new(httpEndpoint.EndpointAnnotation, "localhost", 8080); - var otlpGrpcEndpoint = new EndpointReference(dashboardResource, DashboardEventHandlers.OtlpGrpcEndpointName); + var otlpGrpcEndpoint = new EndpointReference(dashboardResource, KnownEndpointNames.OtlpGrpcEndpointName); otlpGrpcEndpoint.EndpointAnnotation.AllocatedEndpoint = new(otlpGrpcEndpoint.EndpointAnnotation, "localhost", 4317); var context = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { ServiceProvider = TestServiceProvider.Instance }); diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs index f04e285721a..35ba3c48da5 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs @@ -693,11 +693,11 @@ static void SetDashboardAllocatedEndpoints(IResource dashboard, int otlpGrpcPort { foreach (var endpoint in dashboard.Annotations.OfType()) { - if (endpoint.Name == DashboardEventHandlers.OtlpGrpcEndpointName) + if (endpoint.Name == KnownEndpointNames.OtlpGrpcEndpointName) { endpoint.AllocatedEndpoint = new(endpoint, "localhost", otlpGrpcPort, targetPortExpression: otlpGrpcPort.ToString()); } - else if (endpoint.Name == DashboardEventHandlers.OtlpHttpEndpointName) + else if (endpoint.Name == KnownEndpointNames.OtlpHttpEndpointName) { endpoint.AllocatedEndpoint = new(endpoint, "localhost", otlpHttpPort, targetPortExpression: otlpHttpPort.ToString()); } diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 8ccc1004794..b6229f583d9 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -2189,12 +2189,16 @@ public async Task EndpointsAllocatedCorrectly(bool useTunnel, string? containerH var container = builder.AddContainer("aContainer", "image") .WithEndpoint(name: "proxied", port: 15678, targetPort: 11234, isProxied: true) - .WithEndpoint(name: "notProxied", port: 18765, isProxied: false); + .WithEndpoint(name: "notProxied", port: 18765, isProxied: false) + .WithEnvironment("EXE_PROXIED_PORT", executable.GetEndpoint("proxied").Property(EndpointProperty.Port)) + .WithEnvironment("EXE_NOTPROXIED_PORT", executable.GetEndpoint("notProxied").Property(EndpointProperty.Port)); var containerWithAlias = builder.AddContainer("containerWithAlias", "image") .WithEndpoint(name: "proxied", port: 25678, targetPort: 21234, isProxied: true) .WithEndpoint(name: "notProxied", port: 28765, isProxied: false) - .WithContainerNetworkAlias("custom.alias"); + .WithContainerNetworkAlias("custom.alias") + .WithEnvironment("EXE_PROXIED_PORT", executable.GetEndpoint("proxied").Property(EndpointProperty.Port)) + .WithEnvironment("EXE_NOTPROXIED_PORT", executable.GetEndpoint("notProxied").Property(EndpointProperty.Port)); var kubernetesService = new TestKubernetesService(); using var app = builder.Build(); @@ -2220,10 +2224,10 @@ public async Task EndpointsAllocatedCorrectly(bool useTunnel, string? containerH if (useTunnel) { - await AssertTunneledPort(executable.Resource, "proxied"); - await AssertTunneledPort(executable.Resource, "notProxied"); + await AssertTunneledPort(executable.Resource, "proxied", 5678); + await AssertTunneledPort(executable.Resource, "notProxied", 8765); - async ValueTask AssertTunneledPort(IResourceWithEndpoints resource, string endpointName) + async ValueTask AssertTunneledPort(IResourceWithEndpoints resource, string endpointName, int hostPort) { var svcs = kubernetesService.CreatedResources .OfType() @@ -2236,10 +2240,21 @@ async ValueTask AssertTunneledPort(IResourceWithEndpoints resource, string endpo int port = svc.AllocatedPort!.Value; await AssertEndpoint(executable.Resource, endpointName, KnownNetworkIdentifiers.DefaultAspireContainerNetwork, expectedContainerHost, port); + + await AssertEndpoint(executable.Resource, endpointName, KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, hostPort); + + var dcpContainer = kubernetesService.CreatedResources + .OfType() + .Where(c => c.AppModelResourceName == container.Resource.Name) + .Single(); + var exePortEnvVal = dcpContainer.Spec?.Env?.Where(e => e.Name == $"EXE_{endpointName.ToUpper()}_PORT").Single().Value; + Assert.Equal(port.ToString(), exePortEnvVal); } } else { + await AssertEndpoint(executable.Resource, "proxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 5678); + await AssertEndpoint(executable.Resource, "notProxied", KnownNetworkIdentifiers.LocalhostNetwork, KnownHostNames.Localhost, 8765); await AssertEndpoint(executable.Resource, "proxied", KnownNetworkIdentifiers.DefaultAspireContainerNetwork, expectedContainerHost, 5678); await AssertEndpoint(executable.Resource, "notProxied", KnownNetworkIdentifiers.DefaultAspireContainerNetwork, expectedContainerHost, 8765); } diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs index 7f0d216128b..3fca5f3ff05 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.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. #pragma warning disable ASPIRECERTIFICATES001 @@ -1501,11 +1501,12 @@ public async Task ProxylessEndpointWorks() const string testName = "proxyless-endpoint-works"; using var testProgram = CreateTestProgram(testName); + var port = await Network.GetAvailablePortAsync(); testProgram.ServiceABuilder .WithEndpoint("http", e => { - e.Port = 1234; - e.TargetPort = 1234; + e.Port = port; + e.TargetPort = port; e.IsProxied = false; }); @@ -1537,11 +1538,12 @@ public async Task ProxylessAndProxiedEndpointBothWorkOnSameResource() const string testName = "proxyless-and-proxied-endpoints"; using var testProgram = CreateTestProgram(testName); + var port = await Network.GetAvailablePortAsync(); testProgram.ServiceABuilder .WithEndpoint("http", e => { - e.Port = 1234; - e.TargetPort = 1234; + e.Port = port; + e.TargetPort = port; e.IsProxied = false; }, createIfNotExists: false) .WithEndpoint("https", e => @@ -1607,7 +1609,8 @@ public async Task ProxylessContainerCanBeReferenced() const string testName = "proxyless-container"; using var builder = TestDistributedApplicationBuilder.Create(_testOutputHelper); - var redis = builder.AddRedis($"{testName}-redis", 1234).WithEndpoint("tcp", endpoint => + var port = await Network.GetAvailablePortAsync(); + var redis = builder.AddRedis($"{testName}-redis", port).WithEndpoint("tcp", endpoint => { endpoint.IsProxied = false; }); @@ -1638,7 +1641,7 @@ public async Task ProxylessContainerCanBeReferenced() var env = Assert.Single(service.Spec.Env!, e => e.Name == $"ConnectionStrings__{testName}-redis"); var sslVal = redis.Resource.TlsEnabled ? ",ssl=true" : string.Empty; #pragma warning disable CS0618 // Type or member is obsolete - Assert.Equal($"localhost:1234,password={redis.Resource.PasswordParameter?.Value}{sslVal}", env.Value); + Assert.Equal($"localhost:{port},password={redis.Resource.PasswordParameter?.Value}{sslVal}", env.Value); #pragma warning restore CS0618 // Type or member is obsolete var list = await s.ListAsync().DefaultTimeout(); @@ -1646,11 +1649,11 @@ public async Task ProxylessContainerCanBeReferenced() if (redis.Resource.TlsEnabled) { Assert.Equal(2, redisContainer.Spec.Ports!.Count); - Assert.Contains(redisContainer.Spec.Ports!, p => p.HostPort == 1234); + Assert.Contains(redisContainer.Spec.Ports!, p => p.HostPort == port); } else { - Assert.Equal(1234, Assert.Single(redisContainer.Spec.Ports!).HostPort); + Assert.Equal(port, Assert.Single(redisContainer.Spec.Ports!).HostPort); } var otherRedisEnv = Assert.Single(service.Spec.Env!, e => e.Name == $"ConnectionStrings__{testName}-redisNoPort"); @@ -1680,7 +1683,8 @@ public async Task WithEndpointProxySupportDisablesProxies() const string testName = "endpoint-proxy-support"; using var builder = TestDistributedApplicationBuilder.Create(_testOutputHelper); - var redis = builder.AddRedis($"{testName}-redis", 1234).WithEndpointProxySupport(false); + var port = await Network.GetAvailablePortAsync(); + var redis = builder.AddRedis($"{testName}-redis", port).WithEndpointProxySupport(false); // Since port is not specified, this instance will use the container target port (6379) as the host port. var redisNoPort = builder.AddRedis($"{testName}-redisNoPort").WithEndpointProxySupport(false); @@ -1710,7 +1714,7 @@ public async Task WithEndpointProxySupportDisablesProxies() var env = Assert.Single(service.Spec.Env!, e => e.Name == $"ConnectionStrings__{testName}-redis"); var sslVal = redis.Resource.TlsEnabled ? ",ssl=true" : string.Empty; #pragma warning disable CS0618 // Type or member is obsolete - Assert.Equal($"localhost:1234,password={redis.Resource.PasswordParameter!.Value}{sslVal}", env.Value); + Assert.Equal($"localhost:{port},password={redis.Resource.PasswordParameter!.Value}{sslVal}", env.Value); #pragma warning restore CS0618 // Type or member is obsolete var list = await s.ListAsync().DefaultTimeout(); @@ -1718,11 +1722,11 @@ public async Task WithEndpointProxySupportDisablesProxies() if (redis.Resource.TlsEnabled) { Assert.Equal(2, redisContainer.Spec.Ports!.Count); - Assert.Contains(redisContainer.Spec.Ports!, p => p.HostPort == 1234); + Assert.Contains(redisContainer.Spec.Ports!, p => p.HostPort == port); } else { - Assert.Equal(1234, Assert.Single(redisContainer.Spec.Ports!).HostPort); + Assert.Equal(port, Assert.Single(redisContainer.Spec.Ports!).HostPort); } var otherRedisEnv = Assert.Single(service.Spec.Env!, e => e.Name == $"ConnectionStrings__{testName}-redisNoPort"); diff --git a/tests/Aspire.Hosting.Tests/Helpers/Network.cs b/tests/Aspire.Hosting.Tests/Helpers/Network.cs new file mode 100644 index 00000000000..060ceb2bd4f --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Helpers/Network.cs @@ -0,0 +1,65 @@ +// 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 Polly; +using Polly.Retry; +using Polly.Timeout; + +namespace Aspire.Hosting.Tests.Helpers; + +internal static class Network +{ + /// + /// Finds an available (unoccupied) TCP port on the specified network address by attempting + /// to bind a socket to randomly chosen ports within the given range. + /// + /// The inclusive lower bound of the random port range. + /// The inclusive upper bound of the random port range. + /// The network address to bind to (e.g. "127.0.0.1"). + /// An available port number. + /// Thrown when no available port is found within the timeout period. + public static async Task GetAvailablePortAsync( + int minPort = 10000, + int maxPort = 20000, + string address = "127.0.0.1") + { + var triedPorts = new List(); + var random = new Random(); + var ipAddress = IPAddress.Parse(address); + + var pipeline = new ResiliencePipelineBuilder() + .AddTimeout(new TimeoutStrategyOptions { Timeout = TimeSpan.FromSeconds(10) }) + .AddRetry(new RetryStrategyOptions + { + ShouldHandle = new PredicateBuilder().Handle(), + BackoffType = DelayBackoffType.Exponential, + Delay = TimeSpan.FromMilliseconds(50), + MaxDelay = TimeSpan.FromSeconds(2), + MaxRetryAttempts = int.MaxValue, + }) + .Build(); + + try + { + return await pipeline.ExecuteAsync(async (ct) => + { + var port = random.Next(minPort, maxPort + 1); + triedPorts.Add(port); + + using var socket = new Socket(ipAddress.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + socket.Bind(new IPEndPoint(ipAddress, port)); + socket.Close(); + + return port; + }); + } + catch (TimeoutRejectedException) + { + throw new InvalidOperationException( + $"Could not find an available port on address '{address}' after 10 seconds. " + + $"Tried ports: {string.Join(", ", triedPorts)}"); + } + } +} diff --git a/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs b/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs index 0688a07e352..bca940514d8 100644 --- a/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceDependencyTests.cs @@ -591,4 +591,289 @@ public async Task DefaultOverloadUsesTransitiveClosure() Assert.Contains(b.Resource, dependencies); Assert.Contains(c.Resource, dependencies); } + + #region Multi-Resource GetDependenciesAsync Tests + + [Fact] + public async Task MultiResourceIndependentResourcesDependenciesAreMerged() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // A -> X, B -> Y (independent dependencies) + var x = builder.AddContainer("x", "alpine") + .WithHttpEndpoint(8001, 8001, "http"); + var y = builder.AddContainer("y", "alpine") + .WithHttpEndpoint(8002, 8002, "http"); + var a = builder.AddContainer("a", "alpine").WithEnvironment("X_URL", x.GetEndpoint("http")); + var b = builder.AddContainer("b", "alpine").WithEnvironment("Y_URL", y.GetEndpoint("http")); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await ResourceExtensions.GetDependenciesAsync( + [a.Resource, b.Resource], executionContext); + + Assert.Contains(x.Resource, dependencies); + Assert.Contains(y.Resource, dependencies); + Assert.Equal(2, dependencies.Count); + } + + [Fact] + public async Task MultiResourceOverlappingDependenciesAreDeduplicatedAndCombined() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // A -> X, B -> X (shared dependency) + var x = builder.AddContainer("x", "alpine") + .WithHttpEndpoint(8001, 8001, "http"); + var a = builder.AddContainer("a", "alpine").WithEnvironment("X_URL", x.GetEndpoint("http")); + var b = builder.AddContainer("b", "alpine").WithEnvironment("X_URL", x.GetEndpoint("http")); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await ResourceExtensions.GetDependenciesAsync( + [a.Resource, b.Resource], executionContext); + + Assert.Contains(x.Resource, dependencies); + Assert.Single(dependencies); // X should only appear once + } + + [Fact] + public async Task MultiResourceInputResourceExcludedEvenIfOtherInputDependsOnIt() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // A -> B, but both A and B are inputs + var b = builder.AddContainer("b", "alpine") + .WithHttpEndpoint(8080, 8080, "http"); + var a = builder.AddContainer("a", "alpine") + .WithReference(b.GetEndpoint("http")); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await ResourceExtensions.GetDependenciesAsync( + [a.Resource, b.Resource], executionContext); + + // B should be excluded because it's an input resource + Assert.DoesNotContain(a.Resource, dependencies); + Assert.DoesNotContain(b.Resource, dependencies); + Assert.Empty(dependencies); + } + + [Fact] + public async Task MultiResourceTransitiveDependencyThroughInputIsStillIncluded() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // A -> B -> C, inputs are [A, B] + var c = builder.AddRedis("c"); + var b = builder.AddContainer("b", "alpine") + .WithHttpEndpoint(8080, 8080, "http") + .WithReference(c); + var a = builder.AddContainer("a", "alpine") + .WithReference(b.GetEndpoint("http")); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await ResourceExtensions.GetDependenciesAsync( + [a.Resource, b.Resource], executionContext); + + // C should be included (as dependency of B) + Assert.Contains(c.Resource, dependencies); + // A and B are inputs, so excluded + Assert.DoesNotContain(a.Resource, dependencies); + Assert.DoesNotContain(b.Resource, dependencies); + } + + [Fact] + public async Task MultiResourceEmptyInputReturnsEmptySet() + { + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await ResourceExtensions.GetDependenciesAsync( + Array.Empty(), executionContext); + + Assert.Empty(dependencies); + } + + [Fact] + public async Task MultiResourceAllInputsHaveNoDependenciesReturnsEmptySet() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var a = builder.AddContainer("a", "alpine"); + var b = builder.AddContainer("b", "alpine"); + var c = builder.AddContainer("c", "alpine"); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await ResourceExtensions.GetDependenciesAsync( + [a.Resource, b.Resource, c.Resource], executionContext); + + Assert.Empty(dependencies); + } + + [Fact] + public async Task MultiResourceDiamondWithMultipleInputsHandledCorrectly() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // Diamond: A -> B -> D, A -> C -> D + // Input: [A, C] - should get B, D (C is excluded as input) + var d = builder.AddContainer("d", "alpine") + .WithHttpEndpoint(8080, 8080, "http"); + var b = builder.AddContainer("b", "alpine") + .WithHttpEndpoint(8081, 8081, "http") + .WaitFor(d); + var c = builder.AddContainer("c", "alpine") + .WithHttpEndpoint(8082, 8082, "http") + .WaitFor(d); + var a = builder.AddContainer("a", "alpine") + .WaitFor(b) + .WaitFor(c); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await ResourceExtensions.GetDependenciesAsync( + [a.Resource, c.Resource], executionContext); + + Assert.Contains(b.Resource, dependencies); + Assert.Contains(d.Resource, dependencies); + Assert.DoesNotContain(a.Resource, dependencies); + Assert.DoesNotContain(c.Resource, dependencies); + Assert.Equal(2, dependencies.Count); + } + + [Fact] + public async Task MultiResourceDirectOnlyModeWithMultipleResources() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // A -> B -> C, D -> E -> F + var c = builder.AddRedis("c"); + var b = builder.AddContainer("b", "alpine") + .WithHttpEndpoint(8080, 8080, "http") + .WithReference(c); + var a = builder.AddContainer("a", "alpine") + .WithReference(b.GetEndpoint("http")); + + var f = builder.AddRedis("f"); + var e = builder.AddContainer("e", "alpine") + .WithHttpEndpoint(8081, 8081, "http") + .WithReference(f); + var d = builder.AddContainer("d", "alpine") + .WithReference(e.GetEndpoint("http")); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await ResourceExtensions.GetDependenciesAsync( + [a.Resource, d.Resource], executionContext, ResourceDependencyDiscoveryMode.DirectOnly); + + // DirectOnly should only include B and E (direct deps), not C and F + Assert.Contains(b.Resource, dependencies); + Assert.Contains(e.Resource, dependencies); + Assert.DoesNotContain(c.Resource, dependencies); + Assert.DoesNotContain(f.Resource, dependencies); + Assert.Equal(2, dependencies.Count); + } + + [Fact] + public async Task MultiResourceCircularReferenceAmongInputsHandledCorrectly() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // A -> B -> C -> A (circular), plus D as external dependency + var d = builder.AddContainer("d", "alpine") + .WithHttpEndpoint(8083, 8083, "http"); ; + var a = builder.AddContainer("a", "alpine") + .WithHttpEndpoint(8080, 8080, "http"); + var b = builder.AddContainer("b", "alpine") + .WithHttpEndpoint(8081, 8081, "http") + .WithEnvironment("A_URL", a.GetEndpoint("http")); + var c = builder.AddContainer("c", "alpine") + .WithHttpEndpoint(8082, 8082, "http") + .WithEnvironment("B_URL", b.GetEndpoint("http")) + .WithEnvironment("D_URL", d.GetEndpoint("http")); + a.WithEnvironment("C_URL", c.GetEndpoint("http")); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + // All three circular resources as input + var dependencies = await ResourceExtensions.GetDependenciesAsync( + [a.Resource, b.Resource, c.Resource], executionContext); + + // Only D should remain as a dependency (A, B, C are all inputs) + Assert.Contains(d.Resource, dependencies); + Assert.DoesNotContain(a.Resource, dependencies); + Assert.DoesNotContain(b.Resource, dependencies); + Assert.DoesNotContain(c.Resource, dependencies); + Assert.Single(dependencies); + } + + [Fact] + public async Task MultiResourceParentChildBothAsInputsExcludesBoth() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // Simulate parent-child-like relationship using WaitFor + var parent = builder.AddContainer("parent", "alpine"); + var child = builder.AddContainer("child", "alpine") + .WaitFor(parent); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + // Both parent and child as inputs + var dependencies = await ResourceExtensions.GetDependenciesAsync( + [parent.Resource, child.Resource], executionContext); + + // Both are inputs, so neither should appear + Assert.DoesNotContain(parent.Resource, dependencies); + Assert.DoesNotContain(child.Resource, dependencies); + Assert.Empty(dependencies); + } + + [Fact] + public async Task MultiResourceCombinesDependenciesFromDifferentSourceTypes() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // A uses WaitFor(X), B uses WithReference(Y), C uses WithEnvironment(Z endpoint) + var x = builder.AddContainer("x", "alpine"); + var y = builder.AddContainer("y", "alpine") + .WithHttpEndpoint(8001, 8001, "http"); + var z = builder.AddContainer("z", "alpine") + .WithHttpEndpoint(8080, 8080, "http"); + + var a = builder.AddContainer("a", "alpine").WaitFor(x); + var b = builder.AddContainer("b", "alpine") + .WithEnvironment("Y_URL", y.GetEndpoint("http")); + var c = builder.AddContainer("c", "alpine") + .WithEnvironment("Z_URL", z.GetEndpoint("http")); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var dependencies = await ResourceExtensions.GetDependenciesAsync( + [a.Resource, b.Resource, c.Resource], executionContext); + + Assert.Contains(x.Resource, dependencies); + Assert.Contains(y.Resource, dependencies); + Assert.Contains(z.Resource, dependencies); + Assert.Equal(3, dependencies.Count); + } + + [Fact] + public async Task MultiResourceSingleResourceBehavesLikeSingleResourceMethod() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + // Chain: A -> B -> C + var c = builder.AddRedis("c"); + var b = builder.AddContainer("b", "alpine") + .WithHttpEndpoint(5000, 5000, "http") + .WithReference(c); + var a = builder.AddContainer("a", "alpine") + .WithReference(b.GetEndpoint("http")); + + var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + + // Compare single-resource method with multi-resource method using single input + var singleDeps = await a.Resource.GetResourceDependenciesAsync(executionContext); + var multiDeps = await ResourceExtensions.GetDependenciesAsync([a.Resource], executionContext); + + Assert.Equal(singleDeps.Count, multiDeps.Count); + foreach (var dep in singleDeps) + { + Assert.Contains(dep, multiDeps); + } + } + + #endregion } diff --git a/tests/Aspire.Hosting.Tests/WaitForTests.cs b/tests/Aspire.Hosting.Tests/WaitForTests.cs index cd4212e5d36..f6566f11d9a 100644 --- a/tests/Aspire.Hosting.Tests/WaitForTests.cs +++ b/tests/Aspire.Hosting.Tests/WaitForTests.cs @@ -19,11 +19,10 @@ public class WaitForTests(ITestOutputHelper testOutputHelper) public async Task ResourceThatFailsToStartDueToExceptionDoesNotCauseStartAsyncToThrow() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper); - var throwingResource = builder.AddContainer("throwingresource", "doesnotmatter") - .WithEnvironment(ctx => throw new InvalidOperationException("BOOM!")); - var dependingContainerResource = builder.AddContainer("dependingcontainerresource", "doesnotmatter") + var throwingResource = builder.AddContainer("throwingresource", "nonexistent"); + var dependingContainerResource = builder.AddContainer("dependingcontainerresource", "nonexistent2") .WaitFor(throwingResource); - var dependingExecutableResource = builder.AddExecutable("dependingexecutableresource", "doesnotmatter", "alsodoesntmatter") + var dependingExecutableResource = builder.AddExecutable("dependingexecutableresource", "nonexistent", "nonexistentdir") .WaitFor(throwingResource); var abortCts = AsyncTestHelpers.CreateDefaultTimeoutTokenSource(TestConstants.LongTimeoutDuration); diff --git a/tools/perf/Measure-StartupPerformance.ps1 b/tools/perf/Measure-StartupPerformance.ps1 index 626adff10ed..9e051e4ad6b 100644 --- a/tools/perf/Measure-StartupPerformance.ps1 +++ b/tools/perf/Measure-StartupPerformance.ps1 @@ -93,8 +93,6 @@ Set-StrictMode -Version Latest # Constants $EventSourceName = 'Microsoft-Aspire-Hosting' -$DcpModelCreationStartEventId = 17 -$DcpModelCreationStopEventId = 18 # Get repository root (script is in tools/perf) $ScriptDir = $PSScriptRoot @@ -244,37 +242,37 @@ function Invoke-PerformanceIteration { $launchSettings = $jsonContent | ConvertFrom-Json # Try to find a suitable profile (prefer 'http' for simplicity, then first available) - $profile = $null + $launchProfile = $null if ($launchSettings.profiles.http) { - $profile = $launchSettings.profiles.http + $launchProfile = $launchSettings.profiles.http Write-Verbose "Using 'http' launch profile" } elseif ($launchSettings.profiles.https) { - $profile = $launchSettings.profiles.https + $launchProfile = $launchSettings.profiles.https Write-Verbose "Using 'https' launch profile" } else { # Use first profile that has environmentVariables foreach ($prop in $launchSettings.profiles.PSObject.Properties) { if ($prop.Value.environmentVariables) { - $profile = $prop.Value + $launchProfile = $prop.Value Write-Verbose "Using '$($prop.Name)' launch profile" break } } } - if ($profile -and $profile.environmentVariables) { - foreach ($prop in $profile.environmentVariables.PSObject.Properties) { + if ($launchProfile -and $launchProfile.environmentVariables) { + foreach ($prop in $launchProfile.environmentVariables.PSObject.Properties) { $envVars[$prop.Name] = $prop.Value Write-Verbose " Environment: $($prop.Name)=$($prop.Value)" } } # Use applicationUrl to set ASPNETCORE_URLS if not already set - if ($profile -and $profile.applicationUrl -and -not $envVars.ContainsKey('ASPNETCORE_URLS')) { - $envVars['ASPNETCORE_URLS'] = $profile.applicationUrl - Write-Verbose " Environment: ASPNETCORE_URLS=$($profile.applicationUrl) (from applicationUrl)" + if ($launchProfile -and $launchProfile.applicationUrl -and -not $envVars.ContainsKey('ASPNETCORE_URLS')) { + $envVars['ASPNETCORE_URLS'] = $launchProfile.applicationUrl + Write-Verbose " Environment: ASPNETCORE_URLS=$($launchProfile.applicationUrl) (from applicationUrl)" } } catch { @@ -670,8 +668,6 @@ function Main { else { Write-Warning "No traces were collected." } - - return $results } # Run the script From 05d2f7a1d5ac26c6c11f42bd0e24ca87948051eb Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 25 Feb 2026 09:20:28 +0800 Subject: [PATCH 173/256] Write empty JSON from aspire ps to stdout (#14662) --- src/Aspire.Cli/Commands/PsCommand.cs | 2 +- .../Commands/PsCommandTests.cs | 79 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Commands/PsCommand.cs b/src/Aspire.Cli/Commands/PsCommand.cs index 930bae9fc30..bdab4cd8f32 100644 --- a/src/Aspire.Cli/Commands/PsCommand.cs +++ b/src/Aspire.Cli/Commands/PsCommand.cs @@ -100,7 +100,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { if (format == OutputFormat.Json) { - _interactionService.DisplayPlainText("[]"); + _interactionService.DisplayRawText("[]", ConsoleOutput.Standard); } else { diff --git a/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs index 1fab6c70d01..492d78de954 100644 --- a/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs @@ -1,7 +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.Json; +using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; +using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.InternalTesting; @@ -94,6 +97,77 @@ public async Task PsCommand_FormatOption_RejectsInvalidValue() [Fact] public async Task PsCommand_JsonFormat_ReturnsValidJson() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var textWriter = new TestOutputTextWriter(outputHelper); + + var monitor = new TestAuxiliaryBackchannelMonitor(); + var connection1 = 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" + } + }; + var connection2 = new TestAppHostAuxiliaryBackchannel + { + Hash = "test-hash-2", + SocketPath = "/tmp/test2.sock", + IsInScope = true, + AppHostInfo = new AppHostInformation + { + AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App2", "App2.AppHost.csproj"), + ProcessId = 9012 + } + }; + monitor.AddConnection("hash1", "socket.hash1", connection1); + monitor.AddConnection("hash2", "socket.hash2", connection2); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = textWriter; + options.AuxiliaryBackchannelMonitorFactory = _ => monitor; + }); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("ps --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + + var jsonOutput = string.Join(string.Empty, textWriter.Logs); + + var appHosts = JsonSerializer.Deserialize(jsonOutput, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo); + Assert.NotNull(appHosts); + + Assert.Collection(appHosts.OrderBy(a => a.AppHostPid), + first => + { + Assert.EndsWith("App1.AppHost.csproj", first.AppHostPath); + Assert.Equal(1234, first.AppHostPid); + Assert.Equal(5678, first.CliPid); + Assert.Equal("http://localhost:18888/login?t=abc123", first.DashboardUrl); + }, + second => + { + Assert.EndsWith("App2.AppHost.csproj", second.AppHostPath); + Assert.Equal(9012, second.AppHostPid); + Assert.Null(second.CliPid); + Assert.Null(second.DashboardUrl); + }); + } + + [Fact] + public async Task PsCommand_JsonFormat_NoResults_WritesEmptyArrayToStdout() { using var workspace = TemporaryWorkspace.Create(outputHelper); var textWriter = new TestOutputTextWriter(outputHelper); @@ -109,5 +183,10 @@ public async Task PsCommand_JsonFormat_ReturnsValidJson() var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); + + var json = string.Join(string.Empty, textWriter.Logs); + var document = JsonDocument.Parse(json); + Assert.Equal(JsonValueKind.Array, document.RootElement.ValueKind); + Assert.Equal(0, document.RootElement.GetArrayLength()); } } From 03bd2029580c9177bdc0bb641447f097c961607a Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 24 Feb 2026 20:15:04 -0800 Subject: [PATCH 174/256] Add shortcut for aspire run --detached via aspire start (#14644) * Add aspire start shortcut for detached AppHost launch - Add detached AppHost launch to 'aspire start' when no resource specified - Share detached launch behavior between start and run via AppHostLauncher - Register --no-build option on StartCommand and forward to child process - Restore JSON-safe detached output (human-readable to stderr for --format json) - Restore detached child diagnostics (--log-file generation and error surfacing) - Forward --no-build from 'aspire run --detach' to the detached child process Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback: refactor AppHostLauncher, fix string references - Make TimeProvider required (registered in DI as TimeProvider.System) - Add period to log message (nit) - Use option Name properties instead of hardcoded strings for --project/--isolated - Extract BuildChildProcessArgs, StopExistingInstancesAsync, HandleLaunchFailure, DisplayLaunchResultAsync, and LaunchAndWaitForBackchannelAsync as separate methods - Add s_projectOption to AppHostLauncher for shared use - Display error when project not found (item #4) - Fix string references: use SharedCommandStrings for relocated strings - Fix async void to async Task on DisplayLaunchResultAsync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/create-pr/SKILL.md | 55 +++ src/Aspire.Cli/Commands/AppHostLauncher.cs | 395 ++++++++++++++++++ src/Aspire.Cli/Commands/RunCommand.cs | 350 +--------------- src/Aspire.Cli/Commands/StartCommand.cs | 114 ++++- src/Aspire.Cli/Program.cs | 1 + .../ResourceCommandStrings.Designer.cs | 6 + .../Resources/ResourceCommandStrings.resx | 7 +- .../Resources/RunCommandStrings.resx | 4 +- .../SharedCommandStrings.Designer.cs | 12 + .../Resources/SharedCommandStrings.resx | 6 + .../xlf/ResourceCommandStrings.cs.xlf | 11 +- .../xlf/ResourceCommandStrings.de.xlf | 11 +- .../xlf/ResourceCommandStrings.es.xlf | 11 +- .../xlf/ResourceCommandStrings.fr.xlf | 11 +- .../xlf/ResourceCommandStrings.it.xlf | 11 +- .../xlf/ResourceCommandStrings.ja.xlf | 11 +- .../xlf/ResourceCommandStrings.ko.xlf | 11 +- .../xlf/ResourceCommandStrings.pl.xlf | 11 +- .../xlf/ResourceCommandStrings.pt-BR.xlf | 11 +- .../xlf/ResourceCommandStrings.ru.xlf | 11 +- .../xlf/ResourceCommandStrings.tr.xlf | 11 +- .../xlf/ResourceCommandStrings.zh-Hans.xlf | 11 +- .../xlf/ResourceCommandStrings.zh-Hant.xlf | 11 +- .../Resources/xlf/RunCommandStrings.cs.xlf | 4 +- .../Resources/xlf/RunCommandStrings.de.xlf | 4 +- .../Resources/xlf/RunCommandStrings.es.xlf | 4 +- .../Resources/xlf/RunCommandStrings.fr.xlf | 4 +- .../Resources/xlf/RunCommandStrings.it.xlf | 4 +- .../Resources/xlf/RunCommandStrings.ja.xlf | 4 +- .../Resources/xlf/RunCommandStrings.ko.xlf | 4 +- .../Resources/xlf/RunCommandStrings.pl.xlf | 4 +- .../Resources/xlf/RunCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/RunCommandStrings.ru.xlf | 4 +- .../Resources/xlf/RunCommandStrings.tr.xlf | 4 +- .../xlf/RunCommandStrings.zh-Hans.xlf | 4 +- .../xlf/RunCommandStrings.zh-Hant.xlf | 4 +- .../Resources/xlf/SharedCommandStrings.cs.xlf | 12 +- .../Resources/xlf/SharedCommandStrings.de.xlf | 12 +- .../Resources/xlf/SharedCommandStrings.es.xlf | 12 +- .../Resources/xlf/SharedCommandStrings.fr.xlf | 12 +- .../Resources/xlf/SharedCommandStrings.it.xlf | 12 +- .../Resources/xlf/SharedCommandStrings.ja.xlf | 12 +- .../Resources/xlf/SharedCommandStrings.ko.xlf | 12 +- .../Resources/xlf/SharedCommandStrings.pl.xlf | 12 +- .../xlf/SharedCommandStrings.pt-BR.xlf | 12 +- .../Resources/xlf/SharedCommandStrings.ru.xlf | 12 +- .../Resources/xlf/SharedCommandStrings.tr.xlf | 12 +- .../xlf/SharedCommandStrings.zh-Hans.xlf | 12 +- .../xlf/SharedCommandStrings.zh-Hant.xlf | 12 +- .../Commands/RunCommandTests.cs | 6 +- .../Commands/StartCommandTests.cs | 70 ++++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 1 + 52 files changed, 956 insertions(+), 422 deletions(-) create mode 100644 .github/skills/create-pr/SKILL.md create mode 100644 src/Aspire.Cli/Commands/AppHostLauncher.cs diff --git a/.github/skills/create-pr/SKILL.md b/.github/skills/create-pr/SKILL.md new file mode 100644 index 00000000000..eef641ce72b --- /dev/null +++ b/.github/skills/create-pr/SKILL.md @@ -0,0 +1,55 @@ +--- +name: create-pr +description: Create a PR using the repository PR template. Use this when asked to push and open a pull request. +--- + +You are a specialized pull request creation agent for this repository. + +Your goal is to **create a PR** and always **use the repository PR template** at `.github/pull_request_template.md`. + +## Required behavior + +1. Ensure the current branch is ready to open a PR: + - Confirm branch name. + - Confirm changes are committed. + - Push with upstream if needed. + +2. Determine PR metadata: + - Head branch: current branch unless user specifies otherwise. + - Base branch: user-specified base when provided; otherwise infer from context (or repository default branch). + - Title: concise summary of the change. + +3. Build PR body from template: + - Read `.github/pull_request_template.md`. + - Use the template structure as the PR body. + - Fill known details in `## Description` (summary, motivation/context, dependencies, validation). + - Fill checklist choices by selecting known answers and leaving only unknown choices unchecked. + - Keep `Fixes # (issue)` unless a concrete issue number is provided. + +4. Create the PR with `gh` using the template-derived body: + +```bash +GH_PAGER=cat gh pr create \ + --base \ + --head \ + --title "" \ + --body-file +``` + +5. If a PR already exists for the branch: + - Do not create another. + - If requested (or if the body is still mostly unfilled template text), update it with: + +```bash +GH_PAGER=cat gh pr edit \ + --body-file +``` + + - Return the existing PR URL. + +## Notes + +- Do not bypass the template with ad-hoc bodies. +- Keep the body aligned with `.github/pull_request_template.md`. +- If the user asks to preview before creating, show the prepared PR body first, then create after confirmation. +- For checklist sections with Yes/No alternatives, prefer selecting exactly one option per question when information is known. diff --git a/src/Aspire.Cli/Commands/AppHostLauncher.cs b/src/Aspire.Cli/Commands/AppHostLauncher.cs new file mode 100644 index 00000000000..4374d4ecf90 --- /dev/null +++ b/src/Aspire.Cli/Commands/AppHostLauncher.cs @@ -0,0 +1,395 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; +using Aspire.Cli.Backchannel; +using Aspire.Cli.Configuration; +using Aspire.Cli.Interaction; +using Aspire.Cli.Processes; +using Aspire.Cli.Projects; +using Aspire.Cli.Resources; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace Aspire.Cli.Commands; + +/// +/// Encapsulates the logic for launching an AppHost in detached (background) mode. +/// Used by both RunCommand (--detach) and StartCommand (no resource). +/// When adding new launch options, add them here and wire them in both commands. +/// +internal sealed class AppHostLauncher( + IProjectLocator projectLocator, + CliExecutionContext executionContext, + IFeatures features, + IInteractionService interactionService, + IAnsiConsole ansiConsole, + IAuxiliaryBackchannelMonitor backchannelMonitor, + ILogger logger, + TimeProvider timeProvider) +{ + + /// + /// Shared option for the AppHost project file path. + /// + internal static readonly Option s_projectOption = new("--project") + { + Description = SharedCommandStrings.ProjectOptionDescription + }; + + /// + /// Shared option for output format (JSON or table) in detached AppHost mode. + /// + internal static readonly Option s_formatOption = new("--format") + { + Description = SharedCommandStrings.FormatOptionDescription + }; + + /// + /// Shared option for isolated AppHost mode. + /// + internal static readonly Option s_isolatedOption = new("--isolated") + { + Description = SharedCommandStrings.IsolatedOptionDescription + }; + + /// + /// Adds the detached launch options to a command so they appear in --help. + /// Called by both RunCommand and StartCommand to keep options in sync. + /// + internal static void AddLaunchOptions(Command command) + { + command.Options.Add(s_projectOption); + command.Options.Add(s_formatOption); + command.Options.Add(s_isolatedOption); + } + + /// + /// Launches an AppHost in detached mode, waits for the backchannel, and displays the result. + /// + /// The project file passed via --project, or null to auto-discover. + /// The output format (JSON or table). + /// Whether to run in isolated mode. + /// Whether running inside VS Code extension. + /// Global CLI args to forward to child process. + /// Additional unmatched args to forward. + /// Cancellation token. + /// Exit code indicating success or failure. + public async Task LaunchDetachedAsync( + FileInfo? passedAppHostProjectFile, + OutputFormat? format, + bool isolated, + bool isExtensionHost, + IEnumerable globalArgs, + IEnumerable additionalArgs, + CancellationToken cancellationToken) + { + // Route human-readable output to stderr when JSON is requested. + if (format == OutputFormat.Json) + { + interactionService.Console = ConsoleOutput.Error; + } + + // In JSON mode, avoid interactive prompts to keep stdout parseable. + var multipleAppHostBehavior = format == OutputFormat.Json + ? MultipleAppHostProjectsFoundBehavior.Throw + : MultipleAppHostProjectsFoundBehavior.Prompt; + + // Failure mode 1: Project not found + var searchResult = await projectLocator.UseOrFindAppHostProjectFileAsync( + passedAppHostProjectFile, + multipleAppHostBehavior, + createSettingsFile: false, + cancellationToken); + + var effectiveAppHostFile = searchResult.SelectedProjectFile; + + if (effectiveAppHostFile is null) + { + return ExitCodeConstants.FailedToFindProject; + } + + logger.LogDebug("Starting AppHost in background: {AppHostPath}", effectiveAppHostFile.FullName); + + // Check for running instance and stop it if found (same behavior as regular run) + await StopExistingInstancesAsync(effectiveAppHostFile, cancellationToken); + + // Build child process arguments + var childLogFile = GenerateChildLogFilePath(executionContext.LogsDirectory.FullName, timeProvider); + var (executablePath, childArgs) = BuildChildProcessArgs(effectiveAppHostFile, childLogFile, isolated, globalArgs, additionalArgs); + + // Compute the expected socket prefix for backchannel detection + var expectedSocketPrefix = AppHostHelper.ComputeAuxiliarySocketPrefix( + effectiveAppHostFile.FullName, + executionContext.HomeDirectory.FullName); + var expectedHash = AppHostHelper.ExtractHashFromSocketPath(expectedSocketPrefix)!; + + logger.LogDebug("Waiting for socket with prefix: {SocketPrefix}, Hash: {Hash}", expectedSocketPrefix, expectedHash); + + // Start the child process and wait for the backchannel + var launchResult = await LaunchAndWaitForBackchannelAsync(executablePath, childArgs, expectedHash, format, cancellationToken); + + // Handle failure cases + if (launchResult.Backchannel is null || launchResult.ChildProcess is null) + { + return HandleLaunchFailure(launchResult, childLogFile); + } + + // Display results + await DisplayLaunchResultAsync(launchResult, effectiveAppHostFile, childLogFile, format, isExtensionHost, cancellationToken); + + return ExitCodeConstants.Success; + } + + private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, CancellationToken cancellationToken) + { + var runningInstanceDetectionEnabled = features.IsFeatureEnabled(KnownFeatures.RunningInstanceDetectionEnabled, defaultValue: true); + var existingSockets = AppHostHelper.FindMatchingSockets( + effectiveAppHostFile.FullName, + executionContext.HomeDirectory.FullName); + + if (runningInstanceDetectionEnabled && existingSockets.Length > 0) + { + logger.LogDebug("Found {Count} running instance(s) for this AppHost, stopping them first.", existingSockets.Length); + var manager = new RunningInstanceManager(logger, interactionService, timeProvider); + var stopTasks = existingSockets.Select(socket => + manager.StopRunningInstanceAsync(socket, cancellationToken)); + await Task.WhenAll(stopTasks).ConfigureAwait(false); + } + } + + private (string ExecutablePath, List ChildArgs) BuildChildProcessArgs( + FileInfo effectiveAppHostFile, + string childLogFile, + bool isolated, + IEnumerable globalArgs, + IEnumerable additionalArgs) + { + var args = new List + { + "run", + "--non-interactive", + s_projectOption.Name, + effectiveAppHostFile.FullName, + "--log-file", + childLogFile + }; + + args.AddRange(globalArgs); + + if (isolated) + { + args.Add(s_isolatedOption.Name); + } + + foreach (var token in additionalArgs) + { + args.Add(token); + } + + var dotnetPath = Environment.ProcessPath ?? "dotnet"; + var isDotnetHost = dotnetPath.EndsWith("dotnet", StringComparison.OrdinalIgnoreCase) || + dotnetPath.EndsWith("dotnet.exe", StringComparison.OrdinalIgnoreCase); + + var entryAssemblyPath = Environment.GetCommandLineArgs().FirstOrDefault(); + + var childArgs = new List(); + if (isDotnetHost && !string.IsNullOrEmpty(entryAssemblyPath) && entryAssemblyPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + childArgs.Add(entryAssemblyPath); + } + + childArgs.AddRange(args); + + logger.LogDebug("Spawning child CLI: {Executable} (isDotnetHost={IsDotnetHost}) with args: {Args}", + dotnetPath, isDotnetHost, string.Join(" ", childArgs)); + logger.LogDebug("Working directory: {WorkingDirectory}", executionContext.WorkingDirectory.FullName); + + return (dotnetPath, childArgs); + } + + private record LaunchResult(Process? ChildProcess, IAppHostAuxiliaryBackchannel? Backchannel, bool ChildExitedEarly, int ChildExitCode); + + private async Task LaunchAndWaitForBackchannelAsync( + string executablePath, + List childArgs, + string expectedHash, + OutputFormat? format, + CancellationToken cancellationToken) + { + Process? childProcess = null; + var childExitedEarly = false; + var childExitCode = 0; + + async Task WaitForBackchannelAsync() + { + try + { + childProcess = DetachedProcessLauncher.Start( + executablePath, + childArgs, + executionContext.WorkingDirectory.FullName); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to start child CLI process"); + return null; + } + + logger.LogDebug("Child CLI process started with PID: {PID}", childProcess.Id); + + var startTime = timeProvider.GetUtcNow(); + var timeout = TimeSpan.FromSeconds(120); + + while (timeProvider.GetUtcNow() - startTime < timeout) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (childProcess.HasExited) + { + childExitedEarly = true; + childExitCode = childProcess.ExitCode; + logger.LogWarning("Child CLI process exited with code {ExitCode}", childExitCode); + return null; + } + + await backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); + + var connection = backchannelMonitor.GetConnectionsByHash(expectedHash).FirstOrDefault(); + if (connection is not null) + { + return connection; + } + + try + { + await childProcess.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromMilliseconds(500), cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + // Expected - the 500ms delay elapsed without the process exiting + } + } + + return null; + } + + IAppHostAuxiliaryBackchannel? backchannel; + if (format == OutputFormat.Json) + { + backchannel = await WaitForBackchannelAsync(); + } + else + { + backchannel = await interactionService.ShowStatusAsync( + RunCommandStrings.StartingAppHostInBackground, + WaitForBackchannelAsync); + } + + return new LaunchResult(childProcess, backchannel, childExitedEarly, childExitCode); + } + + private int HandleLaunchFailure(LaunchResult result, string childLogFile) + { + if (result.ChildProcess is null) + { + interactionService.DisplayError(RunCommandStrings.FailedToStartAppHost); + return ExitCodeConstants.FailedToDotnetRunAppHost; + } + + if (result.ChildExitedEarly) + { + interactionService.DisplayError(GetDetachedFailureMessage(result.ChildExitCode)); + } + else + { + interactionService.DisplayError(RunCommandStrings.TimeoutWaitingForAppHost); + + if (!result.ChildProcess.HasExited) + { + try + { + result.ChildProcess.Kill(); + } + catch + { + // Ignore errors when killing + } + } + } + + interactionService.DisplayMessage("magnifying_glass_tilted_right", string.Format( + CultureInfo.CurrentCulture, + RunCommandStrings.CheckLogsForDetails, + childLogFile)); + + return ExitCodeConstants.FailedToDotnetRunAppHost; + } + + private async Task DisplayLaunchResultAsync( + LaunchResult result, + FileInfo effectiveAppHostFile, + string childLogFile, + OutputFormat? format, + bool isExtensionHost, + CancellationToken cancellationToken) + { + var appHostInfo = result.Backchannel!.AppHostInfo; + var dashboardUrls = await result.Backchannel.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false); + var pid = appHostInfo?.ProcessId ?? result.ChildProcess!.Id; + + if (format == OutputFormat.Json) + { + var jsonResult = new DetachOutputInfo( + effectiveAppHostFile.FullName, + pid, + result.ChildProcess!.Id, + dashboardUrls?.BaseUrlWithLoginToken, + childLogFile); + var json = JsonSerializer.Serialize(jsonResult, RunCommandJsonContext.RelaxedEscaping.DetachOutputInfo); + interactionService.DisplayRawText(json, ConsoleOutput.Standard); + } + else + { + var appHostRelativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, effectiveAppHostFile.FullName); + RunCommand.RenderAppHostSummary( + ansiConsole, + appHostRelativePath, + dashboardUrls?.BaseUrlWithLoginToken, + codespacesUrl: null, + childLogFile, + isExtensionHost, + pid); + ansiConsole.WriteLine(); + + interactionService.DisplaySuccess(RunCommandStrings.AppHostStartedSuccessfully); + } + } + + /// + /// Creates a user-facing error message for detached child process failures. + /// + internal static string GetDetachedFailureMessage(int childExitCode) + { + return childExitCode switch + { + ExitCodeConstants.FailedToBuildArtifacts => RunCommandStrings.AppHostFailedToBuild, + _ => string.Format(CultureInfo.CurrentCulture, RunCommandStrings.AppHostExitedWithCode, childExitCode) + }; + } + + /// + /// Generates a unique log file path for a detached child CLI process. + /// + internal static string GenerateChildLogFilePath(string logsDirectory, TimeProvider timeProvider) + { + var timestamp = timeProvider.GetUtcNow().ToString("yyyyMMddTHHmmssfff", CultureInfo.InvariantCulture); + var uniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + var fileName = $"cli_{timestamp}_detach-child_{uniqueId}.log"; + return Path.Combine(logsDirectory, fileName); + } +} diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 7d6ee47390f..c8f205483f7 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -11,7 +11,6 @@ using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; using Aspire.Cli.Interaction; -using Aspire.Cli.Processes; using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; @@ -63,40 +62,22 @@ internal sealed class RunCommand : BaseCommand private readonly IConfiguration _configuration; private readonly IServiceProvider _serviceProvider; private readonly IFeatures _features; - private readonly TimeProvider _timeProvider; private readonly ILogger _logger; private readonly IAppHostProjectFactory _projectFactory; - private readonly IAuxiliaryBackchannelMonitor _backchannelMonitor; + private readonly AppHostLauncher _appHostLauncher; private readonly Diagnostics.FileLoggerProvider _fileLoggerProvider; private bool _isDetachMode; protected override bool UpdateNotificationsEnabled => !_isDetachMode; - private static readonly Option s_projectOption = new("--project") - { - Description = RunCommandStrings.ProjectArgumentDescription - }; private static readonly Option s_detachOption = new("--detach") { Description = RunCommandStrings.DetachArgumentDescription }; - private static readonly Option s_formatOption = new("--format") - { - Description = RunCommandStrings.JsonArgumentDescription - }; - private static readonly Option s_isolatedOption = new("--isolated") - { - Description = RunCommandStrings.IsolatedArgumentDescription - }; private static readonly Option s_noBuildOption = new("--no-build") { Description = RunCommandStrings.NoBuildArgumentDescription }; - private static readonly Option s_logFileOption = new("--log-file") - { - Description = "Path to write the log file (used internally by --detach).", - Hidden = true - }; private readonly Option? _startDebugSessionOption; public RunCommand( @@ -113,9 +94,8 @@ public RunCommand( CliExecutionContext executionContext, ILogger logger, IAppHostProjectFactory projectFactory, - IAuxiliaryBackchannelMonitor backchannelMonitor, - Diagnostics.FileLoggerProvider fileLoggerProvider, - TimeProvider? timeProvider) + AppHostLauncher appHostLauncher, + Diagnostics.FileLoggerProvider fileLoggerProvider) : base("run", RunCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { _runner = runner; @@ -128,16 +108,12 @@ public RunCommand( _features = features; _logger = logger; _projectFactory = projectFactory; - _backchannelMonitor = backchannelMonitor; + _appHostLauncher = appHostLauncher; _fileLoggerProvider = fileLoggerProvider; - _timeProvider = timeProvider ?? TimeProvider.System; - Options.Add(s_projectOption); Options.Add(s_detachOption); - Options.Add(s_formatOption); - Options.Add(s_isolatedOption); Options.Add(s_noBuildOption); - Options.Add(s_logFileOption); + AppHostLauncher.AddLaunchOptions(this); if (ExtensionHelper.IsExtensionHost(InteractionService, out _, out _)) { @@ -153,12 +129,12 @@ public RunCommand( protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - var passedAppHostProjectFile = parseResult.GetValue(s_projectOption); + var passedAppHostProjectFile = parseResult.GetValue(AppHostLauncher.s_projectOption); var detach = parseResult.GetValue(s_detachOption); _isDetachMode = detach; - var format = parseResult.GetValue(s_formatOption); - var isolated = parseResult.GetValue(s_isolatedOption); var noBuild = parseResult.GetValue(s_noBuildOption); + var format = parseResult.GetValue(AppHostLauncher.s_formatOption); + var isolated = parseResult.GetValue(AppHostLauncher.s_isolatedOption); var isExtensionHost = ExtensionHelper.IsExtensionHost(InteractionService, out _, out _); var startDebugSession = false; if (isExtensionHost) @@ -626,309 +602,27 @@ public void ProcessResourceState(RpcResourceState resourceState, Action /// On any failure, the log file path is displayed so the user can investigate. /// - private async Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? passedAppHostProjectFile, bool isExtensionHost, CancellationToken cancellationToken) + private Task ExecuteDetachedAsync(ParseResult parseResult, FileInfo? passedAppHostProjectFile, bool isExtensionHost, CancellationToken cancellationToken) { - var format = parseResult.GetValue(s_formatOption); + var format = parseResult.GetValue(AppHostLauncher.s_formatOption); + var isolated = parseResult.GetValue(AppHostLauncher.s_isolatedOption); + var noBuild = parseResult.GetValue(s_noBuildOption); + var globalArgs = RootCommand.GetChildProcessArgs(parseResult); + var additionalArgs = parseResult.UnmatchedTokens.Where(t => t != "--detach").ToList(); - // When outputting JSON, write all console to stderr by default. - // Only content explicitly sent to stdout (JSON results) appears on stdout. - if (format == OutputFormat.Json) + if (noBuild) { - _interactionService.Console = ConsoleOutput.Error; + additionalArgs.Add("--no-build"); } - // Failure mode 1: Project not found - // When outputting JSON, use Throw instead of Prompt to avoid polluting stdout - // with interactive selection UI. The user should specify --project explicitly. - var multipleAppHostBehavior = format == OutputFormat.Json - ? MultipleAppHostProjectsFoundBehavior.Throw - : MultipleAppHostProjectsFoundBehavior.Prompt; - - var searchResult = await _projectLocator.UseOrFindAppHostProjectFileAsync( + return _appHostLauncher.LaunchDetachedAsync( passedAppHostProjectFile, - multipleAppHostBehavior, - createSettingsFile: false, + format, + isolated, + isExtensionHost, + globalArgs, + additionalArgs, cancellationToken); - - var effectiveAppHostFile = searchResult.SelectedProjectFile; - - if (effectiveAppHostFile is null) - { - return ExitCodeConstants.FailedToFindProject; - } - - _logger.LogDebug("Starting AppHost in background: {AppHostPath}", effectiveAppHostFile.FullName); - - // Compute the expected auxiliary socket path prefix for this AppHost. - // The hash identifies the AppHost (from project path), while the PID makes each instance unique. - // Multiple instances of the same AppHost will have the same hash but different PIDs. - var expectedSocketPrefix = AppHostHelper.ComputeAuxiliarySocketPrefix( - effectiveAppHostFile.FullName, - ExecutionContext.HomeDirectory.FullName); - // We know the format is valid since we just computed it with ComputeAuxiliarySocketPrefix - var expectedHash = AppHostHelper.ExtractHashFromSocketPath(expectedSocketPrefix)!; - - _logger.LogDebug("Waiting for socket with prefix: {SocketPrefix}, Hash: {Hash}", expectedSocketPrefix, expectedHash); - - // Check for running instance and stop it if found (same behavior as regular run) - var runningInstanceDetectionEnabled = _features.IsFeatureEnabled(KnownFeatures.RunningInstanceDetectionEnabled, defaultValue: true); - var existingSockets = AppHostHelper.FindMatchingSockets( - effectiveAppHostFile.FullName, - ExecutionContext.HomeDirectory.FullName); - - if (runningInstanceDetectionEnabled && existingSockets.Length > 0) - { - _logger.LogDebug("Found {Count} running instance(s) for this AppHost, stopping them first", existingSockets.Length); - var manager = new RunningInstanceManager(_logger, _interactionService, _timeProvider); - // Stop all running instances in parallel - don't block on failures - var stopTasks = existingSockets.Select(socket => - manager.StopRunningInstanceAsync(socket, cancellationToken)); - await Task.WhenAll(stopTasks).ConfigureAwait(false); - } - - // Build the arguments for the child CLI process - // Tell the child where to write its log so we can find it on failure. - var childLogFile = GenerateChildLogFilePath(); - - var args = new List - { - "run", - "--non-interactive", - "--project", - effectiveAppHostFile.FullName, - "--log-file", - childLogFile - }; - - // Pass through global options that should be forwarded to child CLI - args.AddRange(RootCommand.GetChildProcessArgs(parseResult)); - - // Pass through run-specific options - if (parseResult.GetValue(s_isolatedOption)) - { - args.Add("--isolated"); - } - if (parseResult.GetValue(s_noBuildOption)) - { - args.Add("--no-build"); - } - - // Pass through any unmatched tokens (but not --detach since child shouldn't detach again) - foreach (var token in parseResult.UnmatchedTokens) - { - if (token != "--detach") - { - args.Add(token); - } - } - - // Get the path to the current executable - // When running as `dotnet aspire.dll`, Environment.ProcessPath returns dotnet.exe, - // so we need to also pass the entry assembly (aspire.dll) as the first argument. - // When running native AOT, ProcessPath IS the native executable. - var dotnetPath = Environment.ProcessPath ?? "dotnet"; - var isDotnetHost = dotnetPath.EndsWith("dotnet", StringComparison.OrdinalIgnoreCase) || - dotnetPath.EndsWith("dotnet.exe", StringComparison.OrdinalIgnoreCase); - - // For single-file apps, Assembly.Location is empty. Use command-line args instead. - // args[0] when running `dotnet aspire.dll` is the dll path - var entryAssemblyPath = Environment.GetCommandLineArgs().FirstOrDefault(); - - _logger.LogDebug("Spawning child CLI: {Executable} (isDotnetHost={IsDotnetHost}) with args: {Args}", - dotnetPath, isDotnetHost, string.Join(" ", args)); - _logger.LogDebug("Working directory: {WorkingDirectory}", ExecutionContext.WorkingDirectory.FullName); - - // Build the full argument list for the child process, including the entry assembly - // path when running via `dotnet aspire.dll` - var childArgs = new List(); - if (isDotnetHost && !string.IsNullOrEmpty(entryAssemblyPath) && entryAssemblyPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) - { - childArgs.Add(entryAssemblyPath); - } - - childArgs.AddRange(args); - - // Start the child process and wait for the backchannel in a single status spinner - Process? childProcess = null; - var childExitedEarly = false; - var childExitCode = 0; - - async Task StartAndWaitForBackchannelAsync() - { - // Failure mode 2: Failed to spawn child process - try - { - childProcess = DetachedProcessLauncher.Start( - dotnetPath, - childArgs, - ExecutionContext.WorkingDirectory.FullName); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to start child CLI process"); - return null; - } - - _logger.LogDebug("Child CLI process started with PID: {PID}", childProcess.Id); - - // Failure modes 3 & 4: Wait for the auxiliary backchannel to become available - // - Mode 3: Child exits early (build failure, config error, etc.) - // - Mode 4: Timeout waiting for backchannel (120 seconds) - var startTime = _timeProvider.GetUtcNow(); - var timeout = TimeSpan.FromSeconds(120); - - while (_timeProvider.GetUtcNow() - startTime < timeout) - { - cancellationToken.ThrowIfCancellationRequested(); - - // Failure mode 3: Child process exited early - if (childProcess.HasExited) - { - childExitedEarly = true; - childExitCode = childProcess.ExitCode; - _logger.LogWarning("Child CLI process exited with code {ExitCode}", childExitCode); - return null; - } - - // Trigger a scan and try to connect - await _backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false); - - // Check if we can find a connection for this AppHost by hash - var connection = _backchannelMonitor.GetConnectionsByHash(expectedHash).FirstOrDefault(); - if (connection is not null) - { - return connection; - } - - // Wait a bit before trying again, but short-circuit if the child process exits - try - { - await childProcess.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromMilliseconds(500), cancellationToken).ConfigureAwait(false); - // If we get here, the process exited - we'll catch it at the top of the next iteration - } - catch (TimeoutException) - { - // Expected - the 500ms delay elapsed without the process exiting - } - } - - // Failure mode 4: Timeout - loop exited without finding connection - return null; - } - - // For JSON output, skip the status spinner to avoid contaminating stdout - IAppHostAuxiliaryBackchannel? backchannel; - if (format == OutputFormat.Json) - { - backchannel = await StartAndWaitForBackchannelAsync(); - } - else - { - backchannel = await _interactionService.ShowStatusAsync( - RunCommandStrings.StartingAppHostInBackground, - StartAndWaitForBackchannelAsync); - } - - // Handle failure cases - show specific error and log file path - if (backchannel is null || childProcess is null) - { - if (childProcess is null) - { - _interactionService.DisplayError(RunCommandStrings.FailedToStartAppHost); - return ExitCodeConstants.FailedToDotnetRunAppHost; - } - - if (childExitedEarly) - { - // Show a friendly message based on well-known exit codes from the child - _interactionService.DisplayError(GetDetachedFailureMessage(childExitCode)); - } - else - { - _interactionService.DisplayError(RunCommandStrings.TimeoutWaitingForAppHost); - - // Try to kill the child process if it's still running (timeout case) - if (!childProcess.HasExited) - { - try - { - childProcess.Kill(); - } - catch - { - // Ignore errors when killing - } - } - } - - // Point to the child's log file — it contains the actual build/runtime errors - _interactionService.DisplayMessage("magnifying_glass_tilted_right", string.Format( - CultureInfo.CurrentCulture, - RunCommandStrings.CheckLogsForDetails, - childLogFile.EscapeMarkup())); - - return ExitCodeConstants.FailedToDotnetRunAppHost; - } - - var appHostInfo = backchannel.AppHostInfo; - - // Get the dashboard URLs - var dashboardUrls = await backchannel.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false); - - var pid = appHostInfo?.ProcessId ?? childProcess.Id; - - if (format == OutputFormat.Json) - { - // Output structured JSON for programmatic consumption - var result = new DetachOutputInfo( - effectiveAppHostFile.FullName, - pid, - childProcess.Id, - dashboardUrls?.BaseUrlWithLoginToken, - childLogFile); - var json = JsonSerializer.Serialize(result, RunCommandJsonContext.RelaxedEscaping.DetachOutputInfo); - // Structured output always goes to stdout. - _interactionService.DisplayRawText(json, ConsoleOutput.Standard); - } - else - { - // Display success UX using shared rendering - var appHostRelativePath = Path.GetRelativePath(ExecutionContext.WorkingDirectory.FullName, effectiveAppHostFile.FullName); - RenderAppHostSummary( - _ansiConsole, - appHostRelativePath, - dashboardUrls?.BaseUrlWithLoginToken, - codespacesUrl: null, - childLogFile, - isExtensionHost, - pid); - _ansiConsole.WriteLine(); - - _interactionService.DisplaySuccess(RunCommandStrings.AppHostStartedSuccessfully); - } - - return ExitCodeConstants.Success; - } - - internal static string GetDetachedFailureMessage(int childExitCode) - { - return childExitCode switch - { - ExitCodeConstants.FailedToBuildArtifacts => RunCommandStrings.AppHostFailedToBuild, - _ => string.Format(CultureInfo.CurrentCulture, RunCommandStrings.AppHostExitedWithCode, childExitCode) - }; } - internal static string GenerateChildLogFilePath(string logsDirectory, TimeProvider timeProvider) - { - var timestamp = timeProvider.GetUtcNow().ToString("yyyyMMddTHHmmssfff", CultureInfo.InvariantCulture); - var uniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - var fileName = $"cli_{timestamp}_detach-child_{uniqueId}.log"; - return Path.Combine(logsDirectory, fileName); - } - - private string GenerateChildLogFilePath() - { - return GenerateChildLogFilePath(ExecutionContext.LogsDirectory.FullName, _timeProvider); - } } diff --git a/src/Aspire.Cli/Commands/StartCommand.cs b/src/Aspire.Cli/Commands/StartCommand.cs index 82f2d17ffb7..d2c3d214c8a 100644 --- a/src/Aspire.Cli/Commands/StartCommand.cs +++ b/src/Aspire.Cli/Commands/StartCommand.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.CommandLine; +using System.Globalization; using Aspire.Cli.Backchannel; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; @@ -12,15 +14,25 @@ namespace Aspire.Cli.Commands; -internal sealed class StartCommand : ResourceCommandBase +internal sealed class StartCommand : BaseCommand { internal override HelpGroup HelpGroup => HelpGroup.ResourceManagement; - protected override string CommandName => KnownResourceCommands.StartCommand; - protected override string ProgressVerb => "Starting"; - protected override string BaseVerb => "start"; - protected override string PastTenseVerb => "started"; - protected override string ResourceArgumentDescription => ResourceCommandStrings.StartResourceArgumentDescription; + private readonly IInteractionService _interactionService; + private readonly AppHostConnectionResolver _connectionResolver; + private readonly AppHostLauncher _appHostLauncher; + private readonly ILogger _logger; + + private static readonly Argument s_resourceArgument = new("resource") + { + Description = ResourceCommandStrings.StartResourceArgumentDescription, + Arity = ArgumentArity.ZeroOrOne + }; + + private static readonly Option s_noBuildOption = new("--no-build") + { + Description = RunCommandStrings.NoBuildArgumentDescription + }; public StartCommand( IInteractionService interactionService, @@ -29,10 +41,94 @@ public StartCommand( ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ILogger logger, - AspireCliTelemetry telemetry) + AspireCliTelemetry telemetry, + AppHostLauncher appHostLauncher) : base("start", ResourceCommandStrings.StartDescription, - interactionService, backchannelMonitor, features, updateNotifier, - executionContext, logger, telemetry) + features, updateNotifier, executionContext, interactionService, telemetry) + { + _interactionService = interactionService; + _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); + _appHostLauncher = appHostLauncher; + _logger = logger; + + Arguments.Add(s_resourceArgument); + Options.Add(s_noBuildOption); + AppHostLauncher.AddLaunchOptions(this); + + TreatUnmatchedTokensAsErrors = false; + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var resourceName = parseResult.GetValue(s_resourceArgument); + var passedAppHostProjectFile = parseResult.GetValue(AppHostLauncher.s_projectOption); + var format = parseResult.GetValue(AppHostLauncher.s_formatOption); + var isolated = parseResult.GetValue(AppHostLauncher.s_isolatedOption); + + // If a resource name is provided, start that specific resource + if (!string.IsNullOrEmpty(resourceName)) + { + if (format is not null) + { + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ResourceCommandStrings.OptionNotValidWithResource, "--format")); + return ExitCodeConstants.InvalidCommand; + } + + if (isolated) + { + _interactionService.DisplayError(string.Format(CultureInfo.CurrentCulture, ResourceCommandStrings.OptionNotValidWithResource, "--isolated")); + return ExitCodeConstants.InvalidCommand; + } + + return await StartResourceAsync(passedAppHostProjectFile, resourceName, cancellationToken); + } + + // No resource specified — start the AppHost in detached mode + var noBuild = parseResult.GetValue(s_noBuildOption); + var isExtensionHost = ExtensionHelper.IsExtensionHost(_interactionService, out _, out _); + var globalArgs = RootCommand.GetChildProcessArgs(parseResult); + var additionalArgs = parseResult.UnmatchedTokens.ToList(); + + if (noBuild) + { + additionalArgs.Add("--no-build"); + } + + return await _appHostLauncher.LaunchDetachedAsync( + passedAppHostProjectFile, + format, + isolated, + isExtensionHost, + globalArgs, + additionalArgs, + cancellationToken); + } + + private async Task StartResourceAsync(FileInfo? passedAppHostProjectFile, string resourceName, CancellationToken cancellationToken) { + var result = await _connectionResolver.ResolveConnectionAsync( + passedAppHostProjectFile, + SharedCommandStrings.ScanningForRunningAppHosts, + string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, ResourceCommandStrings.SelectAppHostAction), + SharedCommandStrings.NoInScopeAppHostsShowingAll, + SharedCommandStrings.AppHostNotRunning, + cancellationToken); + + if (!result.Success) + { + _interactionService.DisplayError(result.ErrorMessage); + return ExitCodeConstants.FailedToFindProject; + } + + return await ResourceCommandHelper.ExecuteResourceCommandAsync( + result.Connection!, + _interactionService, + _logger, + resourceName, + KnownResourceCommands.StartCommand, + "Starting", + "start", + "started", + cancellationToken); } } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 05a052f3177..c0ee4443c76 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -354,6 +354,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddSingleton(); // Commands. + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/Aspire.Cli/Resources/ResourceCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/ResourceCommandStrings.Designer.cs index ec4e6db17c0..cf4c27677f3 100644 --- a/src/Aspire.Cli/Resources/ResourceCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/ResourceCommandStrings.Designer.cs @@ -92,5 +92,11 @@ internal static string CommandNameArgumentDescription { return ResourceManager.GetString("CommandNameArgumentDescription", resourceCulture); } } + + internal static string OptionNotValidWithResource { + get { + return ResourceManager.GetString("OptionNotValidWithResource", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/ResourceCommandStrings.resx b/src/Aspire.Cli/Resources/ResourceCommandStrings.resx index 929c46d2db8..12004c39c05 100644 --- a/src/Aspire.Cli/Resources/ResourceCommandStrings.resx +++ b/src/Aspire.Cli/Resources/ResourceCommandStrings.resx @@ -62,10 +62,10 @@ connect to - Start a stopped resource. + Start an apphost in the background, or start a stopped resource. - The name of the resource to start. + The name of the resource to start. If not specified, starts the AppHost in the background. Restart a running resource. @@ -82,4 +82,7 @@ The name of the command to execute. + + The '{0}' option is not valid when starting a resource. + diff --git a/src/Aspire.Cli/Resources/RunCommandStrings.resx b/src/Aspire.Cli/Resources/RunCommandStrings.resx index 70c58a28d54..293f22bb345 100644 --- a/src/Aspire.Cli/Resources/RunCommandStrings.resx +++ b/src/Aspire.Cli/Resources/RunCommandStrings.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Run an apphost in development mode. + Run an Aspire AppHost interactively for development. Stop any running instance of the AppHost without prompting. @@ -130,7 +130,7 @@ Run the AppHost in the background and exit after it starts. - Output format (Table or Json). Only valid with --detach. + Output format for detached AppHost results. Start project resources in watch mode. diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs index 89ef55539f7..8017a085038 100644 --- a/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.Designer.cs @@ -74,5 +74,17 @@ internal static string ProjectOptionDescription { return ResourceManager.GetString("ProjectOptionDescription", resourceCulture); } } + + internal static string FormatOptionDescription { + get { + return ResourceManager.GetString("FormatOptionDescription", resourceCulture); + } + } + + internal static string IsolatedOptionDescription { + get { + return ResourceManager.GetString("IsolatedOptionDescription", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/SharedCommandStrings.resx b/src/Aspire.Cli/Resources/SharedCommandStrings.resx index b99053964ba..bbb24361eb1 100644 --- a/src/Aspire.Cli/Resources/SharedCommandStrings.resx +++ b/src/Aspire.Cli/Resources/SharedCommandStrings.resx @@ -132,4 +132,10 @@ The path to the Aspire AppHost project file. + + Output format for detached AppHost results. + + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf index 88281284b2d..73bdcd553dc 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.cs.xlf @@ -1,4 +1,4 @@ - + @@ -17,6 +17,11 @@ Název prostředku, na kterém se má příkaz provést + + The '{0}' option is not valid when starting a resource. + The '{0}' option is not valid when starting a resource. + + Restart a running resource. Restartujte spuštěný prostředek. @@ -33,12 +38,12 @@ - Start a stopped resource. + Start an apphost in the background, or start a stopped resource. Spusťte zastavený prostředek. - The name of the resource to start. + The name of the resource to start. If not specified, starts the AppHost in the background. Název prostředku, který se má spustit diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf index 0474293a670..d3c7fba709b 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.de.xlf @@ -1,4 +1,4 @@ - + @@ -17,6 +17,11 @@ Der Name der Ressource, für die der Befehl ausgeführt werden soll. + + The '{0}' option is not valid when starting a resource. + The '{0}' option is not valid when starting a resource. + + Restart a running resource. Starten Sie eine laufende Ressource neu. @@ -33,12 +38,12 @@ - Start a stopped resource. + Start an apphost in the background, or start a stopped resource. Starten Sie eine gestoppte Ressource. - The name of the resource to start. + The name of the resource to start. If not specified, starts the AppHost in the background. Der Name der Ressource, die gestartet werden soll. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf index fb0511f74c0..1b2e4fe4b0c 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.es.xlf @@ -1,4 +1,4 @@ - + @@ -17,6 +17,11 @@ El nombre del recurso de destino en el que se ejecutará el comando. + + The '{0}' option is not valid when starting a resource. + The '{0}' option is not valid when starting a resource. + + Restart a running resource. Reinicie un recurso en ejecución. @@ -33,12 +38,12 @@ - Start a stopped resource. + Start an apphost in the background, or start a stopped resource. Inicie un recurso detenido. - The name of the resource to start. + The name of the resource to start. If not specified, starts the AppHost in the background. Nombre del recurso que se va a iniciar. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf index 83f8c10a082..70b7be80431 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.fr.xlf @@ -1,4 +1,4 @@ - + @@ -17,6 +17,11 @@ Nom de la ressource sur laquelle exécuter la commande. + + The '{0}' option is not valid when starting a resource. + The '{0}' option is not valid when starting a resource. + + Restart a running resource. Redémarrer une ressource en cours d’exécution. @@ -33,12 +38,12 @@ - Start a stopped resource. + Start an apphost in the background, or start a stopped resource. Démarrer une ressource arrêtée. - The name of the resource to start. + The name of the resource to start. If not specified, starts the AppHost in the background. Le nom de la ressource à commencer. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf index 4cee2b76cd0..d69449f3e5e 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.it.xlf @@ -1,4 +1,4 @@ - + @@ -17,6 +17,11 @@ Nome della risorsa di destinazione rispetto al quale eseguire il comando. + + The '{0}' option is not valid when starting a resource. + The '{0}' option is not valid when starting a resource. + + Restart a running resource. Riavviare una risorsa in esecuzione. @@ -33,12 +38,12 @@ - Start a stopped resource. + Start an apphost in the background, or start a stopped resource. Avviare una risorsa arrestata. - The name of the resource to start. + The name of the resource to start. If not specified, starts the AppHost in the background. Nome della risorsa da avviare. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf index c756ac645ec..cb5b024e9c7 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ja.xlf @@ -1,4 +1,4 @@ - + @@ -17,6 +17,11 @@ コマンドを実行する対象のリソースの名前。 + + The '{0}' option is not valid when starting a resource. + The '{0}' option is not valid when starting a resource. + + Restart a running resource. 実行中のリソースを再起動します。 @@ -33,12 +38,12 @@ - Start a stopped resource. + Start an apphost in the background, or start a stopped resource. 停止しているリソースを開始します。 - The name of the resource to start. + The name of the resource to start. If not specified, starts the AppHost in the background. 開始するリソースの名前。 diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf index ab1d2740f04..ffd945c2525 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ko.xlf @@ -1,4 +1,4 @@ - + @@ -17,6 +17,11 @@ 명령을 실행할 대상 리소스의 이름입니다. + + The '{0}' option is not valid when starting a resource. + The '{0}' option is not valid when starting a resource. + + Restart a running resource. 실행 중인 리소스를 다시 시작합니다. @@ -33,12 +38,12 @@ - Start a stopped resource. + Start an apphost in the background, or start a stopped resource. 중지된 리소스를 시작합니다. - The name of the resource to start. + The name of the resource to start. If not specified, starts the AppHost in the background. 시작할 리소스의 이름입니다. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf index 3d6de3c32e4..7661fc8b71b 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pl.xlf @@ -1,4 +1,4 @@ - + @@ -17,6 +17,11 @@ Nazwa zasobu, na którym ma zostać wykonane polecenie. + + The '{0}' option is not valid when starting a resource. + The '{0}' option is not valid when starting a resource. + + Restart a running resource. Uruchom ponownie uruchomiony zasób. @@ -33,12 +38,12 @@ - Start a stopped resource. + Start an apphost in the background, or start a stopped resource. Uruchom zatrzymany zasób. - The name of the resource to start. + The name of the resource to start. If not specified, starts the AppHost in the background. Nazwa zasobu do uruchomienia. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf index 46f3fabd4e5..626d0e685d3 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.pt-BR.xlf @@ -1,4 +1,4 @@ - + @@ -17,6 +17,11 @@ O nome do recurso no qual executar o comando. + + The '{0}' option is not valid when starting a resource. + The '{0}' option is not valid when starting a resource. + + Restart a running resource. Reinicie um recurso em execução. @@ -33,12 +38,12 @@ - Start a stopped resource. + Start an apphost in the background, or start a stopped resource. Inicie um recurso parado. - The name of the resource to start. + The name of the resource to start. If not specified, starts the AppHost in the background. O nome do recurso a ser iniciado. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf index fe5a61fa4df..d909cd81c47 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.ru.xlf @@ -1,4 +1,4 @@ - + @@ -17,6 +17,11 @@ Имя целевого ресурса, на котором будет выполняться команда. + + The '{0}' option is not valid when starting a resource. + The '{0}' option is not valid when starting a resource. + + Restart a running resource. Перезапустить уже запущенный ресурс. @@ -33,12 +38,12 @@ - Start a stopped resource. + Start an apphost in the background, or start a stopped resource. Запустить остановленный ресурс. - The name of the resource to start. + The name of the resource to start. If not specified, starts the AppHost in the background. Имя ресурса, который нужно запустить. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf index b4849ab7b0e..0c268825148 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.tr.xlf @@ -1,4 +1,4 @@ - + @@ -17,6 +17,11 @@ Komutu yürütecek kaynağın adı. + + The '{0}' option is not valid when starting a resource. + The '{0}' option is not valid when starting a resource. + + Restart a running resource. Çalışan bir kaynağı yeniden başlatın. @@ -33,12 +38,12 @@ - Start a stopped resource. + Start an apphost in the background, or start a stopped resource. Durdurulmuş bir kaynağı başlatın. - The name of the resource to start. + The name of the resource to start. If not specified, starts the AppHost in the background. Başlatılacak kaynağın adı. diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf index 3f0219bbd27..ddc589c2e40 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hans.xlf @@ -1,4 +1,4 @@ - + @@ -17,6 +17,11 @@ 要在其上执行命令的资源的名称。 + + The '{0}' option is not valid when starting a resource. + The '{0}' option is not valid when starting a resource. + + Restart a running resource. 重启正在运行的资源。 @@ -33,12 +38,12 @@ - Start a stopped resource. + Start an apphost in the background, or start a stopped resource. 启动已停止的资源。 - The name of the resource to start. + The name of the resource to start. If not specified, starts the AppHost in the background. 要启动的资源的名称。 diff --git a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf index 84a54b1a00d..00fff5633e8 100644 --- a/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/ResourceCommandStrings.zh-Hant.xlf @@ -1,4 +1,4 @@ - + @@ -17,6 +17,11 @@ 要執行命令的資源名稱。 + + The '{0}' option is not valid when starting a resource. + The '{0}' option is not valid when starting a resource. + + Restart a running resource. 重新啟動正在執行的資源。 @@ -33,12 +38,12 @@ - Start a stopped resource. + Start an apphost in the background, or start a stopped resource. 啟動已停止的資源。 - The name of the resource to start. + The name of the resource to start. If not specified, starts the AppHost in the background. 要啟動的資源名稱。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf index f2278397832..408f4c0ff76 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf @@ -78,7 +78,7 @@ - Run an apphost in development mode. + Run an Aspire AppHost interactively for development. Spusťte hostitele aplikací Aspire ve vývojovém režimu. @@ -118,7 +118,7 @@ - Output format (Table or Json). Only valid with --detach. + Output format for detached AppHost results. Výsledek výstupu ve formátu JSON (platný jenom s parametrem --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf index 19b08ef5426..fc752ab1cae 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf @@ -78,7 +78,7 @@ - Run an apphost in development mode. + Run an Aspire AppHost interactively for development. Führen Sie einen Aspire-App-Host im Entwicklungsmodus aus. @@ -118,7 +118,7 @@ - Output format (Table or Json). Only valid with --detach. + Output format for detached AppHost results. Ausgabeergebnis als JSON (nur gültig mit --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf index 1e5bdced501..f8dcdd2a279 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf @@ -78,7 +78,7 @@ - Run an apphost in development mode. + Run an Aspire AppHost interactively for development. Ejecutar un apphost de Aspire en modo de desarrollo. @@ -118,7 +118,7 @@ - Output format (Table or Json). Only valid with --detach. + Output format for detached AppHost results. Resultado de salida como JSON (solo válido con --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf index e2808d90bc9..acfc3b0e3c8 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf @@ -78,7 +78,7 @@ - Run an apphost in development mode. + Run an Aspire AppHost interactively for development. Exécutez un hôte d’application Aspire en mode développement. @@ -118,7 +118,7 @@ - Output format (Table or Json). Only valid with --detach. + Output format for detached AppHost results. Afficher le résultat au format JSON (valide uniquement avec --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf index 3762ab6b91d..6b846a3ad4c 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf @@ -78,7 +78,7 @@ - Run an apphost in development mode. + Run an Aspire AppHost interactively for development. Esegui un AppHost Aspire in modalità di sviluppo. @@ -118,7 +118,7 @@ - Output format (Table or Json). Only valid with --detach. + Output format for detached AppHost results. Restituisce il risultato in formato JSON (valido solo con --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf index 671308acd8d..7bade179271 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf @@ -78,7 +78,7 @@ - Run an apphost in development mode. + Run an Aspire AppHost interactively for development. 開発モードで Aspire AppHost を実行します。 @@ -118,7 +118,7 @@ - Output format (Table or Json). Only valid with --detach. + Output format for detached AppHost results. 結果を JSON 形式で出力します (--detach オプション指定時のみ有効)。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf index 61f6cf7be0b..545d3372808 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf @@ -78,7 +78,7 @@ - Run an apphost in development mode. + Run an Aspire AppHost interactively for development. 개발 모드에서 Aspire 앱호스트를 실행합니다. @@ -118,7 +118,7 @@ - Output format (Table or Json). Only valid with --detach. + Output format for detached AppHost results. JSON으로 결과를 출력합니다(--detach와 함께 사용 시에만 유효). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf index 1a6b00940f0..cb9b49f296d 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf @@ -78,7 +78,7 @@ - Run an apphost in development mode. + Run an Aspire AppHost interactively for development. Uruchamianie hosta AppHost platformy Aspire w trybie programowania. @@ -118,7 +118,7 @@ - Output format (Table or Json). Only valid with --detach. + Output format for detached AppHost results. Wyświetl wynik jako JSON (działa tylko z parametrem --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf index b7f3f3ae738..15b25dbf161 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf @@ -78,7 +78,7 @@ - Run an apphost in development mode. + Run an Aspire AppHost interactively for development. Execute um apphost do Aspire no modo de desenvolvimento. @@ -118,7 +118,7 @@ - Output format (Table or Json). Only valid with --detach. + Output format for detached AppHost results. Resultado de saída como JSON (válido somente com --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf index a5b9942a118..acce9cd2a43 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf @@ -78,7 +78,7 @@ - Run an apphost in development mode. + Run an Aspire AppHost interactively for development. Запустите хост приложений Aspire в режиме разработки. @@ -118,7 +118,7 @@ - Output format (Table or Json). Only valid with --detach. + Output format for detached AppHost results. Вывод результата в формате JSON (допустимо только с параметром --detach). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf index 7e335b22c69..a4f08cabbf1 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf @@ -78,7 +78,7 @@ - Run an apphost in development mode. + Run an Aspire AppHost interactively for development. Geliştirme modunda bir Aspire uygulama ana işlemini çalıştırın. @@ -118,7 +118,7 @@ - Output format (Table or Json). Only valid with --detach. + Output format for detached AppHost results. Sonucu JSON olarak çıkar (yalnızca --detach ile geçerlidir). diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf index e4b859cd671..e12006ca5e7 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf @@ -78,7 +78,7 @@ - Run an apphost in development mode. + Run an Aspire AppHost interactively for development. 在开发模式下运行 Aspire 应用主机。 @@ -118,7 +118,7 @@ - Output format (Table or Json). Only valid with --detach. + Output format for detached AppHost results. 以 JSON 格式输出结果(仅在使用 --detach 时有效)。 diff --git a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf index fa9b873a2aa..9eed9ef8e4a 100644 --- a/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf @@ -78,7 +78,7 @@ - Run an apphost in development mode. + Run an Aspire AppHost interactively for development. 在開發模式中執行 Aspire AppHost。 @@ -118,7 +118,7 @@ - Output format (Table or Json). Only valid with --detach. + Output format for detached AppHost results. 輸出結果為 JSON (僅於使用 --detach 時有效)。 diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf index 3ee8a6a6ecb..9cbc235ab5e 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.cs.xlf @@ -1,4 +1,4 @@ - + @@ -7,6 +7,16 @@ No running AppHost found. Use 'aspire run' to start one first. + + Output format for detached AppHost results. + Output format for detached AppHost results. + + + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + + No running AppHosts found in the current directory. Showing all running AppHosts: No running AppHosts found in the current directory. Showing all running AppHosts: diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf index bf2f0af8d6a..64fe68bac69 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.de.xlf @@ -1,4 +1,4 @@ - + @@ -7,6 +7,16 @@ No running AppHost found. Use 'aspire run' to start one first. + + Output format for detached AppHost results. + Output format for detached AppHost results. + + + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + + No running AppHosts found in the current directory. Showing all running AppHosts: No running AppHosts found in the current directory. Showing all running AppHosts: diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf index 996b521487f..daa3997f470 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.es.xlf @@ -1,4 +1,4 @@ - + @@ -7,6 +7,16 @@ No running AppHost found. Use 'aspire run' to start one first. + + Output format for detached AppHost results. + Output format for detached AppHost results. + + + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + + No running AppHosts found in the current directory. Showing all running AppHosts: No running AppHosts found in the current directory. Showing all running AppHosts: diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf index 6d1d03e4473..848d30f3f8f 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.fr.xlf @@ -1,4 +1,4 @@ - + @@ -7,6 +7,16 @@ No running AppHost found. Use 'aspire run' to start one first. + + Output format for detached AppHost results. + Output format for detached AppHost results. + + + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + + No running AppHosts found in the current directory. Showing all running AppHosts: No running AppHosts found in the current directory. Showing all running AppHosts: diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf index 3c1ead19010..d17d89d7c38 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.it.xlf @@ -1,4 +1,4 @@ - + @@ -7,6 +7,16 @@ No running AppHost found. Use 'aspire run' to start one first. + + Output format for detached AppHost results. + Output format for detached AppHost results. + + + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + + No running AppHosts found in the current directory. Showing all running AppHosts: No running AppHosts found in the current directory. Showing all running AppHosts: diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf index e5afe74a3bc..2047eac3fc8 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ja.xlf @@ -1,4 +1,4 @@ - + @@ -7,6 +7,16 @@ No running AppHost found. Use 'aspire run' to start one first. + + Output format for detached AppHost results. + Output format for detached AppHost results. + + + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + + No running AppHosts found in the current directory. Showing all running AppHosts: No running AppHosts found in the current directory. Showing all running AppHosts: diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf index dad5a836341..426e5484a20 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ko.xlf @@ -1,4 +1,4 @@ - + @@ -7,6 +7,16 @@ No running AppHost found. Use 'aspire run' to start one first. + + Output format for detached AppHost results. + Output format for detached AppHost results. + + + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + + No running AppHosts found in the current directory. Showing all running AppHosts: No running AppHosts found in the current directory. Showing all running AppHosts: diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf index 338bdf5cc98..9257c23b362 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pl.xlf @@ -1,4 +1,4 @@ - + @@ -7,6 +7,16 @@ No running AppHost found. Use 'aspire run' to start one first. + + Output format for detached AppHost results. + Output format for detached AppHost results. + + + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + + No running AppHosts found in the current directory. Showing all running AppHosts: No running AppHosts found in the current directory. Showing all running AppHosts: diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf index 19c429cb178..1add8587877 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.pt-BR.xlf @@ -1,4 +1,4 @@ - + @@ -7,6 +7,16 @@ No running AppHost found. Use 'aspire run' to start one first. + + Output format for detached AppHost results. + Output format for detached AppHost results. + + + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + + No running AppHosts found in the current directory. Showing all running AppHosts: No running AppHosts found in the current directory. Showing all running AppHosts: diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf index db6c97cf0d3..97e48d38f13 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.ru.xlf @@ -1,4 +1,4 @@ - + @@ -7,6 +7,16 @@ No running AppHost found. Use 'aspire run' to start one first. + + Output format for detached AppHost results. + Output format for detached AppHost results. + + + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + + No running AppHosts found in the current directory. Showing all running AppHosts: No running AppHosts found in the current directory. Showing all running AppHosts: diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf index 7957e903703..9adf3281842 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.tr.xlf @@ -1,4 +1,4 @@ - + @@ -7,6 +7,16 @@ No running AppHost found. Use 'aspire run' to start one first. + + Output format for detached AppHost results. + Output format for detached AppHost results. + + + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + + No running AppHosts found in the current directory. Showing all running AppHosts: No running AppHosts found in the current directory. Showing all running AppHosts: diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf index a9c18e3ccac..9923a85f39e 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hans.xlf @@ -1,4 +1,4 @@ - + @@ -7,6 +7,16 @@ No running AppHost found. Use 'aspire run' to start one first. + + Output format for detached AppHost results. + Output format for detached AppHost results. + + + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + + No running AppHosts found in the current directory. Showing all running AppHosts: No running AppHosts found in the current directory. Showing all running AppHosts: diff --git a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf index 6468463ed38..dfed8f4018b 100644 --- a/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/SharedCommandStrings.zh-Hant.xlf @@ -1,4 +1,4 @@ - + @@ -7,6 +7,16 @@ No running AppHost found. Use 'aspire run' to start one first. + + Output format for detached AppHost results. + Output format for detached AppHost results. + + + + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously. + + No running AppHosts found in the current directory. Showing all running AppHosts: No running AppHosts found in the current directory. Showing all running AppHosts: diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 3d69f71073f..a97d82b1b1f 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -132,7 +132,7 @@ public async Task RunCommand_WithoutDetachFlag_ShowsUpdateNotification() [Fact] public void GetDetachedFailureMessage_ReturnsBuildSpecificMessage_ForBuildFailureExitCode() { - var message = RunCommand.GetDetachedFailureMessage(ExitCodeConstants.FailedToBuildArtifacts); + var message = AppHostLauncher.GetDetachedFailureMessage(ExitCodeConstants.FailedToBuildArtifacts); Assert.Equal(RunCommandStrings.AppHostFailedToBuild, message); } @@ -140,7 +140,7 @@ public void GetDetachedFailureMessage_ReturnsBuildSpecificMessage_ForBuildFailur [Fact] public void GetDetachedFailureMessage_ReturnsExitCodeMessage_ForUnknownExitCode() { - var message = RunCommand.GetDetachedFailureMessage(123); + var message = AppHostLauncher.GetDetachedFailureMessage(123); Assert.Contains("123", message, StringComparison.Ordinal); } @@ -152,7 +152,7 @@ public void GenerateChildLogFilePath_UsesDetachChildNamingWithoutProcessId() var now = new DateTimeOffset(2026, 02, 12, 18, 00, 00, TimeSpan.Zero); var timeProvider = new FixedTimeProvider(now); - var path = RunCommand.GenerateChildLogFilePath(logsDirectory, timeProvider); + var path = AppHostLauncher.GenerateChildLogFilePath(logsDirectory, timeProvider); var fileName = Path.GetFileName(path); Assert.StartsWith(logsDirectory, path, StringComparison.OrdinalIgnoreCase); diff --git a/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs index 4350993cd41..37af2504c84 100644 --- a/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/StartCommandTests.cs @@ -67,4 +67,74 @@ public async Task StartCommand_AcceptsProjectOption() var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.Success, exitCode); } + + [Fact] + public async Task StartCommand_AcceptsNoBuildOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("start --no-build --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task StartCommand_AcceptsFormatOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("start --format json --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task StartCommand_AcceptsIsolatedOption() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("start --isolated --help"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + [Fact] + public async Task StartCommand_FormatOptionNotValidWithResource() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("start myresource --format json"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } + + [Fact] + public async Task StartCommand_IsolatedOptionNotValidWithResource() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("start myresource --isolated"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(ExitCodeConstants.InvalidCommand, exitCode); + } } diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 92fb03db4be..f3c384b7eb7 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -162,6 +162,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); From 9205c105fd4cae9b0de297188f74dd14799903b6 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Wed, 25 Feb 2026 16:18:08 +1100 Subject: [PATCH 175/256] Remove test-scenarios infrastructure (#14639) The /test-scenario workflow dispatched exploratory testing tasks to dotnet/aspire-playground via the Copilot agent. This infrastructure is being superseded by GitHub's built-in agentic workflows feature which provides similar capabilities natively. Removes: - tests/agent-scenarios/ directory (6 scenarios + README) - .github/workflows/test-scenario.yml - .github/instructions/test-scenario-prompt.instructions.md - Pattern reference in AGENTS.md Co-authored-by: Mitch Denny Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test-scenario-prompt.instructions.md | 598 ------------------ .github/workflows/test-scenario.yml | 174 ----- AGENTS.md | 1 - tests/agent-scenarios/README.md | 126 ---- tests/agent-scenarios/aspire-update/prompt.md | 369 ----------- .../deployment-docker/prompt.md | 366 ----------- tests/agent-scenarios/eshop-update/prompt.md | 522 --------------- .../smoke-test-dotnet/prompt.md | 577 ----------------- .../smoke-test-python/prompt.md | 592 ----------------- tests/agent-scenarios/starter-app/prompt.md | 3 - 10 files changed, 3328 deletions(-) delete mode 100644 .github/instructions/test-scenario-prompt.instructions.md delete mode 100644 .github/workflows/test-scenario.yml delete mode 100644 tests/agent-scenarios/README.md delete mode 100644 tests/agent-scenarios/aspire-update/prompt.md delete mode 100644 tests/agent-scenarios/deployment-docker/prompt.md delete mode 100644 tests/agent-scenarios/eshop-update/prompt.md delete mode 100644 tests/agent-scenarios/smoke-test-dotnet/prompt.md delete mode 100644 tests/agent-scenarios/smoke-test-python/prompt.md delete mode 100644 tests/agent-scenarios/starter-app/prompt.md diff --git a/.github/instructions/test-scenario-prompt.instructions.md b/.github/instructions/test-scenario-prompt.instructions.md deleted file mode 100644 index 3ee688a33fa..00000000000 --- a/.github/instructions/test-scenario-prompt.instructions.md +++ /dev/null @@ -1,598 +0,0 @@ ---- -applyTo: "tests/agent-scenarios/**/prompt.md" ---- - -# Test Scenario Prompt Instructions - -This document provides comprehensive guidelines for authoring `prompt.md` files used with the `/test-scenario` workflow command in the Aspire repository. - -## Purpose - -Test scenario `prompt.md` files define automated exploratory testing scenarios that are executed by GitHub Copilot agents in the `dotnet/aspire-playground` repository. These scenarios validate that changes to Aspire work correctly in realistic development workflows, catching integration issues that unit tests might miss. - -## How Test Scenarios Work - -When a developer or reviewer comments `/test-scenario scenario-name` on a pull request: - -1. The `test-scenario.yml` workflow validates the scenario name -2. Looks for `tests/agent-scenarios/scenario-name/prompt.md` -3. Reads the prompt content from the file -4. Creates an issue in the `aspire-playground` repository with: - - The prompt content as instructions - - Context from the source PR (PR number, URL, repository) - - Assignment to the `copilot-swe-agent` -5. The agent executes the scenario in the playground repository -6. Results are tracked in the created issue and may include a PR with the test application - -This provides end-to-end validation that the changes in the PR work correctly in a realistic development environment. - -## Directory Structure - -Test scenarios are located in `tests/agent-scenarios/` with the following structure: - -```text -tests/agent-scenarios/ -├── README.md # Documentation for all scenarios -├── scenario-name/ -│ └── prompt.md # The scenario prompt -├── another-scenario/ -│ └── prompt.md -└── ... -``` - -### Scenario Name Requirements - -Scenario names must follow strict formatting rules to work with the workflow: - -- **Must be lowercase** -- **Can contain alphanumeric characters (a-z, 0-9)** -- **Can use single hyphens (-) as word separators** -- **No consecutive hyphens** -- **No leading or trailing hyphens** - -**Valid examples:** -- `redis-cache` -- `postgres-db` -- `azure-storage` -- `cli-new-command` - -**Invalid examples:** -- `RedisCache` (uppercase) -- `redis_cache` (underscore) -- `redis--cache` (consecutive hyphens) -- `-redis-cache` (leading hyphen) -- `redis-cache-` (trailing hyphen) - -## Prompt.md File Format - -### Basic Structure - -A `prompt.md` file should be written as clear, actionable instructions for an AI agent. The format can vary based on the complexity of the scenario: - -**Simple format (for straightforward scenarios):** - -```markdown -# Scenario Title - -Brief description of what this scenario tests. - -Detailed instructions for the agent, written in imperative mood: -1. Step one with specific commands -2. Step two with expected outcomes -3. Step three with verification steps -``` - -**Comprehensive format (for complex scenarios):** - -```markdown -# Scenario Title - -## Overview - -Brief description of what this scenario validates and why it's important. - -This smoke test validates that: -1. Key capability one -2. Key capability two -3. Key capability three - -## Prerequisites - -List any prerequisites or environment requirements: -- Docker installed and running (if needed) -- Python 3.11 or later (if needed) -- Network access to download packages - -**Note**: The .NET SDK is not required as a prerequisite - the Aspire CLI will install it automatically. - -## Step 1: First Major Task - -### 1.1 Subtask - -Detailed instructions with commands: - -\```bash -# Command to execute -aspire --version -\``` - -**Expected outcome:** -- What should happen -- What to verify - -### 1.2 Another Subtask - -Continue with substeps... - -## Step 2: Next Major Task - -...continue with additional steps... - -## Success Criteria - -The scenario is considered **PASSED** if: -1. Criterion one -2. Criterion two -3. Criterion three - -The scenario is considered **FAILED** if: -- Failure condition one -- Failure condition two - -## Troubleshooting Tips - -If issues occur during the scenario: - -### Issue Type -- Diagnostic steps -- Possible solutions - -## Notes for Agent Execution - -Special instructions for the AI agent: -1. Capture screenshots at key points -2. Save detailed logs -3. Timing considerations -``` - -### Key Principles for Writing Prompts - -1. **Be Explicit**: Don't assume the agent knows context. Explain what needs to be done and why. - -2. **Use Imperative Commands**: Write instructions as direct commands rather than suggestions. - - ✅ "Run `aspire new` to create a new application" - - ❌ "You might want to create a new application" - -3. **Include Expected Outcomes**: After each command or step, describe what should happen. - -4. **Provide Verification Steps**: Include steps to verify that each action succeeded. - -5. **Reference Existing Knowledge**: For complex operations, refer to existing documentation or patterns: - ```markdown - Follow the CLI acquisition instructions already provided in the aspire-playground - repository to obtain the native AOT build of the CLI for this PR. - ``` - -6. **Include Success/Failure Criteria**: Clearly define what constitutes a passing or failing scenario. - -7. **Use Code Blocks**: Format all commands, code, and file paths in appropriate code blocks with language identifiers. - -8. **Capture Evidence**: Instruct the agent to take screenshots, save logs, or capture other evidence. - -9. **Consider Prerequisites**: Explicitly state what tools or dependencies are needed. - -10. **Think End-to-End**: Scenarios should test realistic workflows from start to finish. - -## Example Scenarios - -### Simple Scenario Example: Starter App - -```markdown -# Starter App Scenario - -Create an aspire application starting by downloading the Aspire CLI and creating a starter app. -``` - -This simple prompt works because: -- The aspire-playground repository has established patterns for CLI acquisition -- Creating a starter app is a well-known workflow -- The agent has context from existing documentation - -### Complex Scenario Example: Smoke Test - -See `tests/agent-scenarios/smoke-test-dotnet/prompt.md` for a comprehensive example that includes: -- Detailed step-by-step instructions -- Multiple verification points -- Screenshot capture requirements -- Success/failure criteria -- Troubleshooting guidance -- Notes for agent execution - -This complex prompt is appropriate when: -- Testing multiple integrated features -- Validating critical workflows -- Requiring specific evidence capture -- Testing new or complex functionality - -## When to Create Test Scenarios - -### Agent Behavior: Never Create Automatically - -**CRITICAL**: The coding agent should **NEVER** automatically create test scenarios or `prompt.md` files unless the developer explicitly requests it. - -Creating scenarios requires understanding: -- What aspects of the feature need integration testing -- What realistic workflows should be validated -- What prerequisites and setup are required -- How to verify success meaningfully - -Only developers can make these decisions. The agent may suggest creating a scenario, but should never create one without explicit approval. - -### When to Suggest Creating Scenarios - -The coding agent **SHOULD** add a comment to the PR suggesting the developer consider creating a test scenario in these situations: - -#### 1. Adding Major New Hosting Integrations - -When a PR adds a new hosting integration package (e.g., `Aspire.Hosting.NewTechnology`), especially: - -- **Local-only integrations** that don't require cloud deployments: - - Databases (Redis, PostgreSQL, MongoDB, SQL Server) - - Message queues (RabbitMQ, Kafka) - - Caches and search engines (Elasticsearch, Meilisearch) - - Container-based services - -- **Integrations with emulator support**: - - Azure services with local emulators - - AWS services with LocalStack support - -**Why**: These can be easily tested in the playground environment without requiring cloud credentials or subscriptions. - -**Example suggestion comment**: -```markdown -💡 **Test Scenario Suggestion** - -This PR adds a new hosting integration for [Technology]. Consider creating a test -scenario to validate the end-to-end developer experience: - -- Create a scenario directory: `tests/agent-scenarios/[technology]-integration/` -- Add a `prompt.md` file that tests: - - Installing the Aspire CLI from this PR build - - Creating a new Aspire app - - Adding the [Technology] resource to the AppHost - - Running the application and verifying the resource works correctly - - Checking the Dashboard shows the resource properly - -Example: See `tests/agent-scenarios/smoke-test-dotnet/` for a comprehensive template. - -To test the scenario, comment `/test-scenario [technology]-integration` on this PR. -``` - -#### 2. Adding Major New Client Integrations - -When a PR adds a new client component package (e.g., `Aspire.NewTechnology.Client`), especially: - -- **Integrations for local services**: - - Database clients (Npgsql, MySqlConnector, StackExchange.Redis) - - Messaging clients (RabbitMQ.Client, Confluent.Kafka) - - Storage clients that work locally - -- **Integrations with significant new APIs**: - - New connection patterns - - New configuration models - - New health check or telemetry features - -**Why**: Client integrations are the primary way developers interact with Aspire components. Testing them in realistic scenarios catches configuration issues, DI problems, and integration bugs. - -**Example suggestion comment**: -```markdown -💡 **Test Scenario Suggestion** - -This PR adds a new client integration for [Technology]. Consider creating a test -scenario to validate the developer experience: - -- Create a scenario directory: `tests/agent-scenarios/[technology]-client/` -- Add a `prompt.md` file that tests: - - Creating an Aspire app with the [Technology] hosting and client packages - - Configuring the client in a service project - - Making actual calls to the [Technology] service - - Verifying telemetry, health checks, and logging work correctly - -Example: See `tests/agent-scenarios/smoke-test-dotnet/` for patterns on testing -end-to-end connectivity. - -To test the scenario, comment `/test-scenario [technology]-client` on this PR. -``` - -#### 3. Adding New Commands to Aspire CLI - -When a PR adds a new command or significant functionality to the Aspire CLI (`src/Aspire.Cli/`): - -- **New top-level commands**: `aspire newcommand` -- **New subcommands**: `aspire existing newsubcommand` -- **Significant changes to existing commands**: New options, changed behavior, new workflows - -**Why**: CLI commands are the entry point for developers. Testing them in realistic scenarios ensures they work correctly with actual projects, handle errors gracefully, and provide good user experience. - -**Example suggestion comment**: -```markdown -💡 **Test Scenario Suggestion** - -This PR adds/modifies the `aspire [command]` CLI command. Consider creating a test -scenario to validate the command works correctly: - -- Create a scenario directory: `tests/agent-scenarios/cli-[command]/` -- Add a `prompt.md` file that tests: - - Acquiring the Aspire CLI from this PR build - - Running `aspire [command]` in various contexts - - Verifying expected outputs and behaviors - - Testing error handling and edge cases - -Example: See `tests/agent-scenarios/smoke-test-dotnet/` for CLI testing patterns. - -To test the scenario, comment `/test-scenario cli-[command]` on this PR. -``` - -#### 4. Other Scenarios to Consider - -The agent should also suggest test scenarios for: - -- **New project templates**: When adding or modifying Aspire project templates - - Create scenarios that test creating and running projects from the template - - Verify all template options work correctly - -- **Dashboard features**: When adding significant new Dashboard capabilities - - Create scenarios that exercise the new UI features - - Include screenshot capture of the new functionality - -- **Breaking changes to developer-facing APIs**: When making changes that affect how developers use Aspire - - Create scenarios that validate the new API patterns work correctly - - Test migration paths from old to new APIs - -- **Service discovery changes**: When modifying how services discover and connect to each other - - Create scenarios with multiple services communicating - - Verify connections work across different patterns - -- **Deployment/publishing changes**: When modifying how Aspire apps are deployed - - Create scenarios that test the full deployment workflow - - Verify generated artifacts are correct - -### When NOT to Suggest Scenarios - -The agent should **NOT** suggest test scenarios for: - -- **Minor bug fixes**: Small corrections that don't change behavior significantly -- **Internal refactoring**: Changes to internal implementation that don't affect public APIs -- **Documentation updates**: Changes only to markdown files, comments, or docs -- **Test code changes**: Modifications only to test projects -- **Build/CI changes**: Changes to build scripts or workflows -- **Cloud-only services**: Integrations that require paid cloud subscriptions - - Azure services without emulators - - AWS services without LocalStack support - - Third-party SaaS services requiring accounts - -## Commenting Format for Suggestions - -When suggesting a test scenario, use this format: - -```markdown -💡 **Test Scenario Suggestion** - -[One paragraph explaining what was added/changed and why a scenario would be valuable] - -**Suggested scenario**: `tests/agent-scenarios/[scenario-name]/` - -**What to test**: -- [Key testing point 1] -- [Key testing point 2] -- [Key testing point 3] - -**Reference**: See `tests/agent-scenarios/[similar-scenario]/` for a template. - -**To test**: Comment `/test-scenario [scenario-name]` on this PR. -``` - -Keep suggestions concise, actionable, and helpful. The goal is to remind developers, not to be prescriptive. - -## Best Practices - -### DO - -✅ **Write prompts from the agent's perspective**: Assume the agent is starting fresh with only the repository context. - -✅ **Break complex scenarios into steps**: Use numbered steps and clear section headers. - -✅ **Include verification at each step**: Don't just run commands — verify they worked. - -✅ **Specify exact commands**: Use code blocks with the exact commands to run. - -✅ **Define success criteria**: Be explicit about what constitutes passing vs. failing. - -✅ **Reference existing patterns**: Point to existing documentation or workflows when applicable. - -✅ **Test realistic workflows**: Scenarios should mirror how real developers would use the feature. - -✅ **Capture evidence**: Request screenshots, logs, or other artifacts that prove the scenario worked. - -✅ **Consider the environment**: Remember that scenarios run in the aspire-playground repo, which has: -- Linux environment (Ubuntu) -- Docker available -- Common development tools (git, curl, etc.) -- Browser automation tools (playwright) - -### DON'T - -❌ **Don't be vague**: Avoid instructions like "test the feature" without specifics. - -❌ **Don't assume context**: Don't assume the agent knows about PRs, issues, or features being tested. - -❌ **Don't skip verification**: Every action should have a verification step. - -❌ **Don't make scenarios too narrow**: Scenarios should test meaningful workflows, not single function calls. - -❌ **Don't require manual intervention**: Scenarios must be fully automatable. - -❌ **Don't test cloud-only services**: Avoid scenarios requiring paid subscriptions or cloud credentials. - -❌ **Don't duplicate unit test coverage**: Scenarios are for integration testing, not unit testing. - -❌ **Don't create scenarios without request**: Never automatically create scenarios—only suggest them. - -## Testing Your Scenario - -After creating a scenario, test it by: - -1. **Commit the prompt.md file** to your PR branch -2. **Comment on the PR**: `/test-scenario your-scenario-name` -3. **Monitor the workflow**: Check the workflow run in the Actions tab -4. **Review the created issue**: Follow the link to see the agent's work in aspire-playground -5. **Iterate if needed**: Update the prompt based on results and test again - -## Common Patterns - -### Pattern: CLI Installation - -Most scenarios need to install the Aspire CLI from the PR build: - -```markdown -## Step 1: Install the Aspire CLI from the PR Build - -The aspire-playground repository includes comprehensive instructions for acquiring -different versions of the CLI, including PR builds. - -**Follow the CLI acquisition instructions already provided in the aspire-playground -repository to obtain the native AOT build of the CLI for this PR.** - -Once acquired, verify the CLI is installed correctly: - -\```bash -aspire --version -\``` - -Expected output should show the version matching the PR build. -``` - -### Pattern: Creating an Application - -Standard pattern for creating a new Aspire application: - -```markdown -## Step 2: Create a New Aspire Application - -Use `aspire new` to create a new application: - -\```bash -aspire new -\``` - -Follow the interactive prompts to select the desired template and options. - -Verify the project structure: - -\```bash -ls -la -\``` - -Expected structure: -- `AppName.sln` - Solution file -- `AppName.AppHost/` - The Aspire AppHost project -- Additional project directories based on template selection -``` - -### Pattern: Running and Verifying - -Standard pattern for running and verifying an application: - -```markdown -## Step 3: Run the Application - -Start the application: - -\```bash -aspire run -\``` - -Wait for startup (30-60 seconds) and note the Dashboard URL from the output. - -### 3.1 Verify the Dashboard - -Navigate to the Dashboard using the URL from the output: - -\```bash -playwright-browser navigate $DASHBOARD_URL -\``` - -**Wait for resources to stabilize**: Allow 10-30 seconds for resources to reach either "Running" or "Failed" state before capturing screenshots. - -\```bash -# Wait for resources to be ready -sleep 15 - -# Take initial screenshot -playwright-browser take_screenshot --filename dashboard-initial.png -\``` - -**If resources take longer than expected** (>60 seconds): Take intermediate screenshots to capture the progression: - -\```bash -# After 30 seconds -playwright-browser take_screenshot --filename dashboard-30s.png - -# After 60 seconds -playwright-browser take_screenshot --filename dashboard-60s.png -\``` - -**If any resources fail**: Expand error details before taking screenshots to capture useful diagnostic information: - -\```bash -# Click on failed resource to expand error details -playwright-browser click --element "failed resource row" --ref "[appropriate selector]" - -# Take screenshot showing expanded error details -playwright-browser take_screenshot --filename dashboard-failure-details.png -\``` - -Expected: Dashboard loads successfully and screenshot shows all resources in "Running" state, or clear error details if any resource failed. -``` - -### Pattern: Capturing Screenshots - -For UI validation: - -```markdown -## Step 4: Capture Visual Evidence - -Take screenshots of key interfaces: - -\```bash -# Dashboard overview -playwright-browser navigate http://localhost:XXXXX -playwright-browser take_screenshot --filename dashboard-main.png - -# Web application -playwright-browser navigate http://localhost:YYYYY -playwright-browser take_screenshot --filename web-app.png -\``` - -Verify screenshots show: -- Dashboard with all resources in "Running" state -- Web application displaying correctly -``` - -## Version History - -- **v1.0** (2025-10): Initial guidelines for test scenario prompts - -## Related Documentation - -- `tests/agent-scenarios/README.md` - Overview of all scenarios -- `.github/workflows/test-scenario.yml` - The workflow that executes scenarios -- Existing scenarios in `tests/agent-scenarios/*/prompt.md` - Examples to reference - -## Questions or Issues - -If you have questions about creating test scenarios or suggestions for improving these guidelines, please: - -1. Open an issue in the dotnet/aspire repository -2. Tag it with the `area-testing` label -3. Reference these instructions in your issue diff --git a/.github/workflows/test-scenario.yml b/.github/workflows/test-scenario.yml deleted file mode 100644 index 884941f2c39..00000000000 --- a/.github/workflows/test-scenario.yml +++ /dev/null @@ -1,174 +0,0 @@ -name: Test Scenario Workflow - -on: - issue_comment: - types: [created] - -permissions: - contents: read - pull-requests: write - -jobs: - test-scenario: - # Only run when the comment starts with /test-scenario on a PR - if: >- - ${{ - startsWith(github.event.comment.body, '/test-scenario') && - github.event.issue.pull_request - }} - runs-on: ubuntu-latest - env: - REPO_OWNER: dotnet - REPO_NAME: aspire-playground - GH_CLI_VERSION: 2.81.0 - GH_PLAYGROUND_TOKEN: ${{ secrets.GH_PLAYGROUND_TOKEN }} - steps: - - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Parse and validate scenario name - id: parse_scenario - env: - COMMENT_BODY: ${{ github.event.comment.body }} - run: | - echo "Comment body: $COMMENT_BODY" - - # Extract scenario name from comment - SCENARIO_NAME=$(echo "$COMMENT_BODY" | \ - grep -oP '^/test-scenario\s+\K[a-z0-9]+(-[a-z0-9]+)*[a-z0-9]$' | head -1) - - if [ -z "$SCENARIO_NAME" ]; then - echo "Error: Invalid or missing scenario name" - echo "Expected format: /test-scenario scenario-name" - echo "Scenario name must be lowercase alphanumeric with hyphens" - exit 1 - fi - - echo "Scenario name: $SCENARIO_NAME" - echo "scenario_name=$SCENARIO_NAME" >> $GITHUB_OUTPUT - - - name: Check for prompt file - id: check_prompt - run: | - SCENARIO_NAME="${{ steps.parse_scenario.outputs.scenario_name }}" - PROMPT_FILE="tests/agent-scenarios/${SCENARIO_NAME}/prompt.md" - - if [ ! -f "$PROMPT_FILE" ]; then - echo "Error: Prompt file not found at $PROMPT_FILE" - exit 1 - fi - - echo "Found prompt file: $PROMPT_FILE" - echo "prompt_file=$PROMPT_FILE" >> $GITHUB_OUTPUT - - - name: Download and install GitHub CLI - run: | - CURRENT_VERSION="" - if command -v gh &> /dev/null; then - CURRENT_VERSION=$(gh --version | \ - grep -oP 'gh version \K[0-9]+\.[0-9]+\.[0-9]+' | head -1) - echo "Current GitHub CLI version: $CURRENT_VERSION" - fi - - if [ "$CURRENT_VERSION" = "$GH_CLI_VERSION" ]; then - echo "GitHub CLI v${GH_CLI_VERSION} already installed" - else - echo "Downloading GitHub CLI v${GH_CLI_VERSION}..." - DOWNLOAD_URL="https://github.com/cli/cli/releases/download/v${GH_CLI_VERSION}" - ARCHIVE_NAME="gh_${GH_CLI_VERSION}_linux_amd64.tar.gz" - curl -fsSL "${DOWNLOAD_URL}/${ARCHIVE_NAME}" -o gh.tar.gz - tar -xzf gh.tar.gz - sudo mv "gh_${GH_CLI_VERSION}_linux_amd64/bin/gh" /usr/local/bin/ - rm -rf gh.tar.gz "gh_${GH_CLI_VERSION}_linux_amd64" - - echo "Verifying GitHub CLI installation..." - gh --version - fi - - - name: Create issue and assign to copilot - id: create_issue - run: | - echo "Creating issue in aspire-playground repository..." - PROMPT_FILE="${{ steps.check_prompt.outputs.prompt_file }}" - SCENARIO_NAME="${{ steps.parse_scenario.outputs.scenario_name }}" - SOURCE_PR_URL="${{ github.event.issue.html_url }}" - SOURCE_PR_NUMBER="${{ github.event.issue.number }}" - SOURCE_REPO="${{ github.repository }}" - - # Auth using the token - gh auth login --with-token <<< "$GH_PLAYGROUND_TOKEN" - - # Build the issue body with context from the source PR and the prompt - ISSUE_TITLE="Test Scenario: ${SCENARIO_NAME}" - - # Read prompt content first - PROMPT_CONTENT=$(cat "$PROMPT_FILE") - - # Build issue body using printf with proper format strings to avoid injection - printf -v ISSUE_BODY '%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s' \ - "## Test Scenario Request" \ - "" \ - "**Scenario:** ${SCENARIO_NAME}" \ - "**Source PR:** ${SOURCE_PR_URL}" \ - "**Source Repository:** ${SOURCE_REPO}" \ - "" \ - "---" \ - "" \ - "$PROMPT_CONTENT" \ - "" \ - "---" \ - "" \ - "This issue was created automatically from PR #${SOURCE_PR_NUMBER} in ${SOURCE_REPO}." - - # Create the issue and assign to copilot - echo "Creating issue with title: $ISSUE_TITLE" - ISSUE_OUTPUT=$(gh issue create \ - --repo "${REPO_OWNER}/${REPO_NAME}" \ - --title "$ISSUE_TITLE" \ - --body "$ISSUE_BODY" \ - --assignee "copilot-swe-agent" \ - 2>&1) - - echo "Issue creation output:" - echo "$ISSUE_OUTPUT" - - # Extract the issue URL from the output - ISSUE_URL=$(echo "$ISSUE_OUTPUT" | \ - grep -oP 'https://github.com/[^/]+/[^/]+/issues/\d+' | head -1) - - if [ -z "$ISSUE_URL" ]; then - echo "Error: Could not extract issue URL from output" - exit 1 - fi - - echo "Successfully created issue: $ISSUE_URL" - echo "issue_url=$ISSUE_URL" >> $GITHUB_OUTPUT - - # Extract issue number for later use - ISSUE_NUMBER=$(echo "$ISSUE_URL" | grep -oP '/issues/\K\d+') - echo "issue_number=$ISSUE_NUMBER" >> $GITHUB_OUTPUT - - - name: Comment on PR with issue link - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const issueUrl = '${{ steps.create_issue.outputs.issue_url }}'; - const scenarioName = '${{ steps.parse_scenario.outputs.scenario_name }}'; - - const comment = `🤖 **AI Agent Task Created** - - Scenario: **${scenarioName}** - - An AI agent has been assigned to execute this scenario. - - 📝 **Issue:** ${issueUrl} - - Please navigate to the issue for more details and to track progress.`; - - await github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: comment - }); diff --git a/AGENTS.md b/AGENTS.md index cb4d5711eb1..f1b83eb0c88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -366,4 +366,3 @@ Additional instructions are automatically applied when editing files matching sp | `src/Aspire.Hosting*/README.md` | `.github/instructions/hosting-readme.instructions.md` - Hosting integration READMEs | | `src/Components/**/README.md` | `.github/instructions/client-readme.instructions.md` - Client integration READMEs | | `tools/QuarantineTools/*` | `.github/instructions/quarantine.instructions.md` - QuarantineTools usage | -| `tests/agent-scenarios/**/prompt.md` | `.github/instructions/test-scenario-prompt.instructions.md` - Test scenario prompts | diff --git a/tests/agent-scenarios/README.md b/tests/agent-scenarios/README.md deleted file mode 100644 index 39e13b075c0..00000000000 --- a/tests/agent-scenarios/README.md +++ /dev/null @@ -1,126 +0,0 @@ -# Agent Scenarios - -This directory contains scenario definitions for the `/test-scenario` workflow command. - -## Structure - -Each scenario is a subdirectory with a `prompt.md` file: - -```text -agent-scenarios/ -├── scenario-name/ -│ └── prompt.md -└── another-scenario/ - └── prompt.md -``` - -## Scenario Name Requirements - -- Must be lowercase -- Can contain alphanumeric characters (a-z, 0-9) -- Can use single hyphens (-) as word separators -- No consecutive hyphens -- No leading or trailing hyphens - -**Valid examples:** -- `starter-app` -- `redis-cache` -- `postgres-db` -- `azure-deployment` - -**Invalid examples:** -- `StarterApp` (uppercase) -- `starter_app` (underscore) -- `starter--app` (consecutive hyphens) -- `-starter-app` (leading hyphen) -- `starter-app-` (trailing hyphen) - -## Usage - -To trigger an agent scenario on a pull request, comment: - -```bash -/test-scenario scenario-name -``` - -For example: - -```bash -/test-scenario starter-app -``` - -The workflow will: -1. Validate the scenario name format -2. Look for `tests/agent-scenarios/scenario-name/prompt.md` -3. Read the prompt from the file -4. Create an issue in the `aspire-playground` repository with the prompt and PR context -5. Assign the issue to the GitHub Copilot agent -6. Wait for the agent to create a PR (up to 5 minutes) -7. Post a comment with links to both the issue and the agent's PR (if available) - -## Creating a New Scenario - -1. Create a new directory under `tests/agent-scenarios/` with a valid scenario name -2. Add a `prompt.md` file with the prompt text for the agent -3. Commit the changes -4. Test by commenting `/test-scenario your-scenario-name` on a PR - -## Example Scenarios - -### starter-app - -Creates a basic Aspire starter application. - -**Prompt:** Create an aspire application starting by downloading the Aspire CLI and creating a starter app. - -### smoke-test-dotnet - -Performs a comprehensive smoke test of an Aspire PR build by installing the Aspire CLI, creating a .NET Blazor-based starter application, and verifying its functionality including the Dashboard, API service, and frontend. - -**Key features:** -- Tests the native AOT build of the Aspire CLI -- Creates and runs an Aspire starter app with Blazor frontend -- Verifies Dashboard functionality and telemetry collection -- Tests SDK install feature flag (`dotNetSdkInstallationEnabled`) -- Captures screenshots for verification - -### smoke-test-python - -Performs a comprehensive smoke test of an Aspire PR build by installing the Aspire CLI, creating a Python starter application with Vite/React frontend, and verifying its functionality. - -**Key features:** -- Tests the native AOT build of the Aspire CLI -- Creates and runs an Aspire Python starter app (`aspire-py-starter`) -- Tests Python backend API service and Vite/React frontend -- Verifies Dashboard functionality and telemetry collection -- Tests SDK install feature flag (`dotNetSdkInstallationEnabled`) -- Tests hot reload for both Python and Vite -- Captures screenshots for verification - -### deployment-docker - -Tests the end-to-end workflow of creating an Aspire application, adding Docker Compose integration, and deploying it using Docker Compose. - -**Key features:** -- Creates a new Aspire starter application -- Adds Docker Compose integration using `aspire add` command -- Updates AppHost to configure Docker Compose environment -- Generates Docker Compose files using `aspire publish` -- Deploys the application with `docker compose up` -- Verifies all service endpoints are accessible -- Tests service-to-service communication -- Cleans up deployment with `docker compose down` - -### eshop-update - -Tests the Aspire CLI's update functionality on the dotnet/eshop repository, validating that PR builds can successfully update real-world Aspire applications. - -**Key features:** -- Downloads and integrates the dotnet/eshop repository -- Tests `aspire update` command on a complex, multi-service application -- Validates package version updates from PR builds -- Launches the updated application with `aspire run` -- Identifies and fixes simple package dependency issues -- Enumerates all packages requiring manual intervention -- Verifies Dashboard functionality and service health for multiple services -- Tests update logic on production-like application architecture diff --git a/tests/agent-scenarios/aspire-update/prompt.md b/tests/agent-scenarios/aspire-update/prompt.md deleted file mode 100644 index 01646174044..00000000000 --- a/tests/agent-scenarios/aspire-update/prompt.md +++ /dev/null @@ -1,369 +0,0 @@ -# Aspire Update Scenario - -This scenario tests the `aspire update` command functionality, including updating projects across different CLI versions and testing the new CLI self-update prompting feature introduced in this PR. - -## Overview - -This test validates that: -1. The latest released version of the Aspire CLI can be acquired and used to create a new project -2. A new starter application can be created and runs successfully -3. The latest daily build of the Aspire CLI can be acquired and used to update the project, and the updated apphost can be run successfully -4. The PR build of the Aspire CLI can be acquired -5. The `aspire update` command correctly prompts to update the CLI when a newer CLI version is available -6. The CLI can be updated to the daily build through the update prompt -7. The dashboard correctly shows the version from the PR build even when using the daily CLI - -## Prerequisites - -Before starting, ensure you have: -- Docker installed and running (for container-based resources if used) -- Network access to download NuGet packages and CLI builds -- Browser automation tools available (playwright) for verification - -**Note**: The .NET SDK is not required as a prerequisite - the Aspire CLI will install it automatically. - -## Step 1: Download the Latest Released Version of the CLI - -Acquire the latest stable release version of the Aspire CLI. - -**Follow the CLI acquisition instructions already provided in the aspire-playground repository to obtain the latest released version of the Aspire CLI (native AOT build).** - -Once acquired, verify the CLI is installed correctly: - -```bash -aspire --version -``` - -Expected output should show the latest released version number (e.g., `9.5.2` or similar - released versions do not have `-preview` or `-pr` suffixes, though other metadata may be present). - -**Note the version number for comparison in later steps.** - -## Step 2: Create a New Starter Project - -Create a new Aspire starter application using the released CLI version. - -### 2.1 Run the Aspire New Command - -Use `aspire new` with interactive template selection. Choose any template randomly - for this test, we'll use whichever template the agent selects. - -```bash -aspire new -``` - -**Follow the interactive prompts:** -1. Select any starter template (e.g., `aspire-starter` or `aspire-py-starter`) -2. Provide a name for the application (suggestion: `AspireUpdateTest`) -3. Accept default options for framework, frontend, etc. - -### 2.2 Verify Project Creation - -After creation, verify the project structure exists: - -```bash -ls -la -``` - -Expected: Project files and directories should be created successfully. - -## Step 3: Run the AppHost to Verify It Works - -Launch the application to verify it works with the released CLI version. - -### 3.1 Start the Application - -```bash -aspire run -``` - -Wait for the application to start (30-60 seconds). Observe the console output for: -- Dashboard URL with access token -- All resources showing as "Running" -- No critical errors - -### 3.2 Verify Dashboard Access - -Navigate to the dashboard URL (from console output) and perform a minimal check: -- Dashboard loads successfully -- Resources are listed and showing as running - -**Take a screenshot of the dashboard showing resources:** - -```bash -playwright-browser navigate $DASHBOARD_URL -playwright-browser take_screenshot --filename dashboard-released-version.png -``` - -**Note:** This is a minimal verification - we just want to confirm the dashboard launches and displays running resources. Detailed resource checks are not needed here. - -### 3.3 Stop the Application - -Press `Ctrl+C` to stop the application and verify it shuts down cleanly. - -## Step 4: Download the Latest Daily Build of the Aspire CLI - -Acquire the latest daily build version of the Aspire CLI. - -**Follow the CLI acquisition instructions already provided in the aspire-playground repository to obtain the latest daily build of the Aspire CLI (native AOT build).** - -This will replace the released version installed in Step 1. - -## Step 5: Check the Version Number to Verify Installation - -Verify the daily build is now installed: - -```bash -aspire --version -``` - -Expected output should show the daily build version number (e.g., `13.0.0-preview.1.xxxxx` or similar). - -**The version should be different from (and typically newer than) the released version noted in Step 1.** - -## Step 6: Use `aspire update` to Update the AppHost Project - -Update the project to use packages from the daily channel. - -### 6.1 Run aspire update - -```bash -aspire update -``` - -**Follow the interactive prompts:** -1. When prompted to select a channel, choose the **daily** channel -2. Confirm the updates when prompted - -**Observe the update process:** -- Package references being analyzed -- Updates being applied -- NuGet.config being updated -- Successful completion message - -**Note:** Since the project was originally created with the released version and we're now using the daily CLI, the update should find packages to update. - -## Step 7: Run the AppHost to Verify It Worked - -Launch the application again with the updated packages. - -### 7.1 Start the Application - -```bash -aspire run -``` - -Wait for startup and verify all resources are running. - -### 7.2 Navigate to Dashboard Help Menu - -Access the dashboard and check the version information. - -```bash -# Navigate to dashboard -playwright-browser navigate $DASHBOARD_URL - -# Navigate to the Help menu (typically in the top-right) -# Look for version information in the Help menu or About dialog -``` - -**Take a screenshot showing the dashboard version:** - -```bash -playwright-browser take_screenshot --filename dashboard-daily-version.png -``` - -**Verify:** The dashboard version should reflect the daily build packages (matching the CLI version from Step 5). - -### 7.3 Stop the Application - -Press `Ctrl+C` to stop the application. - -## Step 8: Download the PR Build of the CLI - -Acquire the CLI build from this PR. - -**Follow the CLI acquisition instructions already provided in the aspire-playground repository to obtain the PR build of the Aspire CLI (native AOT build).** - -This will replace the daily build installed in Step 4. - -## Step 9: Run `aspire --version` to Verify the Version Number - -Verify the PR build is now installed: - -```bash -aspire --version -``` - -Expected output should show the PR build version number (e.g., `13.0.0-pr.12395.xxxxx` or similar). - -**The version should be different from both the released and daily versions noted earlier.** - -## Step 10: Do `aspire update` to Update the AppHost - -Run the update command again with the PR build CLI. - -### 10.1 Run aspire update - -```bash -aspire update -``` - -**Expected behavior:** -- The project should be detected as up-to-date (no package updates needed since we just updated to daily) -- A prompt should appear: **"An update is available for the Aspire CLI. Would you like to update it now?"** - -**This is the NEW functionality being tested - the CLI detects that a newer version (daily) is available compared to the current PR build and prompts to update the CLI itself.** - -### 10.2 Respond Yes to the Prompt - -When prompted to update the CLI, answer **yes** (or `y`). - -**Observe the CLI self-update process:** -- Current CLI location displayed -- Quality level prompt (select "daily") -- Download progress -- Extraction and installation -- Backup of current CLI -- Success message with new version - -## Step 11: Check `aspire --version` - Should Be Back at Daily Build - -Verify the CLI has been updated back to the daily build: - -```bash -aspire --version -``` - -**Expected output:** The version should now match the daily build version from Step 5 (not the PR build from Step 9). - -**This confirms the CLI self-update functionality worked correctly.** - -## Step 12: Run `aspire run` with Daily CLI, Dashboard Shows PR Build - -Launch the application one more time to verify an important behavior. - -### 12.1 Start the Application - -```bash -aspire run -``` - -Wait for startup and verify all resources are running. - -### 12.2 Check Dashboard Version in Help Menu - -Access the dashboard and check the version information. - -```bash -playwright-browser navigate $DASHBOARD_URL -``` - -Navigate to the Help menu or About section and look for version information. - -**Take a screenshot:** - -```bash -playwright-browser take_screenshot --filename dashboard-pr-build-version.png -``` - -**Expected behavior:** Even though we're using the daily build CLI (Step 11), the dashboard should show the version from the **PR build** packages because that's what the project's packages were last updated to. - -**This demonstrates that:** -1. The CLI version (what's used to run the project) is independent of the package versions (what the project references) -2. Downgrading the CLI doesn't downgrade the project packages -3. The dashboard version reflects the package versions, not the CLI version - -### 12.3 Stop the Application - -Press `Ctrl+C` to stop the application. - -## Step 13: Final Verification Checklist - -Confirm all test objectives were met: - -- [ ] Latest released CLI acquired and version verified -- [ ] New project created successfully with released CLI -- [ ] Application ran successfully with released CLI -- [ ] Dashboard accessible with released version -- [ ] Latest daily build CLI acquired and version verified -- [ ] Project updated to daily channel successfully via `aspire update` -- [ ] Application ran successfully with daily build CLI and updated packages -- [ ] Dashboard showed daily build version after update -- [ ] PR build CLI acquired and version verified -- [ ] `aspire update` with PR build CLI detected project was up-to-date -- [ ] **CLI self-update prompt appeared (NEW FEATURE)** -- [ ] User answered yes to CLI update prompt -- [ ] CLI self-updated back to daily build -- [ ] `aspire --version` confirmed CLI is back at daily build version -- [ ] Application ran with daily build CLI -- [ ] **Dashboard showed PR build version even with daily CLI (important behavior)** - -## Success Criteria - -The test is considered **PASSED** if: - -1. **Released CLI**: Successfully acquired and used to create a working project -2. **Daily CLI**: Successfully acquired and used to update the project to daily channel -3. **PR CLI**: Successfully acquired and detected as older than daily build -4. **Update Prompt**: The CLI correctly prompted to update itself when running `aspire update` with an older CLI version (NEW FEATURE) -5. **Self-Update**: The CLI successfully updated itself to the daily build when user confirmed -6. **Version Independence**: The dashboard correctly showed PR build package version even when running with daily build CLI - -The test is considered **FAILED** if: - -- CLI acquisition fails for any version -- Project creation fails -- Project update fails -- **Update prompt does NOT appear when expected (this is the key new feature being tested)** -- CLI self-update fails or doesn't actually update the CLI -- Dashboard version doesn't correctly reflect package versions - -## Key Testing Points for This PR - -This scenario specifically tests the NEW functionality added in this PR: - -1. **Automatic CLI Update Detection**: After a successful `aspire update` of the project, the CLI checks if a newer CLI version is available -2. **User Prompt**: The CLI prompts the user to update the CLI itself with a clear, actionable message -3. **Self-Update Integration**: Accepting the prompt triggers the `aspire update --self` functionality -4. **Version Awareness**: The CLI correctly compares its own version against available versions - -## Notes for Agent Execution - -When executing this scenario as an automated agent: - -1. **Multiple CLI Versions**: Be prepared to handle multiple CLI installations and version switches -2. **Interactive Prompts**: Pay careful attention to prompts, especially the new CLI update prompt -3. **Version Tracking**: Track and compare version numbers at each step -4. **Screenshots**: Capture dashboard screenshots showing version information -5. **Confirmation**: When the CLI update prompt appears, this is the key moment - it should happen after a successful `aspire update` when using an older CLI version -6. **Expected Flow**: Released → Daily → PR → (update prompt) → Daily -7. **Version Comparison**: The dashboard version should reflect package versions, not CLI version - -## Troubleshooting Tips - -### CLI Update Prompt Doesn't Appear - -If the update prompt doesn't appear when expected: -- Verify the PR build is actually older than the daily build -- Check that the project update completed successfully first -- Ensure the CLI downloader is available (not running as dotnet tool) -- Check console output for any error messages - -### CLI Self-Update Fails - -If the self-update fails: -- Verify network connectivity for downloads -- Check disk space for installation -- Ensure proper permissions for file operations -- Review console output for specific error messages - -### Version Confusion - -If version numbers are confusing: -- Remember: CLI version (what runs the app) ≠ Package version (what the app references) -- The dashboard shows the Aspire.Hosting package version -- The CLI version is shown by `aspire --version` -- After CLI self-update, CLI version changes but package versions remain the same - ---- - -**End of Aspire Update Scenario** diff --git a/tests/agent-scenarios/deployment-docker/prompt.md b/tests/agent-scenarios/deployment-docker/prompt.md deleted file mode 100644 index 3072540ac74..00000000000 --- a/tests/agent-scenarios/deployment-docker/prompt.md +++ /dev/null @@ -1,366 +0,0 @@ -# Deployment Docker Scenario - -This scenario tests the end-to-end workflow of creating an Aspire application, adding Docker Compose integration, and deploying it using `aspire deploy`. - -## Overview - -This test validates that: -1. The Aspire CLI from the PR build can be successfully acquired -2. A new Aspire starter application can be created -3. The Docker Compose integration can be added using `aspire add` -4. The AppHost can be updated to configure Docker Compose environment -5. The `aspire publish` command generates valid Docker Compose files -6. The `aspire deploy` command successfully deploys the application - -## Prerequisites - -Before starting, ensure you have: -- Docker installed and running -- Docker Compose CLI available (verify with `docker compose version`) -- Sufficient disk space for the Aspire CLI and application artifacts -- Network access to download NuGet packages - -**Note**: The .NET SDK is not required as a prerequisite - the Aspire CLI will install it automatically. - -## Step 1: Install the Aspire CLI from the PR Build - -The first step is to acquire the Aspire CLI from this PR build. The aspire-playground repository includes comprehensive instructions for acquiring different versions of the CLI, including PR builds. - -**Follow the CLI acquisition instructions already provided in the aspire-playground repository to obtain the native AOT build of the CLI for this PR.** - -Once acquired, verify the CLI is installed correctly: - -```bash -aspire --version -``` - -Expected output should show the version matching the PR build. - -## Step 2: Create a New Aspire Starter Application - -Create a new Aspire application using the starter template. The application will be created in the current git workspace. - -### 2.1 Run the Aspire New Command - -Use the `aspire new` command to create a starter application: - -```bash -aspire new -``` - -**Follow the interactive prompts:** -1. When prompted for a template, select the **"Aspire Starter App"** (template short name: `aspire-starter`) -2. Provide a name for the application when prompted (suggestion: `AspireDockerTest`) -3. Accept the default target framework (should be .NET 10.0) -4. Select Blazor as the frontend technology -5. Choose a test framework (suggestion: xUnit) - -### 2.2 Verify Project Structure - -After creation, verify the project structure: - -```bash -ls -la -``` - -Expected structure: -- `AspireDockerTest.sln` - Solution file -- `AspireDockerTest.AppHost/` - The Aspire AppHost project -- `AspireDockerTest.ServiceDefaults/` - Shared service defaults -- `AspireDockerTest.ApiService/` - Backend API service -- `AspireDockerTest.Web/` - Blazor frontend -- `AspireDockerTest.Tests/` - Test project - -## Step 3: Add Docker Compose Integration - -Add the Docker Compose integration package to the AppHost project using the `aspire add` command. - -### 3.1 Run the Aspire Add Command - -From the workspace directory, run: - -```bash -aspire add -``` - -**Important**: The `aspire add` command will present an interactive menu with a long list of available integrations. You will need to scroll down through the list to find the Docker Compose integration. - -**Follow the interactive prompts:** -1. The command will search for available Aspire integration packages -2. A list of integrations will be displayed -3. **Hint**: The list of integrations is long and may require you to use the down arrow key (↓) or cursor navigation to scroll through the options -4. Navigate through the list to find **"Aspire.Hosting.Docker"** or a similar name for Docker Compose integration -5. Select the Docker Compose/Docker hosting integration -6. Accept the latest version when prompted (or press Enter to accept default) - -The command should output a success message indicating that the package was added to the AppHost project. - -### 3.2 Verify Package Installation - -Verify the package was added by checking the AppHost project file: - -```bash -cat AspireDockerTest.AppHost/AspireDockerTest.AppHost.csproj -``` - -You should see a `` for `Aspire.Hosting.Docker` with the version number. - -## Step 4: Update AppHost Code - -Update the AppHost Program.cs file to configure the Docker Compose environment. - -### 4.1 View Current AppHost Code - -First, view the current AppHost code: - -```bash -cat AspireDockerTest.AppHost/Program.cs -``` - -### 4.2 Add Docker Compose Environment Configuration - -Edit the Program.cs file to add the Docker Compose environment. Add the following line before the `builder.Build()` call: - -```csharp -builder.AddDockerComposeEnvironment("compose"); -``` - -The complete Program.cs should look similar to this: - -```csharp -var builder = DistributedApplication.CreateBuilder(args); - -var apiService = builder.AddProject("apiservice"); - -builder.AddProject("webfrontend") - .WithExternalHttpEndpoints() - .WithReference(apiService); - -// Add Docker Compose environment -builder.AddDockerComposeEnvironment("compose"); - -builder.Build().Run(); -``` - -**Note**: The `AddDockerComposeEnvironment` method registers a Docker Compose environment that will be used when publishing the application. - -### 4.3 Verify the Changes - -Review the updated file to ensure the changes are correct: - -```bash -cat AspireDockerTest.AppHost/Program.cs -``` - -## Step 5: Generate Docker Compose Files with Aspire Publish - -Use the `aspire publish` command to generate Docker Compose artifacts. - -### 5.1 Run Aspire Publish - -From the workspace directory, run: - -```bash -aspire publish -o docker-compose-output -``` - -**What happens:** -- The command will restore dependencies if needed -- Build the solution -- Execute the publish step which generates Docker Compose files -- Output files will be placed in the `docker-compose-output` directory - -**Expected output:** -- Success message indicating publish completed -- Output directory contains generated files - -### 5.2 Examine Generated Files - -List the contents of the output directory: - -```bash -ls -la docker-compose-output/ -``` - -**Expected files:** -- `docker-compose.yaml` - Main Docker Compose configuration file -- Additional configuration files or scripts (may vary) - -View the generated Docker Compose file: - -```bash -cat docker-compose-output/docker-compose.yaml -``` - -**Verify the file contains:** -- Service definitions for `apiservice` and `webfrontend` -- Container image references -- Port mappings for external endpoints -- Environment variable configurations -- Network configurations - -## Step 6: Deploy with Aspire Deploy - -Use the `aspire deploy` command to deploy the application. - -### 6.1 Run Aspire Deploy - -From the workspace directory, run: - -```bash -aspire deploy -o docker-compose-output -``` - -**What happens:** -- The command executes the deployment pipeline for Docker Compose -- Reads the generated Docker Compose configuration -- Deploys the application using the Docker Compose integration -- Manages the lifecycle of containers and services - -**Expected output:** -- Success message indicating deployment completed -- Information about deployed services and their status -- No error messages - -### 6.2 Verify Deployment Status - -After deployment, check the status of the deployed application: - -```bash -# Check if containers are running (if using Docker Compose backend) -docker ps -``` - -**Expected output:** -- List of running containers for the application services -- All services should be in "Up" or "running" state -- Port mappings displayed for external endpoints - -**Observe the deployment:** -- Services were started successfully -- No errors in the deployment process -- Application is ready to accept requests - -## Step 7: Clean Up - -Stop and clean up the deployed application. - -### 7.1 Stop the Application - -Use the appropriate cleanup command based on the deployment method. Since `aspire deploy` was used, you may need to stop the containers manually: - -```bash -# If containers were started, stop them -docker ps -a | grep AspireDockerTest -docker stop $(docker ps -q --filter "name=AspireDockerTest") -docker rm $(docker ps -aq --filter "name=AspireDockerTest") -``` - -Alternatively, if Docker Compose files are in the output directory, you can use: - -```bash -cd docker-compose-output -docker compose down -``` - -**What happens:** -- Stops all running containers -- Removes containers -- Removes networks created during deployment -- Preserves volumes unless `--volumes` flag is used - -**Expected output:** -- Messages showing containers being stopped and removed -- Network removal messages -- No error messages - -### 7.2 Verify Cleanup - -Verify containers are removed: - -```bash -docker ps -a | grep AspireDockerTest -``` - -**Expected output:** -- Empty list or no containers from this application - -## Step 8: Final Verification Checklist - -Go through this final checklist to ensure all test requirements are met: - -- [ ] Aspire CLI acquired successfully from PR build -- [ ] Starter application created with all expected files -- [ ] Docker Compose integration package added via `aspire add` -- [ ] AppHost updated with `AddDockerComposeEnvironment` call -- [ ] `aspire publish` command executed successfully -- [ ] Docker Compose files generated in output directory -- [ ] `docker-compose.yaml` file contains valid service definitions -- [ ] `aspire deploy` command executed successfully -- [ ] Deployment completed without errors -- [ ] Containers are running after deployment -- [ ] Cleanup successfully stopped and removed containers - -## Success Criteria - -The test is considered **PASSED** if: - -1. **CLI Installation**: Aspire CLI from PR build acquired successfully -2. **Project Creation**: New Aspire starter application created successfully -3. **Integration Addition**: Docker Compose integration added via `aspire add` command -4. **Code Update**: AppHost updated with Docker Compose environment configuration -5. **Publishing**: `aspire publish` generates valid Docker Compose files -6. **Deployment**: `aspire deploy` successfully deploys the application -7. **Cleanup**: Cleanup commands successfully stop and remove containers - -The test is considered **FAILED** if: - -- CLI installation fails -- Project creation fails or generates incomplete structure -- `aspire add` command fails to add Docker Compose integration -- `aspire publish` fails to generate Docker Compose files -- Generated Docker Compose files are invalid or incomplete -- `aspire deploy` fails to deploy the application -- Errors occur during deployment or cleanup - -## Troubleshooting Tips - -If issues occur during the test: - -### Docker Compose Integration Not Found -- Ensure you're scrolling through the complete list in `aspire add` -- Try searching by typing "docker" when the list appears -- The integration might be named "Aspire.Hosting.Docker" or similar - -### Publish Fails -- Verify the Docker Compose environment was added to AppHost Program.cs -- Check that the package reference was added to the project file -- Ensure the solution builds successfully before publishing - -### Deploy Fails -- Verify Docker is running: `docker info` -- Check the generated docker-compose.yaml for syntax errors -- Ensure the `aspire publish` command completed successfully -- Review deployment logs for specific error messages -- Ensure required ports are not already in use - -### Services Not Accessible -- Check container status: `docker ps` -- View container logs: `docker logs [container-name]` -- Verify port mappings in docker-compose.yaml -- Check firewall settings - -## Notes for Agent Execution - -When executing this scenario as an automated agent: - -1. **Interactive Navigation**: Be prepared to navigate long lists in interactive prompts -2. **Port Detection**: Extract actual port numbers from `docker ps` output -3. **Timing**: Allow adequate time for Docker image pulls and container startup -4. **Validation**: Verify deployment completes successfully -5. **Cleanup**: Always run cleanup even if earlier steps fail -6. **Evidence**: Capture output from key commands for verification - ---- - -**End of Deployment Docker Scenario** diff --git a/tests/agent-scenarios/eshop-update/prompt.md b/tests/agent-scenarios/eshop-update/prompt.md deleted file mode 100644 index 6bda4f69404..00000000000 --- a/tests/agent-scenarios/eshop-update/prompt.md +++ /dev/null @@ -1,522 +0,0 @@ -# eShop Update Scenario - -This scenario tests the Aspire CLI's update functionality on the dotnet/eshop repository, validating that the PR build can successfully update an existing Aspire application. - -## Overview - -This test validates that: -1. .NET 9.x and .NET 10.x SDKs can be installed using the dotnet-install script -2. The Aspire CLI from the PR build can be successfully acquired -3. The dotnet/eshop repository can be downloaded and integrated into the workspace -4. The `aspire update` command can update the eshop repository to use PR build versions -5. If update succeeds, the application can be launched with `aspire run` -6. The Aspire Dashboard is accessible and all services start successfully -7. Any build errors due to package dependencies can be identified and fixed -8. All packages that required manual updating are enumerated - -## Prerequisites - -Before starting, ensure you have: -- Docker installed and running (for container-based resources) -- Sufficient disk space for the Aspire CLI, eshop repository, and application artifacts -- Network access to download NuGet packages and GitHub tarballs -- Browser automation tools available (playwright) for capturing screenshots - -## Step 1: Install .NET SDKs - -The eShop repository requires both .NET 9.x and .NET 10.x SDKs. Install them using the standard dotnet-install script. - -### 1.1 Download the dotnet-install script - -```bash -curl -sSL -o dotnet-install.sh https://dot.net/v1/dotnet-install.sh -chmod +x dotnet-install.sh -``` - -### 1.2 Install .NET 9.x SDK - -Install the latest version from the .NET 9.0 channel: - -```bash -./dotnet-install.sh --channel 9.0 --install-dir ~/.dotnet -``` - -### 1.3 Install .NET 10.x SDK - -Install the latest version from the .NET 10 channel: - -```bash -./dotnet-install.sh --channel 10.0 --install-dir ~/.dotnet -``` - -### 1.4 Configure PATH - -Ensure the installed SDKs are in your PATH: - -```bash -export DOTNET_ROOT=$HOME/.dotnet -export PATH=$DOTNET_ROOT:$PATH -``` - -### 1.5 Verify SDK Installation - -Verify both SDKs are installed correctly: - -```bash -dotnet --list-sdks -``` - -Expected output should show both .NET 9.x and .NET 10.x SDK versions. - -## Step 2: Install the Aspire CLI from the PR Build - -The first step is to acquire the Aspire CLI from this PR build. The aspire-playground repository includes comprehensive instructions for acquiring different versions of the CLI, including PR builds. - -**Follow the CLI acquisition instructions already provided in the aspire-playground repository to obtain the native AOT build of the CLI for this PR.** - -Once acquired, verify the CLI is installed correctly: - -```bash -aspire --version -``` - -Expected output should show the version matching the PR build. - -## Step 3: Download and Unpack the eShop Repository - -Download the latest version of the dotnet/eshop repository as a tarball and unpack it into the working directory. - -### 3.1 Download the eShop Tarball - -Download the tarball from GitHub: - -```bash -curl -L -o eshop.tar.gz https://github.com/dotnet/eshop/tarball/HEAD -``` - -### 3.2 Unpack the Tarball - -Extract the contents of the tarball. Note that GitHub tarballs create a top-level directory with a name like `dotnet-eshop-`: - -```bash -tar -xzf eshop.tar.gz -``` - -### 3.3 Move Files to Working Directory - -List the extracted directory to identify the exact name: - -```bash -ls -d dotnet-eshop-* -``` - -Move all files from the extracted directory to the current working directory: - -```bash -# Identify the extracted directory name -ESHOP_DIR=$(ls -d dotnet-eshop-* | head -1) - -# Move all files including hidden files to current directory -shopt -s dotglob -mv "$ESHOP_DIR"/* . -rmdir "$ESHOP_DIR" -shopt -u dotglob - -# Clean up the tarball -rm eshop.tar.gz -``` - -### 3.4 Verify eShop Files - -Verify that the eShop repository files are now in the working directory: - -```bash -ls -la -``` - -Expected files: -- `eShop.sln` or similar solution file -- `eShop.AppHost/` - The AppHost project -- Various service projects (Catalog.API, Basket.API, Ordering.API, etc.) -- `src/` directory with service implementations -- `README.md` with eShop documentation - -### 3.5 Commit the eShop Files - -Commit all the eShop files to the current branch: - -```bash -git add . -git commit -m "Add eShop repository for update testing" -``` - -**Important**: Ensure all files are committed before proceeding. The `aspire update` command may modify files, and we need a clean baseline. - -## Step 4: Run Aspire Update - -Now run the `aspire update` command to update the eShop repository to use the PR build versions of Aspire packages. - -### 4.1 Execute Aspire Update - -From the workspace directory (which now contains the eShop files), run: - -```bash -aspire update -``` - -The `aspire update` command will: -- Scan all projects for Aspire package references -- Check for available updates (in this case, from the PR build) -- Update package versions in project files -- Potentially update other dependencies that are affected - -**What to observe:** -- The command should scan the solution or projects -- It should identify Aspire packages that can be updated -- It should show which packages are being updated and to which versions -- The command should complete with exit code 0 for success - -### 4.2 Handle Update Failures - -If the `aspire update` command fails: - -1. **Capture the error output** - Note the exact error message and exit code -2. **Check for common issues**: - - Package version conflicts - - Missing package sources - - Network issues downloading packages -3. **Fail the test** - If `aspire update` fails, the scenario should fail -4. **Report the failure** including: exit code, full error output, and attempted package updates (if visible) - -**If `aspire update` fails, STOP HERE and report the failure. Do not proceed to Step 5.** - -### 4.3 Verify Update Results - -If the update succeeds, verify what was changed: - -```bash -git status -git diff -``` - -**Document the changes:** -- Which files were modified -- Which package versions were updated -- Any other changes made by the update command - -Commit the update changes: - -```bash -git add . -git commit -m "Apply aspire update to use PR build packages" -``` - -## Step 5: Launch the Application with Aspire Run - -If `aspire update` succeeded, attempt to launch the eShop application using `aspire run`. - -### 5.1 Start the Application - -From the workspace directory, run: - -```bash -aspire run -``` - -The `aspire run` command will: -- Locate the AppHost project (likely `eShop.AppHost`) -- Restore all NuGet dependencies -- Build the solution -- Start the Aspire AppHost and all resources - -**What to observe:** -- The command should start the Aspire AppHost -- You should see console output indicating: - - Dashboard starting with a randomly assigned port and access token - - Resources being initialized - - Services starting up - - Watch for any build errors or runtime errors - -### 5.2 Handle Build Errors - -If `aspire run` fails with build errors, analyze them carefully: - -#### 5.2.1 Identify Build Error Types - -Common build error types: -1. **Package dependency mismatches** - Package version conflicts or missing packages -2. **API breaking changes** - Code that no longer compiles due to API changes -3. **Configuration issues** - Missing or invalid configuration -4. **Other errors** - Unrelated to packages - -#### 5.2.2 Fix Package Dependency Issues - -If the build errors are **only** package dependency issues, attempt to fix them: - -```bash -# Example: Update a specific package that's causing conflicts -dotnet add package --version - -# Or remove and re-add with the correct version -dotnet remove package -dotnet add package -``` - -**Keep track of all manual package updates:** -- Create a list in the format specified in section 5.2.4 documenting each manual package update as you make it - -After fixing package issues, try building again: - -```bash -aspire run -``` - -#### 5.2.3 Fail on Non-Package Errors - -If the build errors are **NOT** package dependency issues (e.g., breaking API changes, code compilation errors), do NOT attempt to fix them: - -1. **Stop the build process** -2. **Document the error type** and specific errors -3. **Fail the test** with a clear explanation: - - "Build failed due to [type of error]" - - Provide relevant error messages - - Explain that these are not simple package updates - -#### 5.2.4 Enumerate Manual Package Updates - -Before proceeding or failing, create a comprehensive list of all packages that required manual updating: - -**Format:** -```markdown -Manual Package Updates Required: -1. File: - Package: - Old Version: (or "not installed") - New Version: - Reason: - -2. File: - Package: - ... -``` - -**Include this list in the final report regardless of success or failure.** - -### 5.3 Wait for Startup - -If the build succeeds, allow 60-120 seconds for the application to fully start. eShop has many services and may take longer than simpler apps. - -Monitor the console output for: -- "Dashboard running at: http://localhost:XXXXX" message with the access token -- Services starting (Catalog, Basket, Ordering, etc.) -- Database migrations completing -- Any error messages or failures - -**Tip:** The dashboard URL with access token will be displayed in the console output from `aspire run`. Note this complete URL (including the token parameter) for later steps. - -## Step 6: Verify the Aspire Dashboard - -Once the application is running, access the Aspire Dashboard to verify service health. - -### 6.1 Access the Dashboard - -The dashboard URL with access token is displayed in the output from `aspire run`. Use this URL to access the dashboard. - -**Use browser automation tools to access and capture screenshots:** - -```bash -# Navigate to the dashboard using the URL from aspire run output -# Example: DASHBOARD_URL="http://localhost:12345?token=abc123" -playwright-browser navigate $DASHBOARD_URL -``` - -### 6.2 Wait for Services to Start - -Wait for approximately 60 seconds to allow all services sufficient time to start: - -```bash -sleep 60 -``` - -### 6.3 Navigate to Resources View - -Navigate to the Resources view in the dashboard to see all services: - -```bash -playwright-browser click "text=Resources" -``` - -### 6.4 Take a Screenshot - -Capture a screenshot of the dashboard showing all resources: - -```bash -playwright-browser take_screenshot --filename dashboard-eshop-resources.png -``` - -### 6.5 Analyze Service Status - -Examine the dashboard (via screenshot or browser inspection) to determine: - -1. **Total number of services/resources** -2. **Services with "Running" status** (green indicators) -3. **Services with "Completed" status** (finished without error) -4. **Services with error states** (red indicators or error messages) -5. **Services still starting** (if any) - -**Expected eShop services include (but not limited to):** -- AppHost -- WebApp (frontend) -- Catalog.API -- Basket.API -- Ordering.API -- Identity.API (if present) -- Various databases (PostgreSQL, Redis, etc.) -- Message queues (RabbitMQ, etc.) - -## Step 7: Report Results - -Provide a comprehensive summary of the scenario execution. - -### 7.1 Success Criteria - -The scenario is successful if: -- `aspire update` completed successfully -- `aspire run` launched the application without build errors (or with only package dependency errors that were fixed) -- The Aspire Dashboard is accessible -- All or most services started successfully or completed without error - -### 7.2 Summary Report Format - -Provide a report in the following format: - -```markdown -## eShop Update Scenario Results - -### Update Command -- Status: ✅ SUCCESS / ❌ FAILED -- Exit Code: -- Packages Updated: - -### Build and Run -- Status: ✅ SUCCESS / ⚠️ SUCCESS WITH FIXES / ❌ FAILED -- Build Errors: -- Build Error Type: - -### Dashboard Access -- Status: ✅ ACCESSIBLE / ❌ NOT ACCESSIBLE -- Dashboard URL: -- Screenshot: dashboard-eshop-resources.png - -### Service Status Summary -- Total Services: -- Running: ✅ -- Completed: ✅ -- Failed: ❌ -- Starting: ⏳ - -### Service Details - - -### Manual Package Updates Required - - -### Issues Encountered - - -### Overall Assessment - -``` - -### 7.3 Screenshot Analysis - -Include specific observations from the dashboard screenshot: -- Which services are green (running/healthy) -- Which services are red or yellow (errors/warnings) -- Any services that failed to start -- Overall health assessment - -### 7.4 Package Update Enumeration - -**ALWAYS** include a complete enumeration of packages that required manual updating, even if the list is empty: - -```markdown -### Manual Package Updates Required - - -No packages required manual updating. All updates were handled by `aspire update`. - - -The following packages required manual intervention: - -1. File: eShop.AppHost/eShop.AppHost.csproj - Package: Aspire.Hosting.Azure.Storage - Old Version: 9.0.0 - New Version: 10.0.0-preview.1.12345 - Reason: Version conflict with Azure.Storage.Blobs dependency - -2. File: Directory.Packages.props - Package: Microsoft.Extensions.Http - Old Version: 9.0.0 - New Version: 10.0.0-preview.1.12345 - Reason: Required by updated Aspire packages - -3. ... -``` - -## Step 8: Cleanup - -After completing the scenario (whether success or failure): - -### 8.1 Stop the Application - -If `aspire run` is still running, stop it gracefully: - -```bash -# Press Ctrl+C to stop aspire run, or if running in background: -pkill -f "aspire run" || true -``` - -### 8.2 Final Commit - -Ensure all changes are committed: - -```bash -git add . -git commit -m "Final state after eshop-update scenario" || true -``` - -## Notes and Best Practices - -### Package Update Tracking -- Keep detailed notes of every manual package update -- Include the reason why the automatic update didn't handle it -- This information is crucial for improving the `aspire update` command - -### Error Analysis -- Distinguish between different types of errors: - - Package dependency issues (fixable) - - API breaking changes (not fixable in this scenario) - - Configuration issues (case-by-case) -- Only attempt fixes for simple package updates - -### Dashboard Verification -- Wait sufficient time for services to start (60+ seconds) -- eShop is a complex application with many services -- Some services may take longer to start than others -- A few failed services may be acceptable depending on the error - -### Failure Handling -- If `aspire update` fails, fail fast and report clearly -- If build fails with non-package errors, fail and report clearly -- If the dashboard is inaccessible, still attempt to diagnose why - -### Success Definition -This scenario is considered successful if: -1. `aspire update` runs without errors -2. The application builds (with or without manual package fixes) -3. The application launches and the dashboard is accessible -4. Most services start successfully (some failures may be acceptable) -5. All manual package updates are documented - -The goal is to validate that the PR build's update functionality works correctly on a real-world, complex Aspire application like eShop. diff --git a/tests/agent-scenarios/smoke-test-dotnet/prompt.md b/tests/agent-scenarios/smoke-test-dotnet/prompt.md deleted file mode 100644 index 94e89e9bc6b..00000000000 --- a/tests/agent-scenarios/smoke-test-dotnet/prompt.md +++ /dev/null @@ -1,577 +0,0 @@ -# Smoke Test Scenario - -This scenario performs a comprehensive smoke test of an Aspire PR build by installing the Aspire CLI, creating a starter application, and verifying its functionality. - -## Overview - -This smoke test validates that: -1. The native AOT build of the Aspire CLI from the PR can be successfully acquired -2. A new Aspire starter application can be created using the Blazor template via interactive flow -3. The application can be launched with `aspire run` (which handles restore, build, and execution automatically) -4. The Aspire Dashboard is accessible and functional, with screenshots captured -5. Application components (AppHost, API service, Blazor frontend) are running correctly -6. Telemetry and logs are being collected properly -7. Web UI is functional with screenshots captured for verification - -## Prerequisites - -Before starting, ensure you have: -- Docker installed and running (for container-based resources if used) -- Sufficient disk space for the Aspire CLI and application artifacts -- Network access to download NuGet packages -- Browser automation tools available (playwright) for capturing screenshots - -**Note**: The .NET SDK is not required as a prerequisite - the Aspire CLI will install it automatically. - -## Step 1: Install the Aspire CLI from the PR Build - -The first step is to acquire the Aspire CLI from this PR build. The aspire-playground repository includes comprehensive instructions for acquiring different versions of the CLI, including PR builds. - -**Follow the CLI acquisition instructions already provided in the aspire-playground repository to obtain the native AOT build of the CLI for this PR.** - -Once acquired, verify the CLI is installed correctly: - -```bash -aspire --version -``` - -Expected output should show the version matching the PR build. - -### 1.1 Enable SDK Install Feature Flag - -Before proceeding, enable the `dotNetSdkInstallationEnabled` feature flag to force SDK installation for testing purposes. This ensures the Aspire CLI's SDK installation functionality is properly exercised. - -Set the configuration value: - -```bash -aspire config set --global features.dotNetSdkInstallationEnabled true -``` - -Verify the configuration was set: - -```bash -aspire config get --global features.dotNetSdkInstallationEnabled -``` - -Expected output: `true` - -**Note**: This feature flag forces the Aspire CLI to install the .NET SDK even if a compatible version is already available on the system. This is specifically for testing the SDK installation feature. - -## Step 2: Create a New Aspire Starter Application - -Create a new Aspire application using the Blazor-based starter template. The application will be created in the current git workspace so it becomes part of the PR when the scenario completes. - -### 2.1 Run the Aspire New Command - -Use the `aspire new` command to create a starter application. This command will present an interactive template selection process. - -```bash -aspire new -``` - -**Follow the interactive prompts:** -1. When prompted for a template, select the **"Aspire Starter App"** (template short name: `aspire-starter`) -2. Provide a name for the application when prompted (suggestion: `AspireSmokeTest`) -3. Accept the default target framework (should be .NET 10.0) -4. Select Blazor as the frontend technology -5. Choose a test framework (suggestion: xUnit or MSTest) -6. Optionally include Redis caching if prompted - -### 2.2 Verify Project Structure - -After creation, verify the project structure: - -```bash -ls -la -``` - -Expected structure: -- `AspireSmokeTest.sln` - Solution file -- `AspireSmokeTest.AppHost/` - The Aspire AppHost project -- `AspireSmokeTest.ServiceDefaults/` - Shared service defaults -- `AspireSmokeTest.ApiService/` - Backend API service -- `AspireSmokeTest.Web/` - Blazor frontend -- `AspireSmokeTest.Tests/` - Test project (if test framework was selected) - -### 2.3 Inspect Key Files - -Review key configuration files to understand the application structure: - -```bash -# View the AppHost Program.cs to see resource definitions -cat AspireSmokeTest.AppHost/Program.cs - -# View the solution structure -cat AspireSmokeTest.sln -``` - -## Step 3: Launch the Application with Aspire Run - -Launch the application using the `aspire run` command. The CLI will automatically find the AppHost project, restore dependencies, build, and run the application. - -### 3.1 Start the Application - -From the workspace directory, run: - -```bash -aspire run -``` - -The `aspire run` command will: -- Locate the AppHost project in the current directory -- Restore all NuGet dependencies -- Build the solution -- Start the Aspire AppHost and all resources - -**What to observe:** -- The command should start the Aspire AppHost -- You should see console output indicating: - - Dashboard starting with a randomly assigned port and access token - - Resources being initialized - - Services starting up - - No critical errors in the startup logs - -### 3.2 Wait for Startup - -Allow 30-60 seconds for the application to fully start. Monitor the console output for: -- "Dashboard running at: http://localhost:XXXXX" message with the access token -- "Application started" or similar success messages -- All resources showing as "Running" status - -**Tip:** The dashboard URL with access token will be displayed in the console output from `aspire run`. Note this complete URL (including the token parameter) for later steps. The port is randomly selected each time a new project is created. - -## Step 4: Verify the Aspire Dashboard - -The Aspire Dashboard is the central monitoring interface. Let's verify it's accessible and functional. - -### 4.1 Access the Dashboard - -The dashboard URL with access token is displayed in the output from `aspire run`. Use this URL to access the dashboard. - -**Use browser automation tools to access and capture screenshots:** - -```bash -# Navigate to the dashboard using the URL from aspire run output -# Example: DASHBOARD_URL="http://localhost:12345?token=abc123" -playwright-browser navigate $DASHBOARD_URL -``` - -**Take a screenshot of the dashboard:** - -```bash -playwright-browser take_screenshot --filename dashboard-main.png -``` - -**Expected response:** -- Dashboard loads successfully -- Dashboard login page or main interface displays (depending on auth configuration) -- Screenshot captures the dashboard UI - -**If browser automation fails, use curl for diagnostics:** - -```bash -# Check if dashboard is accessible using the URL from aspire run output -curl -I $DASHBOARD_URL - -# Get the HTML content to diagnose issues -curl $DASHBOARD_URL -``` - -### 4.2 Navigate Dashboard Sections - -Use browser automation to navigate through the dashboard sections and capture screenshots of each. - -#### Resources View -- Navigate to the "Resources" page -- **Take a screenshot showing all resources** -- Verify all expected resources are listed: - - `apiservice` - The API backend - - `webfrontend` - The Blazor web application - - Any other resources (Redis, if included) -- Check that each resource shows: - - Status: "Running" (green indicator) - - Endpoint URLs - - No error states - -```bash -# Take screenshot of Resources view -playwright-browser take_screenshot --filename dashboard-resources.png -``` - -#### Console Logs -- Click on each resource to view its console logs -- **Take screenshots of the console logs for key resources** -- Verify logs are being captured and displayed -- Look for application startup messages -- Ensure no critical errors or exceptions in the logs - -```bash -# Take screenshot of console logs -playwright-browser take_screenshot --filename dashboard-console-logs.png -``` - -#### Structured Logs -- Navigate to the "Structured Logs" section -- **Take a screenshot of the structured logs view** -- Verify that logs from all services are appearing -- Check that log filtering and search functionality works -- Confirm logs have proper timestamps and log levels - -```bash -# Take screenshot of Structured Logs -playwright-browser take_screenshot --filename dashboard-structured-logs.png -``` - -#### Traces -- Navigate to the "Traces" section (if telemetry is enabled) -- **Take a screenshot of the traces view** -- Verify that distributed traces are being collected -- Look for traces showing requests flowing through the system -- Check that trace details are viewable - -```bash -# Take screenshot of Traces -playwright-browser take_screenshot --filename dashboard-traces.png -``` - -#### Metrics -- Navigate to the "Metrics" section -- **Take a screenshot of the metrics view** -- Verify that metrics are being collected -- Check for basic metrics like: - - HTTP request counts - - Response times - - System resource usage (CPU, memory) - -```bash -# Take screenshot of Metrics -playwright-browser take_screenshot --filename dashboard-metrics.png -``` - -### 4.3 Resource Health Check - -For each listed resource in the dashboard: -1. Note the endpoint URL -2. Verify the resource is accessible at that endpoint -3. Check for healthy responses - -## Step 5: Test the API Service - -Verify the API service is functioning correctly. - -### 5.1 Identify API Endpoint - -From the dashboard Resources view, note the endpoint URL for the `apiservice` resource (typically http://localhost:XXXX). - -### 5.2 Call the API - -Test the API endpoints: - -```bash -# Replace with the actual endpoint from the dashboard -API_URL="http://localhost:5001" # Example, use actual URL - -# Test the weather forecast endpoint (common in starter template) -curl $API_URL/weatherforecast - -# Or if specific API paths are documented -curl $API_URL/api/health -``` - -**Expected response:** -- HTTP 200 OK status -- Valid JSON response with weather data or appropriate API response -- No error messages - -### 5.3 Verify API Telemetry - -After making API calls: -1. Return to the Dashboard -2. Check the Structured Logs for new log entries from the API service -3. Verify traces were created for the API requests -4. Check metrics show the API request counts increased - -## Step 6: Test the Blazor Web Frontend - -Verify the web frontend is accessible and functional. - -### 6.1 Identify Web Frontend Endpoint - -From the dashboard Resources view, note the endpoint URL for the `webfrontend` resource (typically http://localhost:XXXX). - -### 6.2 Access the Web Application - -Use browser automation to navigate to the web frontend and capture screenshots: - -```bash -WEB_URL="http://localhost:5000" # Example, use actual URL - -# Navigate to the web app -playwright-browser navigate $WEB_URL -``` - -**Take a screenshot of the home page:** - -```bash -playwright-browser take_screenshot --filename web-home-page.png -``` - -**Expected response:** -- Blazor application loads successfully -- Home page displays correctly -- Screenshot captures the web UI - -### 6.3 Test Web Application Features - -Use browser automation to interact with the application and capture screenshots: - -1. **Home Page**: Verify the home page loads and take a screenshot -2. **Navigation**: Test navigating between pages (Home, Weather, etc.) -3. **Weather Page**: Navigate to the weather forecast page - ```bash - # Take screenshot of Weather page - playwright-browser take_screenshot --filename web-weather-page.png - ``` - - Verify the page loads and displays data from the API - - Capture screenshot showing the weather data -4. **Interactive Elements**: Test any interactive Blazor components - -### 6.4 Verify Web Telemetry - -After interacting with the web application: -1. Check the Dashboard for web frontend logs -2. Verify traces showing frontend → API calls -3. Check that both frontend and backend telemetry is correlated - -## Step 7: Integration Testing - -Verify the end-to-end integration between components. - -### 7.1 Test Frontend-to-API Communication - -From the web frontend: -1. Navigate to a page that calls the API (e.g., Weather page) -2. Verify data is successfully retrieved and displayed -3. Check the Dashboard traces to see the complete request flow: - - Web frontend initiates request - - API service receives and processes request - - Response returns to frontend - - Trace shows the complete distributed transaction - -### 7.2 Verify Service Discovery - -The starter app uses Aspire's service discovery. Verify: -1. The frontend can resolve and call the API service by name -2. No hardcoded URLs are needed in the application code -3. Service discovery is working through the Aspire infrastructure - -### 7.3 Test Configuration Injection - -Verify configuration is properly injected: -1. Check that service defaults are applied -2. Verify connection strings and service URLs are automatically configured -3. Confirm environment-specific settings are working - -## Step 8: Verify Development Features - -Test key development experience features. - -### 8.1 Console Output - -Monitor the console where `aspire run` is executing: -- Verify logs from all services appear in real-time -- Check that log levels are appropriate (Info, Warning, Error) -- Ensure structured logging format is maintained - -### 8.2 Hot Reload (if applicable) - -If the project supports hot reload: -1. Make a small change to the code (e.g., modify a string in the API) -2. Save the file -3. Verify the change is reflected without full restart -4. Check that the dashboard shows the reload event - -### 8.3 Resource Management - -Verify resource lifecycle management: -1. All resources start in the correct order -2. Dependencies are properly handled -3. Resources show correct status in the dashboard - -## Step 9: Graceful Shutdown - -Test that the application shuts down cleanly. - -### 9.1 Stop the Application - -Press `Ctrl+C` in the terminal where `aspire run` is running. - -**Observe:** -- Graceful shutdown messages in the console -- Resources stopping in appropriate order -- No error messages during shutdown -- Clean exit with exit code 0 - -### 9.2 Verify Cleanup - -After shutdown: -1. Verify no orphaned processes are running -2. Check that containers (if any) are stopped -3. Confirm ports are released - -## Step 10: Run Tests (Optional) - -If the application includes tests, run them to verify test infrastructure. - -### 10.1 Run Unit Tests - -```bash -dotnet test AspireSmokeTest.Tests -``` - -**Expected outcome:** -- All tests pass -- Test output shows proper test discovery and execution -- Integration tests (if any) can start and test the app - -## Step 11: Final Verification Checklist - -Go through this final checklist to ensure all smoke test requirements are met: - -- [ ] Aspire CLI acquired successfully from PR build (native AOT version) -- [ ] Starter application created using `aspire new` with Blazor template (interactive flow) -- [ ] Application launches successfully with `aspire run` (automatic restore and build) -- [ ] Aspire Dashboard is accessible at the designated URL -- [ ] **Screenshots captured**: Dashboard main view, Resources, Console Logs, Structured Logs, Traces, Metrics -- [ ] Dashboard Resources view shows all expected resources as "Running" -- [ ] Console logs are visible for all resources -- [ ] Structured logs are being collected and displayed -- [ ] Traces are being collected (if applicable) -- [ ] Metrics are being collected (if applicable) -- [ ] API service responds correctly to HTTP requests -- [ ] Blazor web frontend is accessible and displays correctly -- [ ] **Screenshots captured**: Web home page, Weather page showing API data -- [ ] Frontend successfully calls and receives data from API -- [ ] Service discovery is working between components -- [ ] End-to-end traces show complete request flow -- [ ] Application shuts down cleanly without errors -- [ ] Tests run successfully (if included) - -## Success Criteria - -The smoke test is considered **PASSED** if: - -1. **Installation**: Aspire CLI from PR build acquired successfully (native AOT version) -2. **Creation**: New project created successfully with all expected files (using interactive flow) -3. **Launch**: Application starts and all resources reach "Running" state (automatic restore, build, and run) -4. **Dashboard**: Dashboard is accessible and all sections are functional -5. **Screenshots**: All required screenshots captured showing dashboard and web UI -6. **API**: API service responds correctly to requests -7. **Frontend**: Web frontend loads and displays data from API -8. **Telemetry**: Logs, traces, and metrics are being collected -9. **Integration**: End-to-end request flow works correctly -10. **Shutdown**: Application stops cleanly without errors - -The smoke test is considered **FAILED** if: - -- CLI installation fails or produces errors -- Project creation fails or generates incomplete/corrupt project structure -- Build fails with errors -- Application fails to start or resources remain in error state -- Dashboard is not accessible or shows critical errors -- API or frontend services are not accessible -- Telemetry collection is not working -- Errors occur during normal operation or shutdown - -## Troubleshooting Tips - -If issues occur during the smoke test: - -### CLI Installation Issues -- Verify the artifact path is correct and package exists -- Check that no previous version of Aspire CLI is interfering -- Try uninstalling all .NET tools and reinstalling - -### Build Failures -- Check NuGet package restore completed successfully -- Verify all package sources are accessible -- Review build error messages for specific issues -- Verify the Aspire CLI successfully installed the required .NET SDK - -### Startup Failures -- Check Docker is running (if using containers) -- Verify ports are not already in use by other applications -- Review console output for specific error messages -- Check system resources (disk space, memory) - -### Dashboard Access Issues -- Verify the dashboard URL from console output -- Check firewall settings aren't blocking local ports -- Try accessing via 127.0.0.1 instead of localhost -- Check browser console for JavaScript errors - -### Service Communication Issues -- Verify service discovery is configured correctly -- Check endpoint URLs in dashboard are correct -- Test direct HTTP calls to services to isolate issues -- Review traces for errors in request flow - -## Report Generation - -After completing the smoke test, provide a summary report including: - -1. **Test Environment**: - - OS and version - - .NET SDK version (auto-installed by Aspire CLI) - - Docker version (if applicable) - - Aspire CLI version tested - -2. **Test Results**: - - Overall PASS/FAIL status - - Results for each major step - - Any warnings or non-critical issues encountered - -3. **Performance Notes**: - - Application startup time - - Build duration - - Resource consumption - -4. **Screenshots/Evidence**: - - Dashboard showing all resources running - - API response examples - - Web frontend screenshot - - Trace view showing end-to-end request - -5. **Issues Found** (if any): - - Description of any failures - - Error messages and logs - - Steps to reproduce - - Suggested fixes or workarounds - -## Cleanup - -After completing the smoke test, the application files created in the workspace will become part of the PR. If you need to clean up: - -```bash -# Stop the application if still running (Ctrl+C) - -# The application files remain in the workspace as part of the PR -# No additional cleanup is needed -``` - -**Note**: The created application serves as evidence that the smoke test completed successfully and will be included in the PR for review. - -## Notes for Agent Execution - -When executing this scenario as an automated agent: - -1. **Capture Output**: Save console output, logs, and screenshots at each major step -2. **Error Handling**: If any step fails, capture detailed error information before continuing or stopping -3. **Timing**: Allow adequate time for operations (startup, requests, shutdown) -4. **Validation**: Perform actual HTTP requests and verifications, not just syntax checks -5. **Evidence**: Collect concrete evidence of success (response codes, content verification, etc.) -6. **Reporting**: Provide clear, detailed reporting on test outcomes - ---- - -**End of Smoke Test Scenario** diff --git a/tests/agent-scenarios/smoke-test-python/prompt.md b/tests/agent-scenarios/smoke-test-python/prompt.md deleted file mode 100644 index 73f81ad83ad..00000000000 --- a/tests/agent-scenarios/smoke-test-python/prompt.md +++ /dev/null @@ -1,592 +0,0 @@ -# Smoke Test Scenario - Python/Vite Starter - -This scenario performs a comprehensive smoke test of an Aspire PR build by installing the Aspire CLI, creating a Python starter application with Vite frontend, and verifying its functionality. - -## Overview - -This smoke test validates that: -1. The native AOT build of the Aspire CLI from the PR can be successfully acquired -2. A new Aspire Python starter application can be created using the Python/Vite template via interactive flow -3. The application can be launched with `aspire run` (which handles restore, build, and execution automatically) -4. The Aspire Dashboard is accessible and functional, with screenshots captured -5. Application components (AppHost, Python API service, Vite/React frontend) are running correctly -6. Telemetry and logs are being collected properly -7. Web UI is functional with screenshots captured for verification - -## Prerequisites - -Before starting, ensure you have: -- Docker installed and running (for container-based resources if used) -- Python 3.11 or later installed (for the Python API service) -- Node.js 18 or later installed (for the Vite frontend) -- Sufficient disk space for the Aspire CLI and application artifacts -- Network access to download NuGet packages, Python packages, and npm packages -- Browser automation tools available (playwright) for capturing screenshots - -**Note**: The .NET SDK is not required as a prerequisite - the Aspire CLI will install it automatically. - -## Step 1: Install the Aspire CLI from the PR Build - -The first step is to acquire the Aspire CLI from this PR build. The aspire-playground repository includes comprehensive instructions for acquiring different versions of the CLI, including PR builds. - -**Follow the CLI acquisition instructions already provided in the aspire-playground repository to obtain the native AOT build of the CLI for this PR.** - -Once acquired, verify the CLI is installed correctly: - -```bash -aspire --version -``` - -Expected output should show the version matching the PR build. - -### 1.1 Enable SDK Install Feature Flag - -Before proceeding, enable the `dotNetSdkInstallationEnabled` feature flag to force SDK installation for testing purposes. This ensures the Aspire CLI's SDK installation functionality is properly exercised. - -Set the configuration value: - -```bash -aspire config set --global features.dotNetSdkInstallationEnabled true -``` - -Verify the configuration was set: - -```bash -aspire config get --global features.dotNetSdkInstallationEnabled -``` - -Expected output: `true` - -**Note**: This feature flag forces the Aspire CLI to install the .NET SDK even if a compatible version is already available on the system. This is specifically for testing the SDK installation feature. - -## Step 2: Create a New Aspire Python Starter Application - -Create a new Aspire application using the Python/Vite starter template. The application will be created in the current git workspace so it becomes part of the PR when the scenario completes. - -### 2.1 Run the Aspire New Command - -Use the `aspire new` command to create a starter application. This command will present an interactive template selection process. - -```bash -aspire new -``` - -**Follow the interactive prompts:** -1. When prompted for a template, select the **"Aspire Python Starter App"** (template short name: `aspire-py-starter`) -2. Provide a name for the application when prompted (suggestion: `AspirePySmokeTest`) -3. Accept the default target framework (should be .NET 10.0) -4. Optionally include Redis caching if prompted - -### 2.2 Verify Project Structure - -After creation, verify the project structure: - -```bash -ls -la -``` - -Expected structure: -- `AspirePySmokeTest.sln` - Solution file (if generated) -- `AspirePySmokeTest.AppHost/` - The Aspire AppHost project (C#) -- `app/` - Python backend API service -- `frontend/` - Vite/React frontend -- `apphost.run.json` - AppHost run configuration - -### 2.3 Inspect Key Files - -Review key configuration files to understand the application structure: - -```bash -# View the AppHost code to see resource definitions -cat apphost.cs - -# View the Python app files -ls -la app/ - -# View the frontend package.json -cat frontend/package.json - -# View the Vite configuration -cat frontend/vite.config.ts -``` - -## Step 3: Launch the Application with Aspire Run - -Launch the application using the `aspire run` command. The CLI will automatically find the AppHost configuration, restore dependencies, build, and run the application. - -### 3.1 Start the Application - -From the workspace directory, run: - -```bash -aspire run -``` - -The `aspire run` command will: -- Locate the AppHost configuration in the current directory -- Restore all dependencies (NuGet, Python packages, npm packages) -- Build the solution -- Start the Aspire AppHost and all resources - -**What to observe:** -- The command should start the Aspire AppHost -- You should see console output indicating: - - Dashboard starting with a randomly assigned port and access token - - Resources being initialized - - Python API service starting up - - Vite frontend dev server starting - - No critical errors in the startup logs - -### 3.2 Wait for Startup - -Allow 30-60 seconds for the application to fully start. Monitor the console output for: -- "Dashboard running at: http://localhost:XXXXX" message with the access token -- "Application started" or similar success messages -- All resources showing as "Running" status - -**Tip:** The dashboard URL with access token will be displayed in the console output from `aspire run`. Note this complete URL (including the token parameter) for later steps. The port is randomly selected each time a new project is created. - -## Step 4: Verify the Aspire Dashboard - -The Aspire Dashboard is the central monitoring interface. Let's verify it's accessible and functional. - -### 4.1 Access the Dashboard - -The dashboard URL with access token is displayed in the output from `aspire run`. Use this URL to access the dashboard. - -**Use browser automation tools to access and capture screenshots:** - -```bash -# Navigate to the dashboard using the URL from aspire run output -# Example: DASHBOARD_URL="http://localhost:12345?token=abc123" -playwright-browser navigate $DASHBOARD_URL -``` - -**Take a screenshot of the dashboard:** - -```bash -playwright-browser take_screenshot --filename dashboard-main.png -``` - -**Expected response:** -- Dashboard loads successfully -- Dashboard login page or main interface displays (depending on auth configuration) -- Screenshot captures the dashboard UI - -**If browser automation fails, use curl for diagnostics:** - -```bash -# Check if dashboard is accessible using the URL from aspire run output -curl -I $DASHBOARD_URL - -# Get the HTML content to diagnose issues -curl $DASHBOARD_URL -``` - -### 4.2 Navigate Dashboard Sections - -Use browser automation to navigate through the dashboard sections and capture screenshots of each. - -#### Resources View -- Navigate to the "Resources" page -- **Take a screenshot showing all resources** -- Verify all expected resources are listed: - - `app` - The Python API backend - - `frontend` - The Vite/React frontend application - - Any other resources (Redis, if included) -- Check that each resource shows: - - Status: "Running" (green indicator) - - Endpoint URLs - - No error states - -```bash -# Take screenshot of Resources view -playwright-browser take_screenshot --filename dashboard-resources.png -``` - -#### Console Logs -- Click on each resource to view its console logs -- **Take screenshots of the console logs for key resources** -- Verify logs are being captured and displayed -- Look for application startup messages -- Ensure no critical errors or exceptions in the logs - -```bash -# Take screenshot of console logs -playwright-browser take_screenshot --filename dashboard-console-logs.png -``` - -#### Structured Logs -- Navigate to the "Structured Logs" section -- **Take a screenshot of the structured logs view** -- Verify that logs from all services are appearing -- Check that log filtering and search functionality works -- Confirm logs have proper timestamps and log levels - -```bash -# Take screenshot of Structured Logs -playwright-browser take_screenshot --filename dashboard-structured-logs.png -``` - -#### Traces -- Navigate to the "Traces" section (if telemetry is enabled) -- **Take a screenshot of the traces view** -- Verify that distributed traces are being collected -- Look for traces showing requests flowing through the system -- Check that trace details are viewable - -```bash -# Take screenshot of Traces -playwright-browser take_screenshot --filename dashboard-traces.png -``` - -#### Metrics -- Navigate to the "Metrics" section -- **Take a screenshot of the metrics view** -- Verify that metrics are being collected -- Check for basic metrics like: - - HTTP request counts - - Response times - - System resource usage (CPU, memory) - -```bash -# Take screenshot of Metrics -playwright-browser take_screenshot --filename dashboard-metrics.png -``` - -### 4.3 Resource Health Check - -For each listed resource in the dashboard: -1. Note the endpoint URL -2. Verify the resource is accessible at that endpoint -3. Check for healthy responses - -## Step 5: Test the Python API Service - -Verify the Python API service is functioning correctly. - -### 5.1 Identify API Endpoint - -From the dashboard Resources view, note the endpoint URL for the `app` resource (typically http://localhost:XXXX). - -### 5.2 Call the API - -Test the API endpoints: - -```bash -# Replace with the actual endpoint from the dashboard -API_URL="http://localhost:5001" # Example, use actual URL - -# Test a common Python API endpoint -curl $API_URL/ - -# Or test health/info endpoints if available -curl $API_URL/health -curl $API_URL/info -``` - -**Expected response:** -- HTTP 200 OK status -- Valid JSON response or appropriate API response -- No error messages - -### 5.3 Verify API Telemetry - -After making API calls: -1. Return to the Dashboard -2. Check the Structured Logs for new log entries from the Python API service -3. Verify traces were created for the API requests -4. Check metrics show the API request counts increased - -## Step 6: Test the Vite/React Frontend - -Verify the Vite frontend is accessible and functional. - -### 6.1 Identify Frontend Endpoint - -From the dashboard Resources view, note the endpoint URL for the `frontend` resource (typically http://localhost:XXXX). - -### 6.2 Access the Web Application - -Use browser automation to navigate to the frontend and capture screenshots: - -```bash -WEB_URL="http://localhost:5173" # Example, use actual URL - -# Navigate to the web app -playwright-browser navigate $WEB_URL -``` - -**Take a screenshot of the home page:** - -```bash -playwright-browser take_screenshot --filename web-home-page.png -``` - -**Expected response:** -- Vite/React application loads successfully -- Home page displays correctly -- Screenshot captures the web UI - -### 6.3 Test Web Application Features - -Use browser automation to interact with the application and capture screenshots: - -1. **Home Page**: Verify the home page loads and take a screenshot -2. **Navigation**: Test navigating between pages (if multiple pages exist) -3. **API Integration**: Test pages that call the Python API - ```bash - # Take screenshot showing data from the API - playwright-browser take_screenshot --filename web-api-data.png - ``` - - Verify the page loads and displays data from the Python API - - Capture screenshot showing the data -4. **Interactive Elements**: Test any interactive React components - -### 6.4 Verify Frontend Telemetry - -After interacting with the web application: -1. Check the Dashboard for frontend logs -2. Verify traces showing frontend → API calls -3. Check that both frontend and backend telemetry is correlated - -## Step 7: Integration Testing - -Verify the end-to-end integration between components. - -### 7.1 Test Frontend-to-API Communication - -From the web frontend: -1. Navigate to a page that calls the Python API -2. Verify data is successfully retrieved and displayed -3. Check the Dashboard traces to see the complete request flow: - - Vite frontend initiates request - - Python API service receives and processes request - - Response returns to frontend - - Trace shows the complete distributed transaction - -### 7.2 Verify Service Discovery - -The starter app uses Aspire's service discovery. Verify: -1. The frontend can resolve and call the Python API service by name -2. No hardcoded URLs are needed in the application code -3. Service discovery is working through the Aspire infrastructure - -### 7.3 Test Configuration Injection - -Verify configuration is properly injected: -1. Check that service defaults are applied -2. Verify connection strings and service URLs are automatically configured -3. Confirm environment-specific settings are working - -## Step 8: Verify Development Features - -Test key development experience features. - -### 8.1 Console Output - -Monitor the console where `aspire run` is executing: -- Verify logs from all services appear in real-time -- Check that log levels are appropriate (Info, Warning, Error) -- Ensure structured logging format is maintained - -### 8.2 Hot Reload - -Test hot reload capabilities: - -**Python Hot Reload:** -1. Make a small change to the Python API code (e.g., modify a response string) -2. Save the file -3. Verify the change is reflected without full restart (if supported) - -**Vite Hot Module Replacement (HMR):** -1. Make a small change to the React frontend (e.g., modify text in a component) -2. Save the file -3. Verify the browser automatically updates without page reload -4. Check that the dashboard shows the reload event - -### 8.3 Resource Management - -Verify resource lifecycle management: -1. All resources start in the correct order -2. Dependencies are properly handled -3. Resources show correct status in the dashboard - -## Step 9: Graceful Shutdown - -Test that the application shuts down cleanly. - -### 9.1 Stop the Application - -Press `Ctrl+C` in the terminal where `aspire run` is running. - -**Observe:** -- Graceful shutdown messages in the console -- Resources stopping in appropriate order -- No error messages during shutdown -- Clean exit with exit code 0 - -### 9.2 Verify Cleanup - -After shutdown: -1. Verify no orphaned processes are running (Python, Node.js) -2. Check that containers (if any) are stopped -3. Confirm ports are released - -## Step 10: Final Verification Checklist - -Go through this final checklist to ensure all smoke test requirements are met: - -- [ ] Aspire CLI acquired successfully from PR build (native AOT version) -- [ ] Python starter application created using `aspire new` with Python/Vite template (interactive flow) -- [ ] Application launches successfully with `aspire run` (automatic restore and build) -- [ ] Aspire Dashboard is accessible at the designated URL -- [ ] **Screenshots captured**: Dashboard main view, Resources, Console Logs, Structured Logs, Traces, Metrics -- [ ] Dashboard Resources view shows all expected resources as "Running" (Python API, Vite frontend) -- [ ] Console logs are visible for all resources -- [ ] Structured logs are being collected and displayed -- [ ] Traces are being collected (if applicable) -- [ ] Metrics are being collected (if applicable) -- [ ] Python API service responds correctly to HTTP requests -- [ ] Vite/React frontend is accessible and displays correctly -- [ ] **Screenshots captured**: Web home page, pages showing data from Python API -- [ ] Frontend successfully calls and receives data from Python API -- [ ] Service discovery is working between components -- [ ] End-to-end traces show complete request flow -- [ ] Hot reload works for both Python and Vite (if applicable) -- [ ] Application shuts down cleanly without errors - -## Success Criteria - -The smoke test is considered **PASSED** if: - -1. **Installation**: Aspire CLI from PR build acquired successfully (native AOT version) -2. **Creation**: New Python/Vite project created successfully with all expected files (using interactive flow) -3. **Launch**: Application starts and all resources reach "Running" state (automatic restore, build, and run) -4. **Dashboard**: Dashboard is accessible and all sections are functional -5. **Screenshots**: All required screenshots captured showing dashboard and web UI -6. **Python API**: Python API service responds correctly to requests -7. **Vite Frontend**: Vite/React frontend loads and displays data from Python API -8. **Telemetry**: Logs, traces, and metrics are being collected -9. **Integration**: End-to-end request flow works correctly between Vite frontend and Python API -10. **Shutdown**: Application stops cleanly without errors - -The smoke test is considered **FAILED** if: - -- CLI installation fails or produces errors -- Project creation fails or generates incomplete/corrupt project structure -- Build fails with errors (Python dependencies, npm dependencies, or .NET build) -- Application fails to start or resources remain in error state -- Dashboard is not accessible or shows critical errors -- Python API or Vite frontend services are not accessible -- Telemetry collection is not working -- Errors occur during normal operation or shutdown - -## Troubleshooting Tips - -If issues occur during the smoke test: - -### CLI Installation Issues -- Verify the artifact path is correct and package exists -- Check that no previous version of Aspire CLI is interfering -- Try uninstalling all .NET tools and reinstalling - -### Build Failures -- Check NuGet package restore completed successfully -- Verify Python dependencies installed correctly (check requirements.txt) -- Verify npm packages installed correctly (check package.json) -- Review build error messages for specific issues -- Verify the Aspire CLI successfully installed the required .NET SDK - -### Python Service Issues -- Verify Python 3.11+ is installed: `python --version` or `python3 --version` -- Check Python virtual environment is created correctly -- Verify Python packages installed: look for `venv` or `.venv` directory -- Check Python service logs for import errors or dependency issues - -### Vite Frontend Issues -- Verify Node.js is installed: `node --version` -- Check npm dependencies installed correctly: `ls frontend/node_modules` -- Verify Vite dev server is running: check for port binding messages -- Check browser console for JavaScript errors - -### Startup Failures -- Check Docker is running (if using containers) -- Verify ports are not already in use by other applications -- Review console output for specific error messages -- Check system resources (disk space, memory) - -### Dashboard Access Issues -- Verify the dashboard URL from console output -- Check firewall settings aren't blocking local ports -- Try accessing via 127.0.0.1 instead of localhost -- Check browser console for JavaScript errors - -### Service Communication Issues -- Verify service discovery is configured correctly -- Check endpoint URLs in dashboard are correct -- Test direct HTTP calls to services to isolate issues -- Review traces for errors in request flow - -## Report Generation - -After completing the smoke test, provide a summary report including: - -1. **Test Environment**: - - OS and version - - Python version - - Node.js version - - .NET SDK version (auto-installed by Aspire CLI) - - Docker version (if applicable) - - Aspire CLI version tested - -2. **Test Results**: - - Overall PASS/FAIL status - - Results for each major step - - Any warnings or non-critical issues encountered - -3. **Performance Notes**: - - Application startup time - - Build duration (including Python and npm package installation) - - Resource consumption - -4. **Screenshots/Evidence**: - - Dashboard showing all resources running - - Python API response examples - - Vite/React frontend screenshot - - Trace view showing end-to-end request - -5. **Issues Found** (if any): - - Description of any failures - - Error messages and logs - - Steps to reproduce - - Suggested fixes or workarounds - -## Cleanup - -After completing the smoke test, the application files created in the workspace will become part of the PR. If you need to clean up: - -```bash -# Stop the application if still running (Ctrl+C) - -# The application files remain in the workspace as part of the PR -# No additional cleanup is needed -``` - -**Note**: The created application serves as evidence that the smoke test completed successfully and will be included in the PR for review. - -## Notes for Agent Execution - -When executing this scenario as an automated agent: - -1. **Capture Output**: Save console output, logs, and screenshots at each major step -2. **Error Handling**: If any step fails, capture detailed error information before continuing or stopping -3. **Timing**: Allow adequate time for operations (startup, package installation, requests, shutdown) -4. **Validation**: Perform actual HTTP requests and verifications, not just syntax checks -5. **Evidence**: Collect concrete evidence of success (response codes, content verification, etc.) -6. **Reporting**: Provide clear, detailed reporting on test outcomes -7. **Python/Node.js**: Ensure Python and Node.js are available in the environment - ---- - -**End of Smoke Test Scenario - Python/Vite Starter** diff --git a/tests/agent-scenarios/starter-app/prompt.md b/tests/agent-scenarios/starter-app/prompt.md deleted file mode 100644 index d72e3b4da66..00000000000 --- a/tests/agent-scenarios/starter-app/prompt.md +++ /dev/null @@ -1,3 +0,0 @@ -# Starter App Scenario - -Create an aspire application starting by downloading the Aspire CLI and creating a starter app. From fda1b2c7a54b9124b2dd2cb264f3d243143a4db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= Date: Tue, 24 Feb 2026 21:24:47 -0800 Subject: [PATCH 176/256] Add AspireExport coverage for Aspire.Hosting.Azure.ServiceBus (#14665) * Add AspireExport coverage for Aspire.Hosting.Azure.ServiceBus * Fix codegen issues * Fix other languages tests --- .../ValidationAppHost/.aspire/settings.json | 7 + .../ValidationAppHost/apphost.run.json | 13 + .../ValidationAppHost/apphost.ts | 141 ++ .../ValidationAppHost/package-lock.json | 962 +++++++++ .../ValidationAppHost/package.json | 19 + .../ValidationAppHost/tsconfig.json | 15 + .../AzureServiceBusCorrelationFilter.cs | 1 + .../AzureServiceBusExtensions.cs | 61 +- .../AzureServiceBusQueueResource.cs | 5 + .../AzureServiceBusRule.cs | 1 + .../AzureServiceBusSubscriptionResource.cs | 5 + .../AzureServiceBusTopicResource.cs | 5 + .../AtsTypeScriptCodeGenerator.cs | 54 +- .../Snapshots/AtsGeneratedAspire.verified.go | 233 +++ ...TwoPassScanningGeneratedAspire.verified.go | 790 ++++++++ .../WithOptionalStringCapability.verified.txt | 10 + .../AtsGeneratedAspire.verified.java | 153 ++ ...oPassScanningGeneratedAspire.verified.java | 575 ++++++ .../WithOptionalStringCapability.verified.txt | 10 + .../Snapshots/AtsGeneratedAspire.verified.py | 114 ++ ...TwoPassScanningGeneratedAspire.verified.py | 420 ++++ .../WithOptionalStringCapability.verified.txt | 10 + .../Snapshots/AtsGeneratedAspire.verified.rs | 198 ++ ...TwoPassScanningGeneratedAspire.verified.rs | 688 +++++++ .../WithOptionalStringCapability.verified.txt | 10 + .../AtsTypeScriptCodeGeneratorTests.cs | 25 + .../Snapshots/AtsGeneratedAspire.verified.ts | 383 ++++ ...TwoPassScanningGeneratedAspire.verified.ts | 1763 ++++++++++++++--- .../WithOptionalStringCapability.verified.txt | 10 + .../Snapshots/base.verified.ts | 412 ++++ .../Snapshots/transport.verified.ts | 557 ++++++ .../TestTypes/TestExtensions.cs | 20 + 32 files changed, 7408 insertions(+), 262 deletions(-) create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/.aspire/settings.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/apphost.run.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/apphost.ts create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/package-lock.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/package.json create mode 100644 playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/tsconfig.json create mode 100644 tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/base.verified.ts create mode 100644 tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/transport.verified.ts diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/.aspire/settings.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/.aspire/settings.json new file mode 100644 index 00000000000..019aedb9788 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/.aspire/settings.json @@ -0,0 +1,7 @@ +{ + "appHostPath": "../apphost.ts", + "language": "typescript/nodejs", + "packages": { + "Aspire.Hosting.Azure.ServiceBus": "" + } +} diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/apphost.run.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/apphost.run.json new file mode 100644 index 00000000000..ddfd2808fdf --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/apphost.run.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "https": { + "applicationUrl": "https://localhost:24190;http://localhost:42530", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:60807", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:16724" + } + } + } +} \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/apphost.ts b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/apphost.ts new file mode 100644 index 00000000000..88b5453cab9 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/apphost.ts @@ -0,0 +1,141 @@ +// Aspire TypeScript AppHost — Azure Service Bus validation +// Exercises every exported member of Aspire.Hosting.Azure.ServiceBus + +import { + createBuilder, + AzureServiceBusFilterType, + type AzureServiceBusRule, + type AzureServiceBusCorrelationFilter, +} from './.modules/aspire.js'; + +const builder = await createBuilder(); + +// ── 1. addAzureServiceBus ────────────────────────────────────────────────── +const serviceBus = await builder.addAzureServiceBus("messaging"); + +// ── 2. runAsEmulator — with configureContainer callback ──────────────────── +const emulatorBus = await builder + .addAzureServiceBus("messaging-emulator") + .runAsEmulator({ + configureContainer: async (emulator) => { + await emulator.withConfigurationFile("./servicebus-config.json"); + await emulator.withHostPort({ port: 5672 }); + }, + }); + +// ── 3. addServiceBusQueue — factory method returns Queue type ────────────── +const queue = await serviceBus.addServiceBusQueue("orders", { + queueName: "orders-queue", +}); + +// ── 4. addServiceBusTopic — factory method returns Topic type ────────────── +const topic = await serviceBus.addServiceBusTopic("events", { + topicName: "events-topic", +}); + +// ── 5. addServiceBusSubscription — factory on Topic returns Subscription ─── +const subscription = await topic.addServiceBusSubscription("audit", { + subscriptionName: "audit-sub", +}); + +// ── DTO types ─────────────────────────────────────────────────────────────── +const filter: AzureServiceBusCorrelationFilter = { + correlationId: "order-123", + subject: "OrderCreated", + contentType: "application/json", + messageId: "msg-001", + replyTo: "reply-queue", + sessionId: "session-1", + sendTo: "destination", +}; + +const rule: AzureServiceBusRule = { + name: "order-filter", + filterType: AzureServiceBusFilterType.CorrelationFilter, + correlationFilter: filter, +}; + +// ── 6. withProperties — callbacks on Queue, Topic, Subscription ──────────── +// TimeSpan properties map to number (ticks) in TypeScript +await queue.withProperties(async (q) => { + // Set all queue properties + await q.deadLetteringOnMessageExpiration.set(true); + await q.defaultMessageTimeToLive.set(36000000000); // 1 hour in ticks + await q.duplicateDetectionHistoryTimeWindow.set(6000000000); // 10 min in ticks + await q.forwardDeadLetteredMessagesTo.set("dead-letter-queue"); + await q.forwardTo.set("forwarding-queue"); + await q.lockDuration.set(300000000); // 30 seconds in ticks + await q.maxDeliveryCount.set(10); + await q.requiresDuplicateDetection.set(true); + await q.requiresSession.set(false); + + // Read back properties to verify getter generation + const _dlq: boolean = await q.deadLetteringOnMessageExpiration.get(); + const _ttl: number = await q.defaultMessageTimeToLive.get(); + const _fwd: string = await q.forwardTo.get(); + const _maxDel: number = await q.maxDeliveryCount.get(); +}); + +await topic.withProperties(async (t) => { + await t.defaultMessageTimeToLive.set(6048000000000); // 7 days in ticks + await t.duplicateDetectionHistoryTimeWindow.set(3000000000); // 5 min in ticks + await t.requiresDuplicateDetection.set(false); + + const _dupDetect: boolean = await t.requiresDuplicateDetection.get(); +}); + +await subscription.withProperties(async (s) => { + await s.deadLetteringOnMessageExpiration.set(true); + await s.defaultMessageTimeToLive.set(72000000000); // 2 hours in ticks + await s.forwardDeadLetteredMessagesTo.set("sub-dlq"); + await s.forwardTo.set("sub-forward"); + await s.lockDuration.set(600000000); // 1 min in ticks + await s.maxDeliveryCount.set(5); + await s.requiresSession.set(false); + + // Read back a subscription property + const _lock: number = await s.lockDuration.get(); + + // Add rules using AspireList.add() and the DTO types + await s.rules.add({ + name: "order-filter", + filterType: AzureServiceBusFilterType.CorrelationFilter, + correlationFilter: filter, + }); + + await s.rules.add({ + name: "sql-filter", + filterType: AzureServiceBusFilterType.SqlFilter, + }); +}); + +// ── 7. withRoleAssignments — string-based role assignment shim ───────────── +// On the parent ServiceBus resource (all 3 roles) +await serviceBus.withRoleAssignments(serviceBus, [ + "AzureServiceBusDataOwner", + "AzureServiceBusDataSender", + "AzureServiceBusDataReceiver", +]); + +// On child resources +await queue.withRoleAssignments(serviceBus, ["AzureServiceBusDataReceiver"]); +await topic.withRoleAssignments(serviceBus, ["AzureServiceBusDataSender"]); +await subscription.withRoleAssignments(serviceBus, ["AzureServiceBusDataReceiver"]); + +// ── 8. Verify enum values are accessible ──────────────────────────────────── +const _sqlFilter = AzureServiceBusFilterType.SqlFilter; +const _correlationFilter = AzureServiceBusFilterType.CorrelationFilter; + +// ── 9. Fluent chaining — verify correct return types enable chaining ─────── +// Queue: factory returns QueueResource, can chain withProperties +await serviceBus + .addServiceBusQueue("chained-queue") + .withProperties(async (_q) => {}); + +// Topic → Subscription chaining +await serviceBus + .addServiceBusTopic("chained-topic") + .addServiceBusSubscription("chained-sub") + .withProperties(async (_s) => {}); + +await builder.build().run(); \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/package-lock.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/package-lock.json new file mode 100644 index 00000000000..d4db9c19de6 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/package-lock.json @@ -0,0 +1,962 @@ +{ + "name": "validationapphost", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "validationapphost", + "version": "1.0.0", + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "nodemon": "^3.1.11", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + } + } +} diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/package.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/package.json new file mode 100644 index 00000000000..be16934198a --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/package.json @@ -0,0 +1,19 @@ +{ + "name": "validationapphost", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "aspire run", + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "vscode-jsonrpc": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "nodemon": "^3.1.11", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } +} \ No newline at end of file diff --git a/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/tsconfig.json b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/tsconfig.json new file mode 100644 index 00000000000..edf7302cc25 --- /dev/null +++ b/playground/polyglot/TypeScript/Aspire.Hosting.Azure.ServiceBus/ValidationAppHost/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": ["apphost.ts", ".modules/**/*.ts"], + "exclude": ["node_modules"] +} \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusCorrelationFilter.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusCorrelationFilter.cs index d5d4cc56548..9c19cc8658d 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusCorrelationFilter.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusCorrelationFilter.cs @@ -6,6 +6,7 @@ namespace Aspire.Hosting.Azure; /// /// Represents the correlation filter expression. /// +[AspireDto] public class AzureServiceBusCorrelationFilter { /// diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs index f1be92cc640..8e534d08694 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusExtensions.cs @@ -3,6 +3,7 @@ #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.Collections.Frozen; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -37,6 +38,7 @@ public static class AzureServiceBusExtensions /// /// These can be replaced by calling . /// + [AspireExport("addAzureServiceBus", Description = "Adds an Azure Service Bus namespace resource")] public static IResourceBuilder AddAzureServiceBus(this IDistributedApplicationBuilder builder, [ResourceName] string name) { ArgumentNullException.ThrowIfNull(builder); @@ -145,6 +147,8 @@ public static IResourceBuilder AddAzureServiceBus(this /// The Azure Service Bus resource builder. /// The name of the queue resource. /// A reference to the . + /// This method is not available in polyglot app hosts. Use instead. + [AspireExportIgnore(Reason = "Obsolete API with incorrect return type. Use AddServiceBusQueue instead.")] [Obsolete($"This method is obsolete because it has the wrong return type and will be removed in a future version. Use {nameof(AddServiceBusQueue)} instead to add an Azure Service Bus Queue.")] public static IResourceBuilder AddQueue(this IResourceBuilder builder, [ResourceName] string name) { @@ -163,6 +167,7 @@ public static IResourceBuilder AddQueue(this IResourceB /// The name of the queue resource. /// The name of the Service Bus Queue. If not provided, this defaults to the same value as . /// A reference to the . + [AspireExport("addServiceBusQueue", Description = "Adds an Azure Service Bus queue resource")] public static IResourceBuilder AddServiceBusQueue(this IResourceBuilder builder, [ResourceName] string name, string? queueName = null) { ArgumentNullException.ThrowIfNull(builder); @@ -183,6 +188,7 @@ public static IResourceBuilder AddServiceBusQueue( /// The Azure Service Bus Queue resource builder. /// A method that can be used for customizing the . /// A reference to the . + [AspireExport("withQueueProperties", MethodName = "withProperties", Description = "Configures properties of an Azure Service Bus queue")] public static IResourceBuilder WithProperties(this IResourceBuilder builder, Action configure) { ArgumentNullException.ThrowIfNull(builder); @@ -198,6 +204,8 @@ public static IResourceBuilder WithProperties(this /// /// The Azure Service Bus resource builder. /// The name of the topic resource. + /// This method is not available in polyglot app hosts. Use instead. + [AspireExportIgnore(Reason = "Obsolete API with incorrect return type. Use AddServiceBusTopic instead.")] [Obsolete($"This method is obsolete because it has the wrong return type and will be removed in a future version. Use {nameof(AddServiceBusTopic)} instead to add an Azure Service Bus Topic.")] public static IResourceBuilder AddTopic(this IResourceBuilder builder, [ResourceName] string name) { @@ -215,6 +223,8 @@ public static IResourceBuilder AddTopic(this IResourceB /// The Azure Service Bus resource builder. /// The name of the topic resource. /// The name of the subscriptions. + /// This method is not available in polyglot app hosts. Use and instead. + [AspireExportIgnore(Reason = "Obsolete API. Use AddServiceBusTopic and AddServiceBusSubscription instead.")] [Obsolete($"This method is obsolete because it has the wrong return type and will be removed in a future version. Use {nameof(AddServiceBusTopic)} and {nameof(AddServiceBusSubscription)} instead to add an Azure Service Bus Topic and Subscriptions.")] public static IResourceBuilder AddTopic(this IResourceBuilder builder, [ResourceName] string name, string[] subscriptions) { @@ -240,6 +250,7 @@ public static IResourceBuilder AddTopic(this IResourceB /// The name of the topic resource. /// The name of the Service Bus Topic. If not provided, this defaults to the same value as . /// A reference to the . + [AspireExport("addServiceBusTopic", Description = "Adds an Azure Service Bus topic resource")] public static IResourceBuilder AddServiceBusTopic(this IResourceBuilder builder, [ResourceName] string name, string? topicName = null) { ArgumentNullException.ThrowIfNull(builder); @@ -260,6 +271,7 @@ public static IResourceBuilder AddServiceBusTopic( /// The Azure Service Bus Topic resource builder. /// A method that can be used for customizing the . /// A reference to the . + [AspireExport("withTopicProperties", MethodName = "withProperties", Description = "Configures properties of an Azure Service Bus topic")] public static IResourceBuilder WithProperties(this IResourceBuilder builder, Action configure) { ArgumentNullException.ThrowIfNull(builder); @@ -277,6 +289,8 @@ public static IResourceBuilder WithProperties(this /// The name of the topic resource. /// The name of the subscription. /// A reference to the . + /// This method is not available in polyglot app hosts. Use instead. + [AspireExportIgnore(Reason = "Obsolete API. Use AddServiceBusSubscription instead.")] [Obsolete($"This method is obsolete and will be removed in a future version. Use {nameof(AddServiceBusSubscription)} instead to add an Azure Service Bus Subscription to a Topic.")] public static IResourceBuilder AddSubscription(this IResourceBuilder builder, string topicName, string subscriptionName) { @@ -306,6 +320,7 @@ public static IResourceBuilder AddSubscription(this IRe /// The name of the subscription resource. /// The name of the Service Bus Subscription. If not provided, this defaults to the same value as . /// A reference to the . + [AspireExport("addServiceBusSubscription", Description = "Adds an Azure Service Bus subscription resource")] public static IResourceBuilder AddServiceBusSubscription(this IResourceBuilder builder, [ResourceName] string name, string? subscriptionName = null) { ArgumentNullException.ThrowIfNull(builder); @@ -326,6 +341,7 @@ public static IResourceBuilder AddServiceBu /// The Azure Service Bus Subscription resource builder. /// A method that can be used for customizing the . /// A reference to the . + [AspireExport("withSubscriptionProperties", MethodName = "withProperties", Description = "Configures properties of an Azure Service Bus subscription")] public static IResourceBuilder WithProperties(this IResourceBuilder builder, Action configure) { ArgumentNullException.ThrowIfNull(builder); @@ -361,6 +377,7 @@ public static IResourceBuilder WithProperti /// /// /// + [AspireExport("runAsEmulator", Description = "Configures the Azure Service Bus resource to run with the local emulator")] public static IResourceBuilder RunAsEmulator(this IResourceBuilder builder, Action>? configureContainer = null) { ArgumentNullException.ThrowIfNull(builder); @@ -497,6 +514,7 @@ public static IResourceBuilder RunAsEmulator(this IReso /// The builder for the . /// Path to the file on the AppHost where the emulator configuration is located. /// A reference to the . + [AspireExport("withConfigurationFile", Description = "Sets the emulator configuration file path")] public static IResourceBuilder WithConfigurationFile(this IResourceBuilder builder, string path) { ArgumentNullException.ThrowIfNull(builder); @@ -512,6 +530,7 @@ public static IResourceBuilder WithConfiguratio /// A callback to update the JSON object representation of the configuration. /// A reference to the . /// + /// This method is not available in polyglot app hosts. Use instead to provide a configuration file. /// /// Here is an example of how to configure the emulator to use a different logging mechanism: /// @@ -527,6 +546,7 @@ public static IResourceBuilder WithConfiguratio /// /// /// + [AspireExportIgnore(Reason = "Action callbacks are not ATS-compatible.")] public static IResourceBuilder WithConfiguration(this IResourceBuilder builder, Action configJson) { ArgumentNullException.ThrowIfNull(builder); @@ -543,6 +563,7 @@ public static IResourceBuilder WithConfiguratio /// Builder for the Azure Service Bus emulator container /// The port to bind on the host. If is used, a random port will be assigned. /// A reference to the . + [AspireExport("withHostPort", Description = "Sets the host port for the Service Bus emulator endpoint")] public static IResourceBuilder WithHostPort(this IResourceBuilder builder, int? port) { ArgumentNullException.ThrowIfNull(builder); @@ -628,8 +649,8 @@ private static string CreateEmulatorConfigJson(AzureServiceBusResource emulatorR /// The built-in Service Bus roles to be assigned. /// The updated with the applied role assignments. /// + /// This overload is not available in polyglot app hosts. Use the string-based overload with role names (e.g., "AzureServiceBusDataSender") instead. /// - /// Assigns the AzureServiceBusDataSender role to the 'Projects.Api' project. /// /// var builder = DistributedApplication.CreateBuilder(args); /// @@ -641,6 +662,7 @@ private static string CreateEmulatorConfigJson(AzureServiceBusResource emulatorR /// /// /// + [AspireExportIgnore(Reason = "ServiceBusBuiltInRole is an Azure.Provisioning type not compatible with ATS. Use the string-based overload instead.")] public static IResourceBuilder WithRoleAssignments( this IResourceBuilder builder, IResourceBuilder target, @@ -649,4 +671,41 @@ public static IResourceBuilder WithRoleAssignments( { return builder.WithRoleAssignments(target, ServiceBusBuiltInRole.GetBuiltInRoleName, roles); } + + /// + /// Assigns the specified roles to the given resource, granting it the necessary permissions + /// on the target Azure Service Bus namespace. This replaces the default role assignments for the resource. + /// + /// The resource to which the specified roles will be assigned. + /// The target Azure Service Bus namespace. + /// The built-in Service Bus role names to be assigned (e.g., "AzureServiceBusDataSender", "AzureServiceBusDataReceiver"). + /// The updated with the applied role assignments. + /// Thrown when a role name is not a valid Service Bus built-in role. + [AspireExport("withRoleAssignments", Description = "Assigns Service Bus roles to a resource")] + internal static IResourceBuilder WithRoleAssignments( + this IResourceBuilder builder, + IResourceBuilder target, + params string[] roles) + where T : IResource + { + var builtInRoles = new ServiceBusBuiltInRole[roles.Length]; + for (var i = 0; i < roles.Length; i++) + { + if (!s_serviceBusRolesByName.TryGetValue(roles[i], out var role)) + { + throw new ArgumentException($"'{roles[i]}' is not a valid Service Bus built-in role. Valid roles: {string.Join(", ", s_serviceBusRolesByName.Keys)}.", nameof(roles)); + } + + builtInRoles[i] = role; + } + + return builder.WithRoleAssignments(target, builtInRoles); + } + + private static readonly FrozenDictionary s_serviceBusRolesByName = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [nameof(ServiceBusBuiltInRole.AzureServiceBusDataOwner)] = ServiceBusBuiltInRole.AzureServiceBusDataOwner, + [nameof(ServiceBusBuiltInRole.AzureServiceBusDataReceiver)] = ServiceBusBuiltInRole.AzureServiceBusDataReceiver, + [nameof(ServiceBusBuiltInRole.AzureServiceBusDataSender)] = ServiceBusBuiltInRole.AzureServiceBusDataSender, + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); } diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusQueueResource.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusQueueResource.cs index 87ac3519049..0e210a93238 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusQueueResource.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusQueueResource.cs @@ -19,6 +19,7 @@ namespace Aspire.Hosting.Azure; /// Use to configure specific properties. /// [DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}, Queue = {QueueName}")] +[AspireExport(ExposeProperties = true)] public class AzureServiceBusQueueResource(string name, string queueName, AzureServiceBusResource parent) : Resource(name), IResourceWithParent, IResourceWithConnectionString, IResourceWithAzureFunctionsConfig { @@ -36,11 +37,15 @@ public string QueueName /// /// Gets the parent Azure Service Bus resource. /// + /// This property is not available in polyglot app hosts. + [AspireExportIgnore] public AzureServiceBusResource Parent { get; } = parent ?? throw new ArgumentNullException(nameof(parent)); /// /// Gets the connection string expression for the Azure Service Bus Queue. /// + /// This property is not available in polyglot app hosts. + [AspireExportIgnore] public ReferenceExpression ConnectionStringExpression => Parent.GetConnectionString(QueueName, null); /// diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusRule.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusRule.cs index 362cd821317..02006de98ef 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusRule.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusRule.cs @@ -15,6 +15,7 @@ namespace Aspire.Hosting.Azure; /// /// Use to configure specific properties. /// +[AspireDto] public class AzureServiceBusRule(string name) { private string _name = ThrowIfNullOrEmpty(name); diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusSubscriptionResource.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusSubscriptionResource.cs index 170a5c825e3..3027ddd8eae 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusSubscriptionResource.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusSubscriptionResource.cs @@ -19,6 +19,7 @@ namespace Aspire.Hosting.Azure; /// Use to configure specific properties. /// [DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}, Subscription = {SubscriptionName}")] +[AspireExport(ExposeProperties = true)] public class AzureServiceBusSubscriptionResource(string name, string subscriptionName, AzureServiceBusTopicResource parent) : Resource(name), IResourceWithParent, IResourceWithConnectionString, IResourceWithAzureFunctionsConfig { @@ -36,11 +37,15 @@ public string SubscriptionName /// /// Gets the parent Azure Service Bus Topic resource. /// + /// This property is not available in polyglot app hosts. + [AspireExportIgnore] public AzureServiceBusTopicResource Parent { get; } = parent ?? throw new ArgumentNullException(nameof(parent)); /// /// Gets the connection string expression for the Azure Service Bus Subscription. /// + /// This property is not available in polyglot app hosts. + [AspireExportIgnore] public ReferenceExpression ConnectionStringExpression => Parent.Parent.GetConnectionString(Parent.TopicName, SubscriptionName); /// diff --git a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusTopicResource.cs b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusTopicResource.cs index fb02ba62126..68386802d43 100644 --- a/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusTopicResource.cs +++ b/src/Aspire.Hosting.Azure.ServiceBus/AzureServiceBusTopicResource.cs @@ -19,6 +19,7 @@ namespace Aspire.Hosting.Azure; /// Use to configure specific properties. /// [DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}, Topic = {TopicName}")] +[AspireExport(ExposeProperties = true)] public class AzureServiceBusTopicResource(string name, string topicName, AzureServiceBusResource parent) : Resource(name), IResourceWithParent, IResourceWithConnectionString, IResourceWithAzureFunctionsConfig { @@ -36,11 +37,15 @@ public string TopicName /// /// Gets the parent Azure Service Bus resource. /// + /// This property is not available in polyglot app hosts. + [AspireExportIgnore] public AzureServiceBusResource Parent { get; } = parent ?? throw new ArgumentNullException(nameof(parent)); /// /// Gets the connection string expression for the Azure Service Bus Topic. /// + /// This property is not available in polyglot app hosts. + [AspireExportIgnore] public ReferenceExpression ConnectionStringExpression => Parent.GetConnectionString(TopicName, null); /// diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs index 4788a2e6a78..9dbf6e393e4 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/AtsTypeScriptCodeGenerator.cs @@ -727,6 +727,18 @@ private void GenerateBuilderClass(BuilderModel builder) WriteLine(" }"); WriteLine(); + // Generate property getters/setters for resource types with ExposeProperties + var getters = builder.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertyGetter).ToList(); + var setters = builder.Capabilities.Where(c => c.CapabilityKind == AtsCapabilityKind.PropertySetter).ToList(); + if (getters.Count > 0 || setters.Count > 0) + { + var properties = GroupPropertiesByName(getters, setters); + foreach (var prop in properties) + { + GeneratePropertyLikeObject(prop.PropertyName, prop.Getter, prop.Setter); + } + } + // Generate internal methods and public fluent methods // Capabilities are already flattened - no need to collect from parents // Filter out property getters and setters - they are not methods @@ -780,9 +792,24 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab // Use the actual target parameter name from the capability (e.g., "resource" for withReference) var targetParamName = capability.TargetParameterName ?? "builder"; - // Determine return type - use the builder's own type for fluent methods + // Determine return type - for factory methods returning a different builder type, + // use the return type's class name instead of the receiver's. + // Generic fluent methods (e.g., WithDataVolume()) have ReturnType resolved to + // the constraint type, which equals TargetTypeId — these stay self-returning. + // Factory methods (e.g., AddDatabase) return a type different from both the builder + // AND the target type — these use the actual return type. + var returnTypeId = builder.TypeId; + var returnClassName = builder.BuilderClassName; + if (capability.ReturnsBuilder && capability.ReturnType?.TypeId != null && + !string.Equals(capability.ReturnType.TypeId, builder.TypeId, StringComparison.Ordinal) && + !string.Equals(capability.ReturnType.TypeId, capability.TargetTypeId, StringComparison.Ordinal)) + { + returnTypeId = capability.ReturnType.TypeId; + returnClassName = _wrapperClassNames.GetValueOrDefault(returnTypeId) + ?? DeriveClassName(returnTypeId); + } var returnHandle = capability.ReturnsBuilder - ? GetHandleTypeName(builder.TypeId) + ? GetHandleTypeName(returnTypeId) : "void"; var returnsBuilder = capability.ReturnsBuilder; @@ -837,7 +864,7 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab WriteLine($" /** @internal */"); Write($" private async {internalMethodName}("); Write(internalParamsString); - Write($"): Promise<{builder.BuilderClassName}> {{"); + Write($"): Promise<{returnClassName}> {{"); WriteLine(); // Handle callback registration if any @@ -863,7 +890,7 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab WriteLine($" '{capability.CapabilityId}',"); WriteLine($" rpcArgs"); WriteLine(" );"); - WriteLine($" return new {builder.BuilderClassName}(result, this._client);"); + WriteLine($" return new {returnClassName}(result, this._client);"); } else { @@ -881,7 +908,7 @@ private void GenerateBuilderMethod(BuilderModel builder, AtsCapabilityInfo capab { WriteLine($" /** {capability.Description} */"); } - var promiseClass = $"{builder.BuilderClassName}Promise"; + var promiseClass = $"{returnClassName}Promise"; Write($" {methodName}("); Write(publicParamsString); Write($"): {promiseClass} {{"); @@ -1029,13 +1056,24 @@ private void GenerateThenableClass(BuilderModel builder) } else { - // For fluent builder methods, call the public method which wraps the internal + // For fluent builder methods, determine the correct promise class. + // Factory methods returning a different builder type use the return type's promise class. + var methodPromiseClass = promiseClass; + if (capability.ReturnsBuilder && capability.ReturnType?.TypeId != null && + !string.Equals(capability.ReturnType.TypeId, builder.TypeId, StringComparison.Ordinal) && + !string.Equals(capability.ReturnType.TypeId, capability.TargetTypeId, StringComparison.Ordinal)) + { + var returnClass = _wrapperClassNames.GetValueOrDefault(capability.ReturnType.TypeId) + ?? DeriveClassName(capability.ReturnType.TypeId); + methodPromiseClass = $"{returnClass}Promise"; + } + Write($" {methodName}("); Write(paramsString); - Write($"): {promiseClass} {{"); + Write($"): {methodPromiseClass} {{"); WriteLine(); // Forward to the public method on the underlying object, wrapping result in promise class - Write($" return new {promiseClass}(this._promise.then(obj => obj.{methodName}("); + Write($" return new {methodPromiseClass}(this._promise.then(obj => obj.{methodName}("); Write(argsString); WriteLine(")));"); WriteLine(" }"); diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/AtsGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/AtsGeneratedAspire.verified.go index 58af17b99c2..9b5d1888fb3 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/AtsGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/AtsGeneratedAspire.verified.go @@ -270,6 +270,222 @@ func (s *TestCollectionContext) Metadata() *AspireDict[string, string] { return s.metadata } +// TestDatabaseResource wraps a handle for Aspire.Hosting.CodeGeneration.Go.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource. +type TestDatabaseResource struct { + ResourceBuilderBase +} + +// NewTestDatabaseResource creates a new TestDatabaseResource. +func NewTestDatabaseResource(handle *Handle, client *AspireClient) *TestDatabaseResource { + return &TestDatabaseResource{ + ResourceBuilderBase: NewResourceBuilderBase(handle, client), + } +} + +// WithOptionalString adds an optional string parameter +func (s *TestDatabaseResource) WithOptionalString(value string, enabled bool) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["value"] = SerializeValue(value) + reqArgs["enabled"] = SerializeValue(enabled) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withOptionalString", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithConfig configures the resource with a DTO +func (s *TestDatabaseResource) WithConfig(config *TestConfigDto) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["config"] = SerializeValue(config) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withConfig", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// TestWithEnvironmentCallback configures environment with callback (test version) +func (s *TestDatabaseResource) TestWithEnvironmentCallback(callback func(...any) any) (*IResourceWithEnvironment, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if callback != nil { + reqArgs["callback"] = RegisterCallback(callback) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/testWithEnvironmentCallback", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEnvironment), nil +} + +// WithCreatedAt sets the created timestamp +func (s *TestDatabaseResource) WithCreatedAt(createdAt string) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["createdAt"] = SerializeValue(createdAt) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withCreatedAt", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithModifiedAt sets the modified timestamp +func (s *TestDatabaseResource) WithModifiedAt(modifiedAt string) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["modifiedAt"] = SerializeValue(modifiedAt) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withModifiedAt", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithCorrelationId sets the correlation ID +func (s *TestDatabaseResource) WithCorrelationId(correlationId string) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["correlationId"] = SerializeValue(correlationId) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withCorrelationId", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithOptionalCallback configures with optional callback +func (s *TestDatabaseResource) WithOptionalCallback(callback func(...any) any) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if callback != nil { + reqArgs["callback"] = RegisterCallback(callback) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withOptionalCallback", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithStatus sets the resource status +func (s *TestDatabaseResource) WithStatus(status TestResourceStatus) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["status"] = SerializeValue(status) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withStatus", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithNestedConfig configures with nested DTO +func (s *TestDatabaseResource) WithNestedConfig(config *TestNestedDto) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["config"] = SerializeValue(config) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withNestedConfig", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithValidator adds validation callback +func (s *TestDatabaseResource) WithValidator(validator func(...any) any) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if validator != nil { + reqArgs["validator"] = RegisterCallback(validator) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withValidator", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// TestWaitFor waits for another resource (test version) +func (s *TestDatabaseResource) TestWaitFor(dependency *IResource) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["dependency"] = SerializeValue(dependency) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/testWaitFor", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithDependency adds a dependency on another resource +func (s *TestDatabaseResource) WithDependency(dependency *IResourceWithConnectionString) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["dependency"] = SerializeValue(dependency) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withDependency", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithEndpoints sets the endpoints +func (s *TestDatabaseResource) WithEndpoints(endpoints []string) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["endpoints"] = SerializeValue(endpoints) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withEndpoints", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithEnvironmentVariables sets environment variables +func (s *TestDatabaseResource) WithEnvironmentVariables(variables map[string]string) (*IResourceWithEnvironment, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["variables"] = SerializeValue(variables) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withEnvironmentVariables", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEnvironment), nil +} + +// WithCancellableOperation performs a cancellable operation +func (s *TestDatabaseResource) WithCancellableOperation(operation func(...any) any) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if operation != nil { + reqArgs["operation"] = RegisterCallback(operation) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withCancellableOperation", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + // TestEnvironmentContext wraps a handle for Aspire.Hosting.CodeGeneration.Go.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext. type TestEnvironmentContext struct { HandleWrapperBase @@ -371,6 +587,20 @@ func NewTestRedisResource(handle *Handle, client *AspireClient) *TestRedisResour } } +// AddTestChildDatabase adds a child database to a test Redis resource +func (s *TestRedisResource) AddTestChildDatabase(name string, databaseName string) (*TestDatabaseResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["name"] = SerializeValue(name) + reqArgs["databaseName"] = SerializeValue(databaseName) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/addTestChildDatabase", reqArgs) + if err != nil { + return nil, err + } + return result.(*TestDatabaseResource), nil +} + // WithPersistence configures the Redis resource with persistence func (s *TestRedisResource) WithPersistence(mode TestPersistenceMode) (*TestRedisResource, error) { reqArgs := map[string]any{ @@ -802,6 +1032,9 @@ func init() { RegisterHandleWrapper("Aspire.Hosting.CodeGeneration.Go.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestRedisResource", func(h *Handle, c *AspireClient) any { return NewTestRedisResource(h, c) }) + RegisterHandleWrapper("Aspire.Hosting.CodeGeneration.Go.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource", func(h *Handle, c *AspireClient) any { + return NewTestDatabaseResource(h, c) + }) RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource", func(h *Handle, c *AspireClient) any { return NewIResource(h, c) }) diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index c80f4980418..667f0067740 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -3541,6 +3541,779 @@ func (s *TestCollectionContext) Metadata() *AspireDict[string, string] { return s.metadata } +// TestDatabaseResource wraps a handle for Aspire.Hosting.CodeGeneration.Go.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource. +type TestDatabaseResource struct { + ResourceBuilderBase +} + +// NewTestDatabaseResource creates a new TestDatabaseResource. +func NewTestDatabaseResource(handle *Handle, client *AspireClient) *TestDatabaseResource { + return &TestDatabaseResource{ + ResourceBuilderBase: NewResourceBuilderBase(handle, client), + } +} + +// WithBindMount adds a bind mount +func (s *TestDatabaseResource) WithBindMount(source string, target string, isReadOnly bool) (*ContainerResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["source"] = SerializeValue(source) + reqArgs["target"] = SerializeValue(target) + reqArgs["isReadOnly"] = SerializeValue(isReadOnly) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withBindMount", reqArgs) + if err != nil { + return nil, err + } + return result.(*ContainerResource), nil +} + +// WithEntrypoint sets the container entrypoint +func (s *TestDatabaseResource) WithEntrypoint(entrypoint string) (*ContainerResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["entrypoint"] = SerializeValue(entrypoint) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withEntrypoint", reqArgs) + if err != nil { + return nil, err + } + return result.(*ContainerResource), nil +} + +// WithImageTag sets the container image tag +func (s *TestDatabaseResource) WithImageTag(tag string) (*ContainerResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["tag"] = SerializeValue(tag) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withImageTag", reqArgs) + if err != nil { + return nil, err + } + return result.(*ContainerResource), nil +} + +// WithImageRegistry sets the container image registry +func (s *TestDatabaseResource) WithImageRegistry(registry string) (*ContainerResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["registry"] = SerializeValue(registry) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withImageRegistry", reqArgs) + if err != nil { + return nil, err + } + return result.(*ContainerResource), nil +} + +// WithImage sets the container image +func (s *TestDatabaseResource) WithImage(image string, tag string) (*ContainerResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["image"] = SerializeValue(image) + reqArgs["tag"] = SerializeValue(tag) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withImage", reqArgs) + if err != nil { + return nil, err + } + return result.(*ContainerResource), nil +} + +// WithContainerRuntimeArgs adds runtime arguments for the container +func (s *TestDatabaseResource) WithContainerRuntimeArgs(args []string) (*ContainerResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["args"] = SerializeValue(args) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withContainerRuntimeArgs", reqArgs) + if err != nil { + return nil, err + } + return result.(*ContainerResource), nil +} + +// WithLifetime sets the lifetime behavior of the container resource +func (s *TestDatabaseResource) WithLifetime(lifetime ContainerLifetime) (*ContainerResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["lifetime"] = SerializeValue(lifetime) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withLifetime", reqArgs) + if err != nil { + return nil, err + } + return result.(*ContainerResource), nil +} + +// WithImagePullPolicy sets the container image pull policy +func (s *TestDatabaseResource) WithImagePullPolicy(pullPolicy ImagePullPolicy) (*ContainerResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["pullPolicy"] = SerializeValue(pullPolicy) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withImagePullPolicy", reqArgs) + if err != nil { + return nil, err + } + return result.(*ContainerResource), nil +} + +// WithContainerName sets the container name +func (s *TestDatabaseResource) WithContainerName(name string) (*ContainerResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["name"] = SerializeValue(name) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withContainerName", reqArgs) + if err != nil { + return nil, err + } + return result.(*ContainerResource), nil +} + +// WithEnvironment sets an environment variable +func (s *TestDatabaseResource) WithEnvironment(name string, value string) (*IResourceWithEnvironment, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["name"] = SerializeValue(name) + reqArgs["value"] = SerializeValue(value) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withEnvironment", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEnvironment), nil +} + +// WithEnvironmentExpression adds an environment variable with a reference expression +func (s *TestDatabaseResource) WithEnvironmentExpression(name string, value *ReferenceExpression) (*IResourceWithEnvironment, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["name"] = SerializeValue(name) + reqArgs["value"] = SerializeValue(value) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withEnvironmentExpression", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEnvironment), nil +} + +// WithEnvironmentCallback sets environment variables via callback +func (s *TestDatabaseResource) WithEnvironmentCallback(callback func(...any) any) (*IResourceWithEnvironment, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if callback != nil { + reqArgs["callback"] = RegisterCallback(callback) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withEnvironmentCallback", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEnvironment), nil +} + +// WithEnvironmentCallbackAsync sets environment variables via async callback +func (s *TestDatabaseResource) WithEnvironmentCallbackAsync(callback func(...any) any) (*IResourceWithEnvironment, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if callback != nil { + reqArgs["callback"] = RegisterCallback(callback) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withEnvironmentCallbackAsync", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEnvironment), nil +} + +// WithArgs adds arguments +func (s *TestDatabaseResource) WithArgs(args []string) (*IResourceWithArgs, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["args"] = SerializeValue(args) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withArgs", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithArgs), nil +} + +// WithArgsCallback sets command-line arguments via callback +func (s *TestDatabaseResource) WithArgsCallback(callback func(...any) any) (*IResourceWithArgs, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if callback != nil { + reqArgs["callback"] = RegisterCallback(callback) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withArgsCallback", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithArgs), nil +} + +// WithArgsCallbackAsync sets command-line arguments via async callback +func (s *TestDatabaseResource) WithArgsCallbackAsync(callback func(...any) any) (*IResourceWithArgs, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if callback != nil { + reqArgs["callback"] = RegisterCallback(callback) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withArgsCallbackAsync", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithArgs), nil +} + +// WithReference adds a reference to another resource +func (s *TestDatabaseResource) WithReference(source *IResourceWithConnectionString, connectionName string, optional bool) (*IResourceWithEnvironment, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["source"] = SerializeValue(source) + reqArgs["connectionName"] = SerializeValue(connectionName) + reqArgs["optional"] = SerializeValue(optional) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withReference", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEnvironment), nil +} + +// WithServiceReference adds a service discovery reference to another resource +func (s *TestDatabaseResource) WithServiceReference(source *IResourceWithServiceDiscovery) (*IResourceWithEnvironment, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["source"] = SerializeValue(source) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withServiceReference", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEnvironment), nil +} + +// WithEndpoint adds a network endpoint +func (s *TestDatabaseResource) WithEndpoint(port float64, targetPort float64, scheme string, name string, env string, isProxied bool, isExternal bool, protocol ProtocolType) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["port"] = SerializeValue(port) + reqArgs["targetPort"] = SerializeValue(targetPort) + reqArgs["scheme"] = SerializeValue(scheme) + reqArgs["name"] = SerializeValue(name) + reqArgs["env"] = SerializeValue(env) + reqArgs["isProxied"] = SerializeValue(isProxied) + reqArgs["isExternal"] = SerializeValue(isExternal) + reqArgs["protocol"] = SerializeValue(protocol) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withEndpoint", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + +// WithHttpEndpoint adds an HTTP endpoint +func (s *TestDatabaseResource) WithHttpEndpoint(port float64, targetPort float64, name string, env string, isProxied bool) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["port"] = SerializeValue(port) + reqArgs["targetPort"] = SerializeValue(targetPort) + reqArgs["name"] = SerializeValue(name) + reqArgs["env"] = SerializeValue(env) + reqArgs["isProxied"] = SerializeValue(isProxied) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withHttpEndpoint", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + +// WithHttpsEndpoint adds an HTTPS endpoint +func (s *TestDatabaseResource) WithHttpsEndpoint(port float64, targetPort float64, name string, env string, isProxied bool) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["port"] = SerializeValue(port) + reqArgs["targetPort"] = SerializeValue(targetPort) + reqArgs["name"] = SerializeValue(name) + reqArgs["env"] = SerializeValue(env) + reqArgs["isProxied"] = SerializeValue(isProxied) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withHttpsEndpoint", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + +// WithExternalHttpEndpoints makes HTTP endpoints externally accessible +func (s *TestDatabaseResource) WithExternalHttpEndpoints() (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withExternalHttpEndpoints", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + +// GetEndpoint gets an endpoint reference +func (s *TestDatabaseResource) GetEndpoint(name string) (*EndpointReference, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["name"] = SerializeValue(name) + result, err := s.Client().InvokeCapability("Aspire.Hosting/getEndpoint", reqArgs) + if err != nil { + return nil, err + } + return result.(*EndpointReference), nil +} + +// AsHttp2Service configures resource for HTTP/2 +func (s *TestDatabaseResource) AsHttp2Service() (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/asHttp2Service", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + +// WithUrlsCallback customizes displayed URLs via callback +func (s *TestDatabaseResource) WithUrlsCallback(callback func(...any) any) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if callback != nil { + reqArgs["callback"] = RegisterCallback(callback) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withUrlsCallback", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithUrlsCallbackAsync customizes displayed URLs via async callback +func (s *TestDatabaseResource) WithUrlsCallbackAsync(callback func(...any) any) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if callback != nil { + reqArgs["callback"] = RegisterCallback(callback) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withUrlsCallbackAsync", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithUrl adds or modifies displayed URLs +func (s *TestDatabaseResource) WithUrl(url string, displayText string) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["url"] = SerializeValue(url) + reqArgs["displayText"] = SerializeValue(displayText) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withUrl", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithUrlExpression adds a URL using a reference expression +func (s *TestDatabaseResource) WithUrlExpression(url *ReferenceExpression, displayText string) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["url"] = SerializeValue(url) + reqArgs["displayText"] = SerializeValue(displayText) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withUrlExpression", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithUrlForEndpoint customizes the URL for a specific endpoint via callback +func (s *TestDatabaseResource) WithUrlForEndpoint(endpointName string, callback func(...any) any) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["endpointName"] = SerializeValue(endpointName) + if callback != nil { + reqArgs["callback"] = RegisterCallback(callback) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withUrlForEndpoint", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithUrlForEndpointFactory adds a URL for a specific endpoint via factory callback +func (s *TestDatabaseResource) WithUrlForEndpointFactory(endpointName string, callback func(...any) any) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["endpointName"] = SerializeValue(endpointName) + if callback != nil { + reqArgs["callback"] = RegisterCallback(callback) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withUrlForEndpointFactory", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + +// WaitFor waits for another resource to be ready +func (s *TestDatabaseResource) WaitFor(dependency *IResource) (*IResourceWithWaitSupport, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["dependency"] = SerializeValue(dependency) + result, err := s.Client().InvokeCapability("Aspire.Hosting/waitFor", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithWaitSupport), nil +} + +// WithExplicitStart prevents resource from starting automatically +func (s *TestDatabaseResource) WithExplicitStart() (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withExplicitStart", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WaitForCompletion waits for resource completion +func (s *TestDatabaseResource) WaitForCompletion(dependency *IResource, exitCode float64) (*IResourceWithWaitSupport, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["dependency"] = SerializeValue(dependency) + reqArgs["exitCode"] = SerializeValue(exitCode) + result, err := s.Client().InvokeCapability("Aspire.Hosting/waitForCompletion", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithWaitSupport), nil +} + +// WithHealthCheck adds a health check by key +func (s *TestDatabaseResource) WithHealthCheck(key string) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["key"] = SerializeValue(key) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withHealthCheck", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithHttpHealthCheck adds an HTTP health check +func (s *TestDatabaseResource) WithHttpHealthCheck(path string, statusCode float64, endpointName string) (*IResourceWithEndpoints, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["path"] = SerializeValue(path) + reqArgs["statusCode"] = SerializeValue(statusCode) + reqArgs["endpointName"] = SerializeValue(endpointName) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withHttpHealthCheck", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEndpoints), nil +} + +// WithCommand adds a resource command +func (s *TestDatabaseResource) WithCommand(name string, displayName string, executeCommand func(...any) any, commandOptions *CommandOptions) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["name"] = SerializeValue(name) + reqArgs["displayName"] = SerializeValue(displayName) + if executeCommand != nil { + reqArgs["executeCommand"] = RegisterCallback(executeCommand) + } + if commandOptions != nil { + reqArgs["commandOptions"] = SerializeValue(commandOptions) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/withCommand", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithParentRelationship sets the parent relationship +func (s *TestDatabaseResource) WithParentRelationship(parent *IResource) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["parent"] = SerializeValue(parent) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withParentRelationship", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithVolume adds a volume +func (s *TestDatabaseResource) WithVolume(target string, name string, isReadOnly bool) (*ContainerResource, error) { + reqArgs := map[string]any{ + "resource": SerializeValue(s.Handle()), + } + reqArgs["target"] = SerializeValue(target) + reqArgs["name"] = SerializeValue(name) + reqArgs["isReadOnly"] = SerializeValue(isReadOnly) + result, err := s.Client().InvokeCapability("Aspire.Hosting/withVolume", reqArgs) + if err != nil { + return nil, err + } + return result.(*ContainerResource), nil +} + +// GetResourceName gets the resource name +func (s *TestDatabaseResource) GetResourceName() (*string, error) { + reqArgs := map[string]any{ + "resource": SerializeValue(s.Handle()), + } + result, err := s.Client().InvokeCapability("Aspire.Hosting/getResourceName", reqArgs) + if err != nil { + return nil, err + } + return result.(*string), nil +} + +// WithOptionalString adds an optional string parameter +func (s *TestDatabaseResource) WithOptionalString(value string, enabled bool) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["value"] = SerializeValue(value) + reqArgs["enabled"] = SerializeValue(enabled) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withOptionalString", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithConfig configures the resource with a DTO +func (s *TestDatabaseResource) WithConfig(config *TestConfigDto) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["config"] = SerializeValue(config) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withConfig", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// TestWithEnvironmentCallback configures environment with callback (test version) +func (s *TestDatabaseResource) TestWithEnvironmentCallback(callback func(...any) any) (*IResourceWithEnvironment, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if callback != nil { + reqArgs["callback"] = RegisterCallback(callback) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/testWithEnvironmentCallback", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEnvironment), nil +} + +// WithCreatedAt sets the created timestamp +func (s *TestDatabaseResource) WithCreatedAt(createdAt string) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["createdAt"] = SerializeValue(createdAt) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withCreatedAt", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithModifiedAt sets the modified timestamp +func (s *TestDatabaseResource) WithModifiedAt(modifiedAt string) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["modifiedAt"] = SerializeValue(modifiedAt) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withModifiedAt", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithCorrelationId sets the correlation ID +func (s *TestDatabaseResource) WithCorrelationId(correlationId string) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["correlationId"] = SerializeValue(correlationId) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withCorrelationId", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithOptionalCallback configures with optional callback +func (s *TestDatabaseResource) WithOptionalCallback(callback func(...any) any) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if callback != nil { + reqArgs["callback"] = RegisterCallback(callback) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withOptionalCallback", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithStatus sets the resource status +func (s *TestDatabaseResource) WithStatus(status TestResourceStatus) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["status"] = SerializeValue(status) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withStatus", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithNestedConfig configures with nested DTO +func (s *TestDatabaseResource) WithNestedConfig(config *TestNestedDto) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["config"] = SerializeValue(config) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withNestedConfig", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithValidator adds validation callback +func (s *TestDatabaseResource) WithValidator(validator func(...any) any) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if validator != nil { + reqArgs["validator"] = RegisterCallback(validator) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withValidator", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// TestWaitFor waits for another resource (test version) +func (s *TestDatabaseResource) TestWaitFor(dependency *IResource) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["dependency"] = SerializeValue(dependency) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/testWaitFor", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithDependency adds a dependency on another resource +func (s *TestDatabaseResource) WithDependency(dependency *IResourceWithConnectionString) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["dependency"] = SerializeValue(dependency) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withDependency", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithEndpoints sets the endpoints +func (s *TestDatabaseResource) WithEndpoints(endpoints []string) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["endpoints"] = SerializeValue(endpoints) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withEndpoints", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + +// WithEnvironmentVariables sets environment variables +func (s *TestDatabaseResource) WithEnvironmentVariables(variables map[string]string) (*IResourceWithEnvironment, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["variables"] = SerializeValue(variables) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withEnvironmentVariables", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResourceWithEnvironment), nil +} + +// WithCancellableOperation performs a cancellable operation +func (s *TestDatabaseResource) WithCancellableOperation(operation func(...any) any) (*IResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + if operation != nil { + reqArgs["operation"] = RegisterCallback(operation) + } + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/withCancellableOperation", reqArgs) + if err != nil { + return nil, err + } + return result.(*IResource), nil +} + // TestEnvironmentContext wraps a handle for Aspire.Hosting.CodeGeneration.Go.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext. type TestEnvironmentContext struct { HandleWrapperBase @@ -4199,6 +4972,20 @@ func (s *TestRedisResource) GetResourceName() (*string, error) { return result.(*string), nil } +// AddTestChildDatabase adds a child database to a test Redis resource +func (s *TestRedisResource) AddTestChildDatabase(name string, databaseName string) (*TestDatabaseResource, error) { + reqArgs := map[string]any{ + "builder": SerializeValue(s.Handle()), + } + reqArgs["name"] = SerializeValue(name) + reqArgs["databaseName"] = SerializeValue(databaseName) + result, err := s.Client().InvokeCapability("Aspire.Hosting.CodeGeneration.Go.Tests/addTestChildDatabase", reqArgs) + if err != nil { + return nil, err + } + return result.(*TestDatabaseResource), nil +} + // WithPersistence configures the Redis resource with persistence func (s *TestRedisResource) WithPersistence(mode TestPersistenceMode) (*TestRedisResource, error) { reqArgs := map[string]any{ @@ -4711,6 +5498,9 @@ func init() { RegisterHandleWrapper("Aspire.Hosting.CodeGeneration.Go.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestRedisResource", func(h *Handle, c *AspireClient) any { return NewTestRedisResource(h, c) }) + RegisterHandleWrapper("Aspire.Hosting.CodeGeneration.Go.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource", func(h *Handle, c *AspireClient) any { + return NewTestDatabaseResource(h, c) + }) RegisterHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment", func(h *Handle, c *AspireClient) any { return NewIResourceWithEnvironment(h, c) }) diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/WithOptionalStringCapability.verified.txt b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/WithOptionalStringCapability.verified.txt index 3ce17af2c65..97f11ab024e 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/WithOptionalStringCapability.verified.txt +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/WithOptionalStringCapability.verified.txt @@ -68,6 +68,16 @@ IsResourceBuilder: true, IsDistributedApplicationBuilder: false, IsDistributedApplication: false + }, + { + TypeId: Aspire.Hosting.CodeGeneration.Go.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource, + ClrType: TestDatabaseResource, + Category: Handle, + IsInterface: false, + IsReadOnly: false, + IsResourceBuilder: true, + IsDistributedApplicationBuilder: false, + IsDistributedApplication: false } ], ReturnsBuilder: true, diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/AtsGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/AtsGeneratedAspire.verified.java index eca0a237d4d..70413c37e23 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/AtsGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/AtsGeneratedAspire.verified.java @@ -257,6 +257,147 @@ public AspireDict metadata() { } +/** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource. */ +class TestDatabaseResource extends ResourceBuilderBase { + TestDatabaseResource(Handle handle, AspireClient client) { + super(handle, client); + } + + /** Adds an optional string parameter */ + public IResource withOptionalString(String value, Boolean enabled) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (value != null) { + reqArgs.put("value", AspireClient.serializeValue(value)); + } + if (enabled != null) { + reqArgs.put("enabled", AspireClient.serializeValue(enabled)); + } + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withOptionalString", reqArgs); + } + + /** Configures the resource with a DTO */ + public IResource withConfig(TestConfigDto config) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("config", AspireClient.serializeValue(config)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withConfig", reqArgs); + } + + /** Configures environment with callback (test version) */ + public IResourceWithEnvironment testWithEnvironmentCallback(Function callback) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (callback != null) { + reqArgs.put("callback", getClient().registerCallback(callback)); + } + return (IResourceWithEnvironment) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/testWithEnvironmentCallback", reqArgs); + } + + /** Sets the created timestamp */ + public IResource withCreatedAt(String createdAt) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("createdAt", AspireClient.serializeValue(createdAt)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withCreatedAt", reqArgs); + } + + /** Sets the modified timestamp */ + public IResource withModifiedAt(String modifiedAt) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("modifiedAt", AspireClient.serializeValue(modifiedAt)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withModifiedAt", reqArgs); + } + + /** Sets the correlation ID */ + public IResource withCorrelationId(String correlationId) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("correlationId", AspireClient.serializeValue(correlationId)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withCorrelationId", reqArgs); + } + + /** Configures with optional callback */ + public IResource withOptionalCallback(Function callback) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (callback != null) { + reqArgs.put("callback", getClient().registerCallback(callback)); + } + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withOptionalCallback", reqArgs); + } + + /** Sets the resource status */ + public IResource withStatus(TestResourceStatus status) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("status", AspireClient.serializeValue(status)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withStatus", reqArgs); + } + + /** Configures with nested DTO */ + public IResource withNestedConfig(TestNestedDto config) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("config", AspireClient.serializeValue(config)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withNestedConfig", reqArgs); + } + + /** Adds validation callback */ + public IResource withValidator(Function validator) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (validator != null) { + reqArgs.put("validator", getClient().registerCallback(validator)); + } + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withValidator", reqArgs); + } + + /** Waits for another resource (test version) */ + public IResource testWaitFor(IResource dependency) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("dependency", AspireClient.serializeValue(dependency)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/testWaitFor", reqArgs); + } + + /** Adds a dependency on another resource */ + public IResource withDependency(IResourceWithConnectionString dependency) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("dependency", AspireClient.serializeValue(dependency)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withDependency", reqArgs); + } + + /** Sets the endpoints */ + public IResource withEndpoints(String[] endpoints) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("endpoints", AspireClient.serializeValue(endpoints)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withEndpoints", reqArgs); + } + + /** Sets environment variables */ + public IResourceWithEnvironment withEnvironmentVariables(Map variables) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("variables", AspireClient.serializeValue(variables)); + return (IResourceWithEnvironment) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withEnvironmentVariables", reqArgs); + } + + /** Performs a cancellable operation */ + public IResource withCancellableOperation(Function operation) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (operation != null) { + reqArgs.put("operation", getClient().registerCallback(operation)); + } + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withCancellableOperation", reqArgs); + } + +} + /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext. */ class TestEnvironmentContext extends HandleWrapperBase { TestEnvironmentContext(Handle handle, AspireClient client) { @@ -316,6 +457,17 @@ class TestRedisResource extends ResourceBuilderBase { super(handle, client); } + /** Adds a child database to a test Redis resource */ + public TestDatabaseResource addTestChildDatabase(String name, String databaseName) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("name", AspireClient.serializeValue(name)); + if (databaseName != null) { + reqArgs.put("databaseName", AspireClient.serializeValue(databaseName)); + } + return (TestDatabaseResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/addTestChildDatabase", reqArgs); + } + /** Configures the Redis resource with persistence */ public TestRedisResource withPersistence(TestPersistenceMode mode) { Map reqArgs = new HashMap<>(); @@ -603,6 +755,7 @@ class AspireRegistrations { AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext", (h, c) -> new TestEnvironmentContext(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCollectionContext", (h, c) -> new TestCollectionContext(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestRedisResource", (h, c) -> new TestRedisResource(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource", (h, c) -> new TestDatabaseResource(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource", (h, c) -> new IResource(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithConnectionString", (h, c) -> new IResourceWithConnectionString(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", (h, c) -> new IDistributedApplicationBuilder(h, c)); diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index b52d228294d..4c8d580b64a 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -2765,6 +2765,569 @@ public AspireDict metadata() { } +/** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource. */ +class TestDatabaseResource extends ResourceBuilderBase { + TestDatabaseResource(Handle handle, AspireClient client) { + super(handle, client); + } + + /** Adds a bind mount */ + public ContainerResource withBindMount(String source, String target, Boolean isReadOnly) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("source", AspireClient.serializeValue(source)); + reqArgs.put("target", AspireClient.serializeValue(target)); + if (isReadOnly != null) { + reqArgs.put("isReadOnly", AspireClient.serializeValue(isReadOnly)); + } + return (ContainerResource) getClient().invokeCapability("Aspire.Hosting/withBindMount", reqArgs); + } + + /** Sets the container entrypoint */ + public ContainerResource withEntrypoint(String entrypoint) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("entrypoint", AspireClient.serializeValue(entrypoint)); + return (ContainerResource) getClient().invokeCapability("Aspire.Hosting/withEntrypoint", reqArgs); + } + + /** Sets the container image tag */ + public ContainerResource withImageTag(String tag) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("tag", AspireClient.serializeValue(tag)); + return (ContainerResource) getClient().invokeCapability("Aspire.Hosting/withImageTag", reqArgs); + } + + /** Sets the container image registry */ + public ContainerResource withImageRegistry(String registry) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("registry", AspireClient.serializeValue(registry)); + return (ContainerResource) getClient().invokeCapability("Aspire.Hosting/withImageRegistry", reqArgs); + } + + /** Sets the container image */ + public ContainerResource withImage(String image, String tag) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("image", AspireClient.serializeValue(image)); + if (tag != null) { + reqArgs.put("tag", AspireClient.serializeValue(tag)); + } + return (ContainerResource) getClient().invokeCapability("Aspire.Hosting/withImage", reqArgs); + } + + /** Adds runtime arguments for the container */ + public ContainerResource withContainerRuntimeArgs(String[] args) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("args", AspireClient.serializeValue(args)); + return (ContainerResource) getClient().invokeCapability("Aspire.Hosting/withContainerRuntimeArgs", reqArgs); + } + + /** Sets the lifetime behavior of the container resource */ + public ContainerResource withLifetime(ContainerLifetime lifetime) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("lifetime", AspireClient.serializeValue(lifetime)); + return (ContainerResource) getClient().invokeCapability("Aspire.Hosting/withLifetime", reqArgs); + } + + /** Sets the container image pull policy */ + public ContainerResource withImagePullPolicy(ImagePullPolicy pullPolicy) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("pullPolicy", AspireClient.serializeValue(pullPolicy)); + return (ContainerResource) getClient().invokeCapability("Aspire.Hosting/withImagePullPolicy", reqArgs); + } + + /** Sets the container name */ + public ContainerResource withContainerName(String name) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("name", AspireClient.serializeValue(name)); + return (ContainerResource) getClient().invokeCapability("Aspire.Hosting/withContainerName", reqArgs); + } + + /** Sets an environment variable */ + public IResourceWithEnvironment withEnvironment(String name, String value) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("name", AspireClient.serializeValue(name)); + reqArgs.put("value", AspireClient.serializeValue(value)); + return (IResourceWithEnvironment) getClient().invokeCapability("Aspire.Hosting/withEnvironment", reqArgs); + } + + /** Adds an environment variable with a reference expression */ + public IResourceWithEnvironment withEnvironmentExpression(String name, ReferenceExpression value) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("name", AspireClient.serializeValue(name)); + reqArgs.put("value", AspireClient.serializeValue(value)); + return (IResourceWithEnvironment) getClient().invokeCapability("Aspire.Hosting/withEnvironmentExpression", reqArgs); + } + + /** Sets environment variables via callback */ + public IResourceWithEnvironment withEnvironmentCallback(Function callback) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (callback != null) { + reqArgs.put("callback", getClient().registerCallback(callback)); + } + return (IResourceWithEnvironment) getClient().invokeCapability("Aspire.Hosting/withEnvironmentCallback", reqArgs); + } + + /** Sets environment variables via async callback */ + public IResourceWithEnvironment withEnvironmentCallbackAsync(Function callback) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (callback != null) { + reqArgs.put("callback", getClient().registerCallback(callback)); + } + return (IResourceWithEnvironment) getClient().invokeCapability("Aspire.Hosting/withEnvironmentCallbackAsync", reqArgs); + } + + /** Adds arguments */ + public IResourceWithArgs withArgs(String[] args) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("args", AspireClient.serializeValue(args)); + return (IResourceWithArgs) getClient().invokeCapability("Aspire.Hosting/withArgs", reqArgs); + } + + /** Sets command-line arguments via callback */ + public IResourceWithArgs withArgsCallback(Function callback) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (callback != null) { + reqArgs.put("callback", getClient().registerCallback(callback)); + } + return (IResourceWithArgs) getClient().invokeCapability("Aspire.Hosting/withArgsCallback", reqArgs); + } + + /** Sets command-line arguments via async callback */ + public IResourceWithArgs withArgsCallbackAsync(Function callback) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (callback != null) { + reqArgs.put("callback", getClient().registerCallback(callback)); + } + return (IResourceWithArgs) getClient().invokeCapability("Aspire.Hosting/withArgsCallbackAsync", reqArgs); + } + + /** Adds a reference to another resource */ + public IResourceWithEnvironment withReference(IResourceWithConnectionString source, String connectionName, Boolean optional) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("source", AspireClient.serializeValue(source)); + if (connectionName != null) { + reqArgs.put("connectionName", AspireClient.serializeValue(connectionName)); + } + if (optional != null) { + reqArgs.put("optional", AspireClient.serializeValue(optional)); + } + return (IResourceWithEnvironment) getClient().invokeCapability("Aspire.Hosting/withReference", reqArgs); + } + + /** Adds a service discovery reference to another resource */ + public IResourceWithEnvironment withServiceReference(IResourceWithServiceDiscovery source) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("source", AspireClient.serializeValue(source)); + return (IResourceWithEnvironment) getClient().invokeCapability("Aspire.Hosting/withServiceReference", reqArgs); + } + + /** Adds a network endpoint */ + public IResourceWithEndpoints withEndpoint(Double port, Double targetPort, String scheme, String name, String env, Boolean isProxied, Boolean isExternal, ProtocolType protocol) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (port != null) { + reqArgs.put("port", AspireClient.serializeValue(port)); + } + if (targetPort != null) { + reqArgs.put("targetPort", AspireClient.serializeValue(targetPort)); + } + if (scheme != null) { + reqArgs.put("scheme", AspireClient.serializeValue(scheme)); + } + if (name != null) { + reqArgs.put("name", AspireClient.serializeValue(name)); + } + if (env != null) { + reqArgs.put("env", AspireClient.serializeValue(env)); + } + if (isProxied != null) { + reqArgs.put("isProxied", AspireClient.serializeValue(isProxied)); + } + if (isExternal != null) { + reqArgs.put("isExternal", AspireClient.serializeValue(isExternal)); + } + if (protocol != null) { + reqArgs.put("protocol", AspireClient.serializeValue(protocol)); + } + return (IResourceWithEndpoints) getClient().invokeCapability("Aspire.Hosting/withEndpoint", reqArgs); + } + + /** Adds an HTTP endpoint */ + public IResourceWithEndpoints withHttpEndpoint(Double port, Double targetPort, String name, String env, Boolean isProxied) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (port != null) { + reqArgs.put("port", AspireClient.serializeValue(port)); + } + if (targetPort != null) { + reqArgs.put("targetPort", AspireClient.serializeValue(targetPort)); + } + if (name != null) { + reqArgs.put("name", AspireClient.serializeValue(name)); + } + if (env != null) { + reqArgs.put("env", AspireClient.serializeValue(env)); + } + if (isProxied != null) { + reqArgs.put("isProxied", AspireClient.serializeValue(isProxied)); + } + return (IResourceWithEndpoints) getClient().invokeCapability("Aspire.Hosting/withHttpEndpoint", reqArgs); + } + + /** Adds an HTTPS endpoint */ + public IResourceWithEndpoints withHttpsEndpoint(Double port, Double targetPort, String name, String env, Boolean isProxied) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (port != null) { + reqArgs.put("port", AspireClient.serializeValue(port)); + } + if (targetPort != null) { + reqArgs.put("targetPort", AspireClient.serializeValue(targetPort)); + } + if (name != null) { + reqArgs.put("name", AspireClient.serializeValue(name)); + } + if (env != null) { + reqArgs.put("env", AspireClient.serializeValue(env)); + } + if (isProxied != null) { + reqArgs.put("isProxied", AspireClient.serializeValue(isProxied)); + } + return (IResourceWithEndpoints) getClient().invokeCapability("Aspire.Hosting/withHttpsEndpoint", reqArgs); + } + + /** Makes HTTP endpoints externally accessible */ + public IResourceWithEndpoints withExternalHttpEndpoints() { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + return (IResourceWithEndpoints) getClient().invokeCapability("Aspire.Hosting/withExternalHttpEndpoints", reqArgs); + } + + /** Gets an endpoint reference */ + public EndpointReference getEndpoint(String name) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("name", AspireClient.serializeValue(name)); + return (EndpointReference) getClient().invokeCapability("Aspire.Hosting/getEndpoint", reqArgs); + } + + /** Configures resource for HTTP/2 */ + public IResourceWithEndpoints asHttp2Service() { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + return (IResourceWithEndpoints) getClient().invokeCapability("Aspire.Hosting/asHttp2Service", reqArgs); + } + + /** Customizes displayed URLs via callback */ + public IResource withUrlsCallback(Function callback) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (callback != null) { + reqArgs.put("callback", getClient().registerCallback(callback)); + } + return (IResource) getClient().invokeCapability("Aspire.Hosting/withUrlsCallback", reqArgs); + } + + /** Customizes displayed URLs via async callback */ + public IResource withUrlsCallbackAsync(Function callback) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (callback != null) { + reqArgs.put("callback", getClient().registerCallback(callback)); + } + return (IResource) getClient().invokeCapability("Aspire.Hosting/withUrlsCallbackAsync", reqArgs); + } + + /** Adds or modifies displayed URLs */ + public IResource withUrl(String url, String displayText) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("url", AspireClient.serializeValue(url)); + if (displayText != null) { + reqArgs.put("displayText", AspireClient.serializeValue(displayText)); + } + return (IResource) getClient().invokeCapability("Aspire.Hosting/withUrl", reqArgs); + } + + /** Adds a URL using a reference expression */ + public IResource withUrlExpression(ReferenceExpression url, String displayText) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("url", AspireClient.serializeValue(url)); + if (displayText != null) { + reqArgs.put("displayText", AspireClient.serializeValue(displayText)); + } + return (IResource) getClient().invokeCapability("Aspire.Hosting/withUrlExpression", reqArgs); + } + + /** Customizes the URL for a specific endpoint via callback */ + public IResource withUrlForEndpoint(String endpointName, Function callback) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("endpointName", AspireClient.serializeValue(endpointName)); + if (callback != null) { + reqArgs.put("callback", getClient().registerCallback(callback)); + } + return (IResource) getClient().invokeCapability("Aspire.Hosting/withUrlForEndpoint", reqArgs); + } + + /** Adds a URL for a specific endpoint via factory callback */ + public IResourceWithEndpoints withUrlForEndpointFactory(String endpointName, Function callback) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("endpointName", AspireClient.serializeValue(endpointName)); + if (callback != null) { + reqArgs.put("callback", getClient().registerCallback(callback)); + } + return (IResourceWithEndpoints) getClient().invokeCapability("Aspire.Hosting/withUrlForEndpointFactory", reqArgs); + } + + /** Waits for another resource to be ready */ + public IResourceWithWaitSupport waitFor(IResource dependency) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("dependency", AspireClient.serializeValue(dependency)); + return (IResourceWithWaitSupport) getClient().invokeCapability("Aspire.Hosting/waitFor", reqArgs); + } + + /** Prevents resource from starting automatically */ + public IResource withExplicitStart() { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + return (IResource) getClient().invokeCapability("Aspire.Hosting/withExplicitStart", reqArgs); + } + + /** Waits for resource completion */ + public IResourceWithWaitSupport waitForCompletion(IResource dependency, Double exitCode) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("dependency", AspireClient.serializeValue(dependency)); + if (exitCode != null) { + reqArgs.put("exitCode", AspireClient.serializeValue(exitCode)); + } + return (IResourceWithWaitSupport) getClient().invokeCapability("Aspire.Hosting/waitForCompletion", reqArgs); + } + + /** Adds a health check by key */ + public IResource withHealthCheck(String key) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("key", AspireClient.serializeValue(key)); + return (IResource) getClient().invokeCapability("Aspire.Hosting/withHealthCheck", reqArgs); + } + + /** Adds an HTTP health check */ + public IResourceWithEndpoints withHttpHealthCheck(String path, Double statusCode, String endpointName) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (path != null) { + reqArgs.put("path", AspireClient.serializeValue(path)); + } + if (statusCode != null) { + reqArgs.put("statusCode", AspireClient.serializeValue(statusCode)); + } + if (endpointName != null) { + reqArgs.put("endpointName", AspireClient.serializeValue(endpointName)); + } + return (IResourceWithEndpoints) getClient().invokeCapability("Aspire.Hosting/withHttpHealthCheck", reqArgs); + } + + /** Adds a resource command */ + public IResource withCommand(String name, String displayName, Function executeCommand, CommandOptions commandOptions) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("name", AspireClient.serializeValue(name)); + reqArgs.put("displayName", AspireClient.serializeValue(displayName)); + if (executeCommand != null) { + reqArgs.put("executeCommand", getClient().registerCallback(executeCommand)); + } + if (commandOptions != null) { + reqArgs.put("commandOptions", AspireClient.serializeValue(commandOptions)); + } + return (IResource) getClient().invokeCapability("Aspire.Hosting/withCommand", reqArgs); + } + + /** Sets the parent relationship */ + public IResource withParentRelationship(IResource parent) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("parent", AspireClient.serializeValue(parent)); + return (IResource) getClient().invokeCapability("Aspire.Hosting/withParentRelationship", reqArgs); + } + + /** Adds a volume */ + public ContainerResource withVolume(String target, String name, Boolean isReadOnly) { + Map reqArgs = new HashMap<>(); + reqArgs.put("resource", AspireClient.serializeValue(getHandle())); + reqArgs.put("target", AspireClient.serializeValue(target)); + if (name != null) { + reqArgs.put("name", AspireClient.serializeValue(name)); + } + if (isReadOnly != null) { + reqArgs.put("isReadOnly", AspireClient.serializeValue(isReadOnly)); + } + return (ContainerResource) getClient().invokeCapability("Aspire.Hosting/withVolume", reqArgs); + } + + /** Gets the resource name */ + public String getResourceName() { + Map reqArgs = new HashMap<>(); + reqArgs.put("resource", AspireClient.serializeValue(getHandle())); + return (String) getClient().invokeCapability("Aspire.Hosting/getResourceName", reqArgs); + } + + /** Adds an optional string parameter */ + public IResource withOptionalString(String value, Boolean enabled) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (value != null) { + reqArgs.put("value", AspireClient.serializeValue(value)); + } + if (enabled != null) { + reqArgs.put("enabled", AspireClient.serializeValue(enabled)); + } + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withOptionalString", reqArgs); + } + + /** Configures the resource with a DTO */ + public IResource withConfig(TestConfigDto config) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("config", AspireClient.serializeValue(config)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withConfig", reqArgs); + } + + /** Configures environment with callback (test version) */ + public IResourceWithEnvironment testWithEnvironmentCallback(Function callback) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (callback != null) { + reqArgs.put("callback", getClient().registerCallback(callback)); + } + return (IResourceWithEnvironment) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/testWithEnvironmentCallback", reqArgs); + } + + /** Sets the created timestamp */ + public IResource withCreatedAt(String createdAt) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("createdAt", AspireClient.serializeValue(createdAt)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withCreatedAt", reqArgs); + } + + /** Sets the modified timestamp */ + public IResource withModifiedAt(String modifiedAt) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("modifiedAt", AspireClient.serializeValue(modifiedAt)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withModifiedAt", reqArgs); + } + + /** Sets the correlation ID */ + public IResource withCorrelationId(String correlationId) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("correlationId", AspireClient.serializeValue(correlationId)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withCorrelationId", reqArgs); + } + + /** Configures with optional callback */ + public IResource withOptionalCallback(Function callback) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (callback != null) { + reqArgs.put("callback", getClient().registerCallback(callback)); + } + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withOptionalCallback", reqArgs); + } + + /** Sets the resource status */ + public IResource withStatus(TestResourceStatus status) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("status", AspireClient.serializeValue(status)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withStatus", reqArgs); + } + + /** Configures with nested DTO */ + public IResource withNestedConfig(TestNestedDto config) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("config", AspireClient.serializeValue(config)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withNestedConfig", reqArgs); + } + + /** Adds validation callback */ + public IResource withValidator(Function validator) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (validator != null) { + reqArgs.put("validator", getClient().registerCallback(validator)); + } + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withValidator", reqArgs); + } + + /** Waits for another resource (test version) */ + public IResource testWaitFor(IResource dependency) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("dependency", AspireClient.serializeValue(dependency)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/testWaitFor", reqArgs); + } + + /** Adds a dependency on another resource */ + public IResource withDependency(IResourceWithConnectionString dependency) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("dependency", AspireClient.serializeValue(dependency)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withDependency", reqArgs); + } + + /** Sets the endpoints */ + public IResource withEndpoints(String[] endpoints) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("endpoints", AspireClient.serializeValue(endpoints)); + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withEndpoints", reqArgs); + } + + /** Sets environment variables */ + public IResourceWithEnvironment withEnvironmentVariables(Map variables) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("variables", AspireClient.serializeValue(variables)); + return (IResourceWithEnvironment) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withEnvironmentVariables", reqArgs); + } + + /** Performs a cancellable operation */ + public IResource withCancellableOperation(Function operation) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + if (operation != null) { + reqArgs.put("operation", getClient().registerCallback(operation)); + } + return (IResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/withCancellableOperation", reqArgs); + } + +} + /** Wrapper for Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext. */ class TestEnvironmentContext extends HandleWrapperBase { TestEnvironmentContext(Handle handle, AspireClient client) { @@ -3246,6 +3809,17 @@ public String getResourceName() { return (String) getClient().invokeCapability("Aspire.Hosting/getResourceName", reqArgs); } + /** Adds a child database to a test Redis resource */ + public TestDatabaseResource addTestChildDatabase(String name, String databaseName) { + Map reqArgs = new HashMap<>(); + reqArgs.put("builder", AspireClient.serializeValue(getHandle())); + reqArgs.put("name", AspireClient.serializeValue(name)); + if (databaseName != null) { + reqArgs.put("databaseName", AspireClient.serializeValue(databaseName)); + } + return (TestDatabaseResource) getClient().invokeCapability("Aspire.Hosting.CodeGeneration.Java.Tests/addTestChildDatabase", reqArgs); + } + /** Configures the Redis resource with persistence */ public TestRedisResource withPersistence(TestPersistenceMode mode) { Map reqArgs = new HashMap<>(); @@ -3564,6 +4138,7 @@ class AspireRegistrations { AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext", (h, c) -> new TestEnvironmentContext(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCollectionContext", (h, c) -> new TestCollectionContext(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestRedisResource", (h, c) -> new TestRedisResource(h, c)); + AspireClient.registerHandleWrapper("Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource", (h, c) -> new TestDatabaseResource(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment", (h, c) -> new IResourceWithEnvironment(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs", (h, c) -> new IResourceWithArgs(h, c)); AspireClient.registerHandleWrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints", (h, c) -> new IResourceWithEndpoints(h, c)); diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/WithOptionalStringCapability.verified.txt b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/WithOptionalStringCapability.verified.txt index 56fc51f2121..0b42c1cd606 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/WithOptionalStringCapability.verified.txt +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/WithOptionalStringCapability.verified.txt @@ -68,6 +68,16 @@ IsResourceBuilder: true, IsDistributedApplicationBuilder: false, IsDistributedApplication: false + }, + { + TypeId: Aspire.Hosting.CodeGeneration.Java.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource, + ClrType: TestDatabaseResource, + Category: Handle, + IsInterface: false, + IsReadOnly: false, + IsResourceBuilder: true, + IsDistributedApplicationBuilder: false, + IsDistributedApplication: false } ], ReturnsBuilder: true, diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py index f5c1186ded4..d6a93a4da90 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/AtsGeneratedAspire.verified.py @@ -180,6 +180,111 @@ def metadata(self) -> AspireDict[str, str]: return self._metadata +class TestDatabaseResource(ResourceBuilderBase): + def __init__(self, handle: Handle, client: AspireClient): + super().__init__(handle, client) + + def with_optional_string(self, value: str | None = None, enabled: bool = True) -> IResource: + """Adds an optional string parameter""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + if value is not None: + args["value"] = serialize_value(value) + args["enabled"] = serialize_value(enabled) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withOptionalString", args) + + def with_config(self, config: TestConfigDto) -> IResource: + """Configures the resource with a DTO""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["config"] = serialize_value(config) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withConfig", args) + + def test_with_environment_callback(self, callback: Callable[[TestEnvironmentContext], None]) -> IResourceWithEnvironment: + """Configures environment with callback (test version)""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + callback_id = register_callback(callback) if callback is not None else None + if callback_id is not None: + args["callback"] = callback_id + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/testWithEnvironmentCallback", args) + + def with_created_at(self, created_at: str) -> IResource: + """Sets the created timestamp""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["createdAt"] = serialize_value(created_at) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withCreatedAt", args) + + def with_modified_at(self, modified_at: str) -> IResource: + """Sets the modified timestamp""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["modifiedAt"] = serialize_value(modified_at) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withModifiedAt", args) + + def with_correlation_id(self, correlation_id: str) -> IResource: + """Sets the correlation ID""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["correlationId"] = serialize_value(correlation_id) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withCorrelationId", args) + + def with_optional_callback(self, callback: Callable[[TestCallbackContext], None] | None = None) -> IResource: + """Configures with optional callback""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + callback_id = register_callback(callback) if callback is not None else None + if callback_id is not None: + args["callback"] = callback_id + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withOptionalCallback", args) + + def with_status(self, status: TestResourceStatus) -> IResource: + """Sets the resource status""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["status"] = serialize_value(status) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withStatus", args) + + def with_nested_config(self, config: TestNestedDto) -> IResource: + """Configures with nested DTO""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["config"] = serialize_value(config) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withNestedConfig", args) + + def with_validator(self, validator: Callable[[TestResourceContext], bool]) -> IResource: + """Adds validation callback""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + validator_id = register_callback(validator) if validator is not None else None + if validator_id is not None: + args["validator"] = validator_id + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withValidator", args) + + def test_wait_for(self, dependency: IResource) -> IResource: + """Waits for another resource (test version)""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["dependency"] = serialize_value(dependency) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/testWaitFor", args) + + def with_dependency(self, dependency: IResourceWithConnectionString) -> IResource: + """Adds a dependency on another resource""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["dependency"] = serialize_value(dependency) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withDependency", args) + + def with_endpoints(self, endpoints: list[str]) -> IResource: + """Sets the endpoints""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["endpoints"] = serialize_value(endpoints) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withEndpoints", args) + + def with_environment_variables(self, variables: dict[str, str]) -> IResourceWithEnvironment: + """Sets environment variables""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["variables"] = serialize_value(variables) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withEnvironmentVariables", args) + + def with_cancellable_operation(self, operation: Callable[[CancellationToken], None]) -> IResource: + """Performs a cancellable operation""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + operation_id = register_callback(operation) if operation is not None else None + if operation_id is not None: + args["operation"] = operation_id + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withCancellableOperation", args) + + class TestEnvironmentContext(HandleWrapperBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) @@ -222,6 +327,14 @@ class TestRedisResource(ResourceBuilderBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) + def add_test_child_database(self, name: str, database_name: str | None = None) -> TestDatabaseResource: + """Adds a child database to a test Redis resource""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["name"] = serialize_value(name) + if database_name is not None: + args["databaseName"] = serialize_value(database_name) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/addTestChildDatabase", args) + def with_persistence(self, mode: TestPersistenceMode = None) -> TestRedisResource: """Configures the Redis resource with persistence""" args: Dict[str, Any] = { "builder": serialize_value(self._handle) } @@ -444,6 +557,7 @@ def validate_async(self) -> bool: register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext", lambda handle, client: TestEnvironmentContext(handle, client)) register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCollectionContext", lambda handle, client: TestCollectionContext(handle, client)) register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestRedisResource", lambda handle, client: TestRedisResource(handle, client)) +register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource", lambda handle, client: TestDatabaseResource(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource", lambda handle, client: IResource(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithConnectionString", lambda handle, client: IResourceWithConnectionString(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder", lambda handle, client: IDistributedApplicationBuilder(handle, client)) diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index 1bc2962a5e1..5b8681afece 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -1929,6 +1929,417 @@ def metadata(self) -> AspireDict[str, str]: return self._metadata +class TestDatabaseResource(ResourceBuilderBase): + def __init__(self, handle: Handle, client: AspireClient): + super().__init__(handle, client) + + def with_bind_mount(self, source: str, target: str, is_read_only: bool = False) -> ContainerResource: + """Adds a bind mount""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["source"] = serialize_value(source) + args["target"] = serialize_value(target) + args["isReadOnly"] = serialize_value(is_read_only) + return self._client.invoke_capability("Aspire.Hosting/withBindMount", args) + + def with_entrypoint(self, entrypoint: str) -> ContainerResource: + """Sets the container entrypoint""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["entrypoint"] = serialize_value(entrypoint) + return self._client.invoke_capability("Aspire.Hosting/withEntrypoint", args) + + def with_image_tag(self, tag: str) -> ContainerResource: + """Sets the container image tag""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["tag"] = serialize_value(tag) + return self._client.invoke_capability("Aspire.Hosting/withImageTag", args) + + def with_image_registry(self, registry: str) -> ContainerResource: + """Sets the container image registry""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["registry"] = serialize_value(registry) + return self._client.invoke_capability("Aspire.Hosting/withImageRegistry", args) + + def with_image(self, image: str, tag: str | None = None) -> ContainerResource: + """Sets the container image""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["image"] = serialize_value(image) + if tag is not None: + args["tag"] = serialize_value(tag) + return self._client.invoke_capability("Aspire.Hosting/withImage", args) + + def with_container_runtime_args(self, args: list[str]) -> ContainerResource: + """Adds runtime arguments for the container""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["args"] = serialize_value(args) + return self._client.invoke_capability("Aspire.Hosting/withContainerRuntimeArgs", args) + + def with_lifetime(self, lifetime: ContainerLifetime) -> ContainerResource: + """Sets the lifetime behavior of the container resource""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["lifetime"] = serialize_value(lifetime) + return self._client.invoke_capability("Aspire.Hosting/withLifetime", args) + + def with_image_pull_policy(self, pull_policy: ImagePullPolicy) -> ContainerResource: + """Sets the container image pull policy""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["pullPolicy"] = serialize_value(pull_policy) + return self._client.invoke_capability("Aspire.Hosting/withImagePullPolicy", args) + + def with_container_name(self, name: str) -> ContainerResource: + """Sets the container name""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["name"] = serialize_value(name) + return self._client.invoke_capability("Aspire.Hosting/withContainerName", args) + + def with_environment(self, name: str, value: str) -> IResourceWithEnvironment: + """Sets an environment variable""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["name"] = serialize_value(name) + args["value"] = serialize_value(value) + return self._client.invoke_capability("Aspire.Hosting/withEnvironment", args) + + def with_environment_expression(self, name: str, value: ReferenceExpression) -> IResourceWithEnvironment: + """Adds an environment variable with a reference expression""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["name"] = serialize_value(name) + args["value"] = serialize_value(value) + return self._client.invoke_capability("Aspire.Hosting/withEnvironmentExpression", args) + + def with_environment_callback(self, callback: Callable[[EnvironmentCallbackContext], None]) -> IResourceWithEnvironment: + """Sets environment variables via callback""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + callback_id = register_callback(callback) if callback is not None else None + if callback_id is not None: + args["callback"] = callback_id + return self._client.invoke_capability("Aspire.Hosting/withEnvironmentCallback", args) + + def with_environment_callback_async(self, callback: Callable[[EnvironmentCallbackContext], None]) -> IResourceWithEnvironment: + """Sets environment variables via async callback""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + callback_id = register_callback(callback) if callback is not None else None + if callback_id is not None: + args["callback"] = callback_id + return self._client.invoke_capability("Aspire.Hosting/withEnvironmentCallbackAsync", args) + + def with_args(self, args: list[str]) -> IResourceWithArgs: + """Adds arguments""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["args"] = serialize_value(args) + return self._client.invoke_capability("Aspire.Hosting/withArgs", args) + + def with_args_callback(self, callback: Callable[[CommandLineArgsCallbackContext], None]) -> IResourceWithArgs: + """Sets command-line arguments via callback""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + callback_id = register_callback(callback) if callback is not None else None + if callback_id is not None: + args["callback"] = callback_id + return self._client.invoke_capability("Aspire.Hosting/withArgsCallback", args) + + def with_args_callback_async(self, callback: Callable[[CommandLineArgsCallbackContext], None]) -> IResourceWithArgs: + """Sets command-line arguments via async callback""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + callback_id = register_callback(callback) if callback is not None else None + if callback_id is not None: + args["callback"] = callback_id + return self._client.invoke_capability("Aspire.Hosting/withArgsCallbackAsync", args) + + def with_reference(self, source: IResourceWithConnectionString, connection_name: str | None = None, optional: bool = False) -> IResourceWithEnvironment: + """Adds a reference to another resource""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["source"] = serialize_value(source) + if connection_name is not None: + args["connectionName"] = serialize_value(connection_name) + args["optional"] = serialize_value(optional) + return self._client.invoke_capability("Aspire.Hosting/withReference", args) + + def with_service_reference(self, source: IResourceWithServiceDiscovery) -> IResourceWithEnvironment: + """Adds a service discovery reference to another resource""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["source"] = serialize_value(source) + return self._client.invoke_capability("Aspire.Hosting/withServiceReference", args) + + def with_endpoint(self, port: float | None = None, target_port: float | None = None, scheme: str | None = None, name: str | None = None, env: str | None = None, is_proxied: bool = True, is_external: bool | None = None, protocol: ProtocolType | None = None) -> IResourceWithEndpoints: + """Adds a network endpoint""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + if port is not None: + args["port"] = serialize_value(port) + if target_port is not None: + args["targetPort"] = serialize_value(target_port) + if scheme is not None: + args["scheme"] = serialize_value(scheme) + if name is not None: + args["name"] = serialize_value(name) + if env is not None: + args["env"] = serialize_value(env) + args["isProxied"] = serialize_value(is_proxied) + if is_external is not None: + args["isExternal"] = serialize_value(is_external) + if protocol is not None: + args["protocol"] = serialize_value(protocol) + return self._client.invoke_capability("Aspire.Hosting/withEndpoint", args) + + def with_http_endpoint(self, port: float | None = None, target_port: float | None = None, name: str | None = None, env: str | None = None, is_proxied: bool = True) -> IResourceWithEndpoints: + """Adds an HTTP endpoint""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + if port is not None: + args["port"] = serialize_value(port) + if target_port is not None: + args["targetPort"] = serialize_value(target_port) + if name is not None: + args["name"] = serialize_value(name) + if env is not None: + args["env"] = serialize_value(env) + args["isProxied"] = serialize_value(is_proxied) + return self._client.invoke_capability("Aspire.Hosting/withHttpEndpoint", args) + + def with_https_endpoint(self, port: float | None = None, target_port: float | None = None, name: str | None = None, env: str | None = None, is_proxied: bool = True) -> IResourceWithEndpoints: + """Adds an HTTPS endpoint""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + if port is not None: + args["port"] = serialize_value(port) + if target_port is not None: + args["targetPort"] = serialize_value(target_port) + if name is not None: + args["name"] = serialize_value(name) + if env is not None: + args["env"] = serialize_value(env) + args["isProxied"] = serialize_value(is_proxied) + return self._client.invoke_capability("Aspire.Hosting/withHttpsEndpoint", args) + + def with_external_http_endpoints(self) -> IResourceWithEndpoints: + """Makes HTTP endpoints externally accessible""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + return self._client.invoke_capability("Aspire.Hosting/withExternalHttpEndpoints", args) + + def get_endpoint(self, name: str) -> EndpointReference: + """Gets an endpoint reference""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["name"] = serialize_value(name) + return self._client.invoke_capability("Aspire.Hosting/getEndpoint", args) + + def as_http2_service(self) -> IResourceWithEndpoints: + """Configures resource for HTTP/2""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + return self._client.invoke_capability("Aspire.Hosting/asHttp2Service", args) + + def with_urls_callback(self, callback: Callable[[ResourceUrlsCallbackContext], None]) -> IResource: + """Customizes displayed URLs via callback""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + callback_id = register_callback(callback) if callback is not None else None + if callback_id is not None: + args["callback"] = callback_id + return self._client.invoke_capability("Aspire.Hosting/withUrlsCallback", args) + + def with_urls_callback_async(self, callback: Callable[[ResourceUrlsCallbackContext], None]) -> IResource: + """Customizes displayed URLs via async callback""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + callback_id = register_callback(callback) if callback is not None else None + if callback_id is not None: + args["callback"] = callback_id + return self._client.invoke_capability("Aspire.Hosting/withUrlsCallbackAsync", args) + + def with_url(self, url: str, display_text: str | None = None) -> IResource: + """Adds or modifies displayed URLs""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["url"] = serialize_value(url) + if display_text is not None: + args["displayText"] = serialize_value(display_text) + return self._client.invoke_capability("Aspire.Hosting/withUrl", args) + + def with_url_expression(self, url: ReferenceExpression, display_text: str | None = None) -> IResource: + """Adds a URL using a reference expression""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["url"] = serialize_value(url) + if display_text is not None: + args["displayText"] = serialize_value(display_text) + return self._client.invoke_capability("Aspire.Hosting/withUrlExpression", args) + + def with_url_for_endpoint(self, endpoint_name: str, callback: Callable[[ResourceUrlAnnotation], None]) -> IResource: + """Customizes the URL for a specific endpoint via callback""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["endpointName"] = serialize_value(endpoint_name) + callback_id = register_callback(callback) if callback is not None else None + if callback_id is not None: + args["callback"] = callback_id + return self._client.invoke_capability("Aspire.Hosting/withUrlForEndpoint", args) + + def with_url_for_endpoint_factory(self, endpoint_name: str, callback: Callable[[EndpointReference], ResourceUrlAnnotation]) -> IResourceWithEndpoints: + """Adds a URL for a specific endpoint via factory callback""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["endpointName"] = serialize_value(endpoint_name) + callback_id = register_callback(callback) if callback is not None else None + if callback_id is not None: + args["callback"] = callback_id + return self._client.invoke_capability("Aspire.Hosting/withUrlForEndpointFactory", args) + + def wait_for(self, dependency: IResource) -> IResourceWithWaitSupport: + """Waits for another resource to be ready""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["dependency"] = serialize_value(dependency) + return self._client.invoke_capability("Aspire.Hosting/waitFor", args) + + def with_explicit_start(self) -> IResource: + """Prevents resource from starting automatically""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + return self._client.invoke_capability("Aspire.Hosting/withExplicitStart", args) + + def wait_for_completion(self, dependency: IResource, exit_code: float = 0) -> IResourceWithWaitSupport: + """Waits for resource completion""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["dependency"] = serialize_value(dependency) + args["exitCode"] = serialize_value(exit_code) + return self._client.invoke_capability("Aspire.Hosting/waitForCompletion", args) + + def with_health_check(self, key: str) -> IResource: + """Adds a health check by key""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["key"] = serialize_value(key) + return self._client.invoke_capability("Aspire.Hosting/withHealthCheck", args) + + def with_http_health_check(self, path: str | None = None, status_code: float | None = None, endpoint_name: str | None = None) -> IResourceWithEndpoints: + """Adds an HTTP health check""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + if path is not None: + args["path"] = serialize_value(path) + if status_code is not None: + args["statusCode"] = serialize_value(status_code) + if endpoint_name is not None: + args["endpointName"] = serialize_value(endpoint_name) + return self._client.invoke_capability("Aspire.Hosting/withHttpHealthCheck", args) + + def with_command(self, name: str, display_name: str, execute_command: Callable[[ExecuteCommandContext], ExecuteCommandResult], command_options: CommandOptions | None = None) -> IResource: + """Adds a resource command""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["name"] = serialize_value(name) + args["displayName"] = serialize_value(display_name) + execute_command_id = register_callback(execute_command) if execute_command is not None else None + if execute_command_id is not None: + args["executeCommand"] = execute_command_id + if command_options is not None: + args["commandOptions"] = serialize_value(command_options) + return self._client.invoke_capability("Aspire.Hosting/withCommand", args) + + def with_parent_relationship(self, parent: IResource) -> IResource: + """Sets the parent relationship""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["parent"] = serialize_value(parent) + return self._client.invoke_capability("Aspire.Hosting/withParentRelationship", args) + + def with_volume(self, target: str, name: str | None = None, is_read_only: bool = False) -> ContainerResource: + """Adds a volume""" + args: Dict[str, Any] = { "resource": serialize_value(self._handle) } + args["target"] = serialize_value(target) + if name is not None: + args["name"] = serialize_value(name) + args["isReadOnly"] = serialize_value(is_read_only) + return self._client.invoke_capability("Aspire.Hosting/withVolume", args) + + def get_resource_name(self) -> str: + """Gets the resource name""" + args: Dict[str, Any] = { "resource": serialize_value(self._handle) } + return self._client.invoke_capability("Aspire.Hosting/getResourceName", args) + + def with_optional_string(self, value: str | None = None, enabled: bool = True) -> IResource: + """Adds an optional string parameter""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + if value is not None: + args["value"] = serialize_value(value) + args["enabled"] = serialize_value(enabled) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withOptionalString", args) + + def with_config(self, config: TestConfigDto) -> IResource: + """Configures the resource with a DTO""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["config"] = serialize_value(config) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withConfig", args) + + def test_with_environment_callback(self, callback: Callable[[TestEnvironmentContext], None]) -> IResourceWithEnvironment: + """Configures environment with callback (test version)""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + callback_id = register_callback(callback) if callback is not None else None + if callback_id is not None: + args["callback"] = callback_id + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/testWithEnvironmentCallback", args) + + def with_created_at(self, created_at: str) -> IResource: + """Sets the created timestamp""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["createdAt"] = serialize_value(created_at) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withCreatedAt", args) + + def with_modified_at(self, modified_at: str) -> IResource: + """Sets the modified timestamp""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["modifiedAt"] = serialize_value(modified_at) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withModifiedAt", args) + + def with_correlation_id(self, correlation_id: str) -> IResource: + """Sets the correlation ID""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["correlationId"] = serialize_value(correlation_id) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withCorrelationId", args) + + def with_optional_callback(self, callback: Callable[[TestCallbackContext], None] | None = None) -> IResource: + """Configures with optional callback""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + callback_id = register_callback(callback) if callback is not None else None + if callback_id is not None: + args["callback"] = callback_id + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withOptionalCallback", args) + + def with_status(self, status: TestResourceStatus) -> IResource: + """Sets the resource status""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["status"] = serialize_value(status) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withStatus", args) + + def with_nested_config(self, config: TestNestedDto) -> IResource: + """Configures with nested DTO""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["config"] = serialize_value(config) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withNestedConfig", args) + + def with_validator(self, validator: Callable[[TestResourceContext], bool]) -> IResource: + """Adds validation callback""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + validator_id = register_callback(validator) if validator is not None else None + if validator_id is not None: + args["validator"] = validator_id + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withValidator", args) + + def test_wait_for(self, dependency: IResource) -> IResource: + """Waits for another resource (test version)""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["dependency"] = serialize_value(dependency) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/testWaitFor", args) + + def with_dependency(self, dependency: IResourceWithConnectionString) -> IResource: + """Adds a dependency on another resource""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["dependency"] = serialize_value(dependency) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withDependency", args) + + def with_endpoints(self, endpoints: list[str]) -> IResource: + """Sets the endpoints""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["endpoints"] = serialize_value(endpoints) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withEndpoints", args) + + def with_environment_variables(self, variables: dict[str, str]) -> IResourceWithEnvironment: + """Sets environment variables""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["variables"] = serialize_value(variables) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withEnvironmentVariables", args) + + def with_cancellable_operation(self, operation: Callable[[CancellationToken], None]) -> IResource: + """Performs a cancellable operation""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + operation_id = register_callback(operation) if operation is not None else None + if operation_id is not None: + args["operation"] = operation_id + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/withCancellableOperation", args) + + class TestEnvironmentContext(HandleWrapperBase): def __init__(self, handle: Handle, client: AspireClient): super().__init__(handle, client) @@ -2277,6 +2688,14 @@ def get_resource_name(self) -> str: args: Dict[str, Any] = { "resource": serialize_value(self._handle) } return self._client.invoke_capability("Aspire.Hosting/getResourceName", args) + def add_test_child_database(self, name: str, database_name: str | None = None) -> TestDatabaseResource: + """Adds a child database to a test Redis resource""" + args: Dict[str, Any] = { "builder": serialize_value(self._handle) } + args["name"] = serialize_value(name) + if database_name is not None: + args["databaseName"] = serialize_value(database_name) + return self._client.invoke_capability("Aspire.Hosting.CodeGeneration.Python.Tests/addTestChildDatabase", args) + def with_persistence(self, mode: TestPersistenceMode = None) -> TestRedisResource: """Configures the Redis resource with persistence""" args: Dict[str, Any] = { "builder": serialize_value(self._handle) } @@ -2529,6 +2948,7 @@ def __init__(self, handle: Handle, client: AspireClient): register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext", lambda handle, client: TestEnvironmentContext(handle, client)) register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCollectionContext", lambda handle, client: TestCollectionContext(handle, client)) register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestRedisResource", lambda handle, client: TestRedisResource(handle, client)) +register_handle_wrapper("Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource", lambda handle, client: TestDatabaseResource(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEnvironment", lambda handle, client: IResourceWithEnvironment(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs", lambda handle, client: IResourceWithArgs(handle, client)) register_handle_wrapper("Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithEndpoints", lambda handle, client: IResourceWithEndpoints(handle, client)) diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/WithOptionalStringCapability.verified.txt b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/WithOptionalStringCapability.verified.txt index 6b1c1fcc13a..4e3007672bc 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/WithOptionalStringCapability.verified.txt +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/WithOptionalStringCapability.verified.txt @@ -68,6 +68,16 @@ IsResourceBuilder: true, IsDistributedApplicationBuilder: false, IsDistributedApplication: false + }, + { + TypeId: Aspire.Hosting.CodeGeneration.Python.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource, + ClrType: TestDatabaseResource, + Category: Handle, + IsInterface: false, + IsReadOnly: false, + IsResourceBuilder: true, + IsDistributedApplicationBuilder: false, + IsDistributedApplication: false } ], ReturnsBuilder: true, diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs index 2114fa33ff7..e6467d43b8d 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/AtsGeneratedAspire.verified.rs @@ -378,6 +378,191 @@ impl TestCollectionContext { } } +/// Wrapper for Aspire.Hosting.CodeGeneration.Rust.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource +pub struct TestDatabaseResource { + handle: Handle, + client: Arc, +} + +impl HasHandle for TestDatabaseResource { + fn handle(&self) -> &Handle { + &self.handle + } +} + +impl TestDatabaseResource { + pub fn new(handle: Handle, client: Arc) -> Self { + Self { handle, client } + } + + pub fn handle(&self) -> &Handle { + &self.handle + } + + pub fn client(&self) -> &Arc { + &self.client + } + + /// Adds an optional string parameter + pub fn with_optional_string(&self, value: Option<&str>, enabled: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + if let Some(ref v) = value { + args.insert("value".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = enabled { + args.insert("enabled".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withOptionalString", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Configures the resource with a DTO + pub fn with_config(&self, config: TestConfigDto) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("config".to_string(), serde_json::to_value(&config).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withConfig", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Configures environment with callback (test version) + pub fn test_with_environment_callback(&self, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(callback); + args.insert("callback".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/testWithEnvironmentCallback", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEnvironment::new(handle, self.client.clone())) + } + + /// Sets the created timestamp + pub fn with_created_at(&self, created_at: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("createdAt".to_string(), serde_json::to_value(&created_at).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withCreatedAt", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Sets the modified timestamp + pub fn with_modified_at(&self, modified_at: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("modifiedAt".to_string(), serde_json::to_value(&modified_at).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withModifiedAt", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Sets the correlation ID + pub fn with_correlation_id(&self, correlation_id: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("correlationId".to_string(), serde_json::to_value(&correlation_id).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withCorrelationId", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Configures with optional callback + pub fn with_optional_callback(&self, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(callback); + args.insert("callback".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withOptionalCallback", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Sets the resource status + pub fn with_status(&self, status: TestResourceStatus) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("status".to_string(), serde_json::to_value(&status).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withStatus", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Configures with nested DTO + pub fn with_nested_config(&self, config: TestNestedDto) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("config".to_string(), serde_json::to_value(&config).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withNestedConfig", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Adds validation callback + pub fn with_validator(&self, validator: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(validator); + args.insert("validator".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withValidator", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Waits for another resource (test version) + pub fn test_wait_for(&self, dependency: &IResource) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("dependency".to_string(), dependency.handle().to_json()); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/testWaitFor", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Adds a dependency on another resource + pub fn with_dependency(&self, dependency: &IResourceWithConnectionString) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("dependency".to_string(), dependency.handle().to_json()); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withDependency", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Sets the endpoints + pub fn with_endpoints(&self, endpoints: Vec) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("endpoints".to_string(), serde_json::to_value(&endpoints).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withEndpoints", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Sets environment variables + pub fn with_environment_variables(&self, variables: HashMap) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("variables".to_string(), serde_json::to_value(&variables).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withEnvironmentVariables", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEnvironment::new(handle, self.client.clone())) + } + + /// Performs a cancellable operation + pub fn with_cancellable_operation(&self, operation: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(operation); + args.insert("operation".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withCancellableOperation", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } +} + /// Wrapper for Aspire.Hosting.CodeGeneration.Rust.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext pub struct TestEnvironmentContext { handle: Handle, @@ -483,6 +668,19 @@ impl TestRedisResource { &self.client } + /// Adds a child database to a test Redis resource + pub fn add_test_child_database(&self, name: &str, database_name: Option<&str>) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("name".to_string(), serde_json::to_value(&name).unwrap_or(Value::Null)); + if let Some(ref v) = database_name { + args.insert("databaseName".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/addTestChildDatabase", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(TestDatabaseResource::new(handle, self.client.clone())) + } + /// Configures the Redis resource with persistence pub fn with_persistence(&self, mode: Option) -> Result> { let mut args: HashMap = HashMap::new(); diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index 13a268d1dd0..1591ba1dbb6 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -3633,6 +3633,681 @@ impl TestCollectionContext { } } +/// Wrapper for Aspire.Hosting.CodeGeneration.Rust.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource +pub struct TestDatabaseResource { + handle: Handle, + client: Arc, +} + +impl HasHandle for TestDatabaseResource { + fn handle(&self) -> &Handle { + &self.handle + } +} + +impl TestDatabaseResource { + pub fn new(handle: Handle, client: Arc) -> Self { + Self { handle, client } + } + + pub fn handle(&self) -> &Handle { + &self.handle + } + + pub fn client(&self) -> &Arc { + &self.client + } + + /// Adds a bind mount + pub fn with_bind_mount(&self, source: &str, target: &str, is_read_only: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("source".to_string(), serde_json::to_value(&source).unwrap_or(Value::Null)); + args.insert("target".to_string(), serde_json::to_value(&target).unwrap_or(Value::Null)); + if let Some(ref v) = is_read_only { + args.insert("isReadOnly".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withBindMount", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(ContainerResource::new(handle, self.client.clone())) + } + + /// Sets the container entrypoint + pub fn with_entrypoint(&self, entrypoint: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("entrypoint".to_string(), serde_json::to_value(&entrypoint).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/withEntrypoint", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(ContainerResource::new(handle, self.client.clone())) + } + + /// Sets the container image tag + pub fn with_image_tag(&self, tag: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("tag".to_string(), serde_json::to_value(&tag).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/withImageTag", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(ContainerResource::new(handle, self.client.clone())) + } + + /// Sets the container image registry + pub fn with_image_registry(&self, registry: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("registry".to_string(), serde_json::to_value(®istry).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/withImageRegistry", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(ContainerResource::new(handle, self.client.clone())) + } + + /// Sets the container image + pub fn with_image(&self, image: &str, tag: Option<&str>) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("image".to_string(), serde_json::to_value(&image).unwrap_or(Value::Null)); + if let Some(ref v) = tag { + args.insert("tag".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withImage", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(ContainerResource::new(handle, self.client.clone())) + } + + /// Adds runtime arguments for the container + pub fn with_container_runtime_args(&self, args: Vec) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("args".to_string(), serde_json::to_value(&args).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/withContainerRuntimeArgs", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(ContainerResource::new(handle, self.client.clone())) + } + + /// Sets the lifetime behavior of the container resource + pub fn with_lifetime(&self, lifetime: ContainerLifetime) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("lifetime".to_string(), serde_json::to_value(&lifetime).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/withLifetime", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(ContainerResource::new(handle, self.client.clone())) + } + + /// Sets the container image pull policy + pub fn with_image_pull_policy(&self, pull_policy: ImagePullPolicy) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("pullPolicy".to_string(), serde_json::to_value(&pull_policy).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/withImagePullPolicy", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(ContainerResource::new(handle, self.client.clone())) + } + + /// Sets the container name + pub fn with_container_name(&self, name: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("name".to_string(), serde_json::to_value(&name).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/withContainerName", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(ContainerResource::new(handle, self.client.clone())) + } + + /// Sets an environment variable + pub fn with_environment(&self, name: &str, value: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("name".to_string(), serde_json::to_value(&name).unwrap_or(Value::Null)); + args.insert("value".to_string(), serde_json::to_value(&value).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/withEnvironment", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEnvironment::new(handle, self.client.clone())) + } + + /// Adds an environment variable with a reference expression + pub fn with_environment_expression(&self, name: &str, value: ReferenceExpression) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("name".to_string(), serde_json::to_value(&name).unwrap_or(Value::Null)); + args.insert("value".to_string(), serde_json::to_value(&value).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/withEnvironmentExpression", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEnvironment::new(handle, self.client.clone())) + } + + /// Sets environment variables via callback + pub fn with_environment_callback(&self, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(callback); + args.insert("callback".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting/withEnvironmentCallback", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEnvironment::new(handle, self.client.clone())) + } + + /// Sets environment variables via async callback + pub fn with_environment_callback_async(&self, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(callback); + args.insert("callback".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting/withEnvironmentCallbackAsync", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEnvironment::new(handle, self.client.clone())) + } + + /// Adds arguments + pub fn with_args(&self, args: Vec) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("args".to_string(), serde_json::to_value(&args).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/withArgs", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithArgs::new(handle, self.client.clone())) + } + + /// Sets command-line arguments via callback + pub fn with_args_callback(&self, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(callback); + args.insert("callback".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting/withArgsCallback", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithArgs::new(handle, self.client.clone())) + } + + /// Sets command-line arguments via async callback + pub fn with_args_callback_async(&self, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(callback); + args.insert("callback".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting/withArgsCallbackAsync", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithArgs::new(handle, self.client.clone())) + } + + /// Adds a reference to another resource + pub fn with_reference(&self, source: &IResourceWithConnectionString, connection_name: Option<&str>, optional: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("source".to_string(), source.handle().to_json()); + if let Some(ref v) = connection_name { + args.insert("connectionName".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = optional { + args.insert("optional".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withReference", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEnvironment::new(handle, self.client.clone())) + } + + /// Adds a service discovery reference to another resource + pub fn with_service_reference(&self, source: &IResourceWithServiceDiscovery) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("source".to_string(), source.handle().to_json()); + let result = self.client.invoke_capability("Aspire.Hosting/withServiceReference", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEnvironment::new(handle, self.client.clone())) + } + + /// Adds a network endpoint + pub fn with_endpoint(&self, port: Option, target_port: Option, scheme: Option<&str>, name: Option<&str>, env: Option<&str>, is_proxied: Option, is_external: Option, protocol: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + if let Some(ref v) = port { + args.insert("port".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = target_port { + args.insert("targetPort".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = scheme { + args.insert("scheme".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = name { + args.insert("name".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = env { + args.insert("env".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = is_proxied { + args.insert("isProxied".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = is_external { + args.insert("isExternal".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = protocol { + args.insert("protocol".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withEndpoint", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + + /// Adds an HTTP endpoint + pub fn with_http_endpoint(&self, port: Option, target_port: Option, name: Option<&str>, env: Option<&str>, is_proxied: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + if let Some(ref v) = port { + args.insert("port".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = target_port { + args.insert("targetPort".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = name { + args.insert("name".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = env { + args.insert("env".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = is_proxied { + args.insert("isProxied".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withHttpEndpoint", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + + /// Adds an HTTPS endpoint + pub fn with_https_endpoint(&self, port: Option, target_port: Option, name: Option<&str>, env: Option<&str>, is_proxied: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + if let Some(ref v) = port { + args.insert("port".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = target_port { + args.insert("targetPort".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = name { + args.insert("name".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = env { + args.insert("env".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = is_proxied { + args.insert("isProxied".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withHttpsEndpoint", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + + /// Makes HTTP endpoints externally accessible + pub fn with_external_http_endpoints(&self) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let result = self.client.invoke_capability("Aspire.Hosting/withExternalHttpEndpoints", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + + /// Gets an endpoint reference + pub fn get_endpoint(&self, name: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("name".to_string(), serde_json::to_value(&name).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/getEndpoint", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(EndpointReference::new(handle, self.client.clone())) + } + + /// Configures resource for HTTP/2 + pub fn as_http2_service(&self) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let result = self.client.invoke_capability("Aspire.Hosting/asHttp2Service", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + + /// Customizes displayed URLs via callback + pub fn with_urls_callback(&self, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(callback); + args.insert("callback".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting/withUrlsCallback", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Customizes displayed URLs via async callback + pub fn with_urls_callback_async(&self, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(callback); + args.insert("callback".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting/withUrlsCallbackAsync", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Adds or modifies displayed URLs + pub fn with_url(&self, url: &str, display_text: Option<&str>) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("url".to_string(), serde_json::to_value(&url).unwrap_or(Value::Null)); + if let Some(ref v) = display_text { + args.insert("displayText".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withUrl", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Adds a URL using a reference expression + pub fn with_url_expression(&self, url: ReferenceExpression, display_text: Option<&str>) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("url".to_string(), serde_json::to_value(&url).unwrap_or(Value::Null)); + if let Some(ref v) = display_text { + args.insert("displayText".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withUrlExpression", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Customizes the URL for a specific endpoint via callback + pub fn with_url_for_endpoint(&self, endpoint_name: &str, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("endpointName".to_string(), serde_json::to_value(&endpoint_name).unwrap_or(Value::Null)); + let callback_id = register_callback(callback); + args.insert("callback".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting/withUrlForEndpoint", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Adds a URL for a specific endpoint via factory callback + pub fn with_url_for_endpoint_factory(&self, endpoint_name: &str, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("endpointName".to_string(), serde_json::to_value(&endpoint_name).unwrap_or(Value::Null)); + let callback_id = register_callback(callback); + args.insert("callback".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting/withUrlForEndpointFactory", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + + /// Waits for another resource to be ready + pub fn wait_for(&self, dependency: &IResource) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("dependency".to_string(), dependency.handle().to_json()); + let result = self.client.invoke_capability("Aspire.Hosting/waitFor", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithWaitSupport::new(handle, self.client.clone())) + } + + /// Prevents resource from starting automatically + pub fn with_explicit_start(&self) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let result = self.client.invoke_capability("Aspire.Hosting/withExplicitStart", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Waits for resource completion + pub fn wait_for_completion(&self, dependency: &IResource, exit_code: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("dependency".to_string(), dependency.handle().to_json()); + if let Some(ref v) = exit_code { + args.insert("exitCode".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/waitForCompletion", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithWaitSupport::new(handle, self.client.clone())) + } + + /// Adds a health check by key + pub fn with_health_check(&self, key: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("key".to_string(), serde_json::to_value(&key).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting/withHealthCheck", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Adds an HTTP health check + pub fn with_http_health_check(&self, path: Option<&str>, status_code: Option, endpoint_name: Option<&str>) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + if let Some(ref v) = path { + args.insert("path".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = status_code { + args.insert("statusCode".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = endpoint_name { + args.insert("endpointName".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withHttpHealthCheck", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEndpoints::new(handle, self.client.clone())) + } + + /// Adds a resource command + pub fn with_command(&self, name: &str, display_name: &str, execute_command: impl Fn(Vec) -> Value + Send + Sync + 'static, command_options: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("name".to_string(), serde_json::to_value(&name).unwrap_or(Value::Null)); + args.insert("displayName".to_string(), serde_json::to_value(&display_name).unwrap_or(Value::Null)); + let callback_id = register_callback(execute_command); + args.insert("executeCommand".to_string(), Value::String(callback_id)); + if let Some(ref v) = command_options { + args.insert("commandOptions".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withCommand", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Sets the parent relationship + pub fn with_parent_relationship(&self, parent: &IResource) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("parent".to_string(), parent.handle().to_json()); + let result = self.client.invoke_capability("Aspire.Hosting/withParentRelationship", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Adds a volume + pub fn with_volume(&self, target: &str, name: Option<&str>, is_read_only: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("resource".to_string(), self.handle.to_json()); + args.insert("target".to_string(), serde_json::to_value(&target).unwrap_or(Value::Null)); + if let Some(ref v) = name { + args.insert("name".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = is_read_only { + args.insert("isReadOnly".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting/withVolume", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(ContainerResource::new(handle, self.client.clone())) + } + + /// Gets the resource name + pub fn get_resource_name(&self) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("resource".to_string(), self.handle.to_json()); + let result = self.client.invoke_capability("Aspire.Hosting/getResourceName", args)?; + Ok(serde_json::from_value(result)?) + } + + /// Adds an optional string parameter + pub fn with_optional_string(&self, value: Option<&str>, enabled: Option) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + if let Some(ref v) = value { + args.insert("value".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + if let Some(ref v) = enabled { + args.insert("enabled".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withOptionalString", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Configures the resource with a DTO + pub fn with_config(&self, config: TestConfigDto) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("config".to_string(), serde_json::to_value(&config).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withConfig", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Configures environment with callback (test version) + pub fn test_with_environment_callback(&self, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(callback); + args.insert("callback".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/testWithEnvironmentCallback", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEnvironment::new(handle, self.client.clone())) + } + + /// Sets the created timestamp + pub fn with_created_at(&self, created_at: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("createdAt".to_string(), serde_json::to_value(&created_at).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withCreatedAt", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Sets the modified timestamp + pub fn with_modified_at(&self, modified_at: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("modifiedAt".to_string(), serde_json::to_value(&modified_at).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withModifiedAt", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Sets the correlation ID + pub fn with_correlation_id(&self, correlation_id: &str) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("correlationId".to_string(), serde_json::to_value(&correlation_id).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withCorrelationId", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Configures with optional callback + pub fn with_optional_callback(&self, callback: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(callback); + args.insert("callback".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withOptionalCallback", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Sets the resource status + pub fn with_status(&self, status: TestResourceStatus) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("status".to_string(), serde_json::to_value(&status).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withStatus", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Configures with nested DTO + pub fn with_nested_config(&self, config: TestNestedDto) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("config".to_string(), serde_json::to_value(&config).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withNestedConfig", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Adds validation callback + pub fn with_validator(&self, validator: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(validator); + args.insert("validator".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withValidator", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Waits for another resource (test version) + pub fn test_wait_for(&self, dependency: &IResource) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("dependency".to_string(), dependency.handle().to_json()); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/testWaitFor", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Adds a dependency on another resource + pub fn with_dependency(&self, dependency: &IResourceWithConnectionString) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("dependency".to_string(), dependency.handle().to_json()); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withDependency", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Sets the endpoints + pub fn with_endpoints(&self, endpoints: Vec) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("endpoints".to_string(), serde_json::to_value(&endpoints).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withEndpoints", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } + + /// Sets environment variables + pub fn with_environment_variables(&self, variables: HashMap) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("variables".to_string(), serde_json::to_value(&variables).unwrap_or(Value::Null)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withEnvironmentVariables", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResourceWithEnvironment::new(handle, self.client.clone())) + } + + /// Performs a cancellable operation + pub fn with_cancellable_operation(&self, operation: impl Fn(Vec) -> Value + Send + Sync + 'static) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + let callback_id = register_callback(operation); + args.insert("operation".to_string(), Value::String(callback_id)); + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/withCancellableOperation", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(IResource::new(handle, self.client.clone())) + } +} + /// Wrapper for Aspire.Hosting.CodeGeneration.Rust.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext pub struct TestEnvironmentContext { handle: Handle, @@ -4228,6 +4903,19 @@ impl TestRedisResource { Ok(serde_json::from_value(result)?) } + /// Adds a child database to a test Redis resource + pub fn add_test_child_database(&self, name: &str, database_name: Option<&str>) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("builder".to_string(), self.handle.to_json()); + args.insert("name".to_string(), serde_json::to_value(&name).unwrap_or(Value::Null)); + if let Some(ref v) = database_name { + args.insert("databaseName".to_string(), serde_json::to_value(v).unwrap_or(Value::Null)); + } + let result = self.client.invoke_capability("Aspire.Hosting.CodeGeneration.Rust.Tests/addTestChildDatabase", args)?; + let handle: Handle = serde_json::from_value(result)?; + Ok(TestDatabaseResource::new(handle, self.client.clone())) + } + /// Configures the Redis resource with persistence pub fn with_persistence(&self, mode: Option) -> Result> { let mut args: HashMap = HashMap::new(); diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/WithOptionalStringCapability.verified.txt b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/WithOptionalStringCapability.verified.txt index 771ce4719da..157565cfbd5 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/WithOptionalStringCapability.verified.txt +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/WithOptionalStringCapability.verified.txt @@ -68,6 +68,16 @@ IsResourceBuilder: true, IsDistributedApplicationBuilder: false, IsDistributedApplication: false + }, + { + TypeId: Aspire.Hosting.CodeGeneration.Rust.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource, + ClrType: TestDatabaseResource, + Category: Handle, + IsInterface: false, + IsReadOnly: false, + IsResourceBuilder: true, + IsDistributedApplicationBuilder: false, + IsDistributedApplication: false } ], ReturnsBuilder: true, diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs index 1120a7082f5..ad23dede7b0 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/AtsTypeScriptCodeGeneratorTests.cs @@ -296,6 +296,31 @@ public void Scanner_ReturnsBuilder_TrueForResourceBuilderReturnTypes() "withRedisSpecific returns IResourceBuilder but ReturnsBuilder is false - thenable wrapper won't be generated"); } + [Fact] + public void FactoryMethod_ReturnsChildResourceType_NotParentType() + { + // Regression test: Factory methods on a builder (e.g., AddDatabase on SqlServerServerResource) + // must return the child resource type, not the parent/receiver type. + // Previously, the codegen always used the builder's own type for the return type, + // causing addDatabase() to return SqlServerServerResourcePromise instead of + // SqlServerDatabaseResourcePromise. + var atsContext = CreateContextFromTestAssembly(); + var files = _generator.GenerateDistributedApplication(atsContext); + var aspireTs = files["aspire.ts"]; + + // addTestChildDatabase is a factory method on TestRedisResource that returns TestDatabaseResource. + // The generated internal method must return TestDatabaseResource, not TestRedisResource. + Assert.Contains("_addTestChildDatabaseInternal", aspireTs); + Assert.Contains("Promise", aspireTs); + + // The public fluent method must return TestDatabaseResourcePromise, not TestRedisResourcePromise. + Assert.Matches(@"addTestChildDatabase\([^)]*\):\s*TestDatabaseResourcePromise", aspireTs); + + // Verify the thenable class also uses the child type's promise class. + // In TestRedisResourcePromise, addTestChildDatabase should return TestDatabaseResourcePromise. + Assert.Contains("new TestDatabaseResourcePromise(this._promise.then(obj => obj.addTestChildDatabase(", aspireTs); + } + [Fact] public async Task Scanner_WithPersistence_HasCorrectExpandedTargets() { diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts index ba7f48633df..b5637614b86 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/AtsGeneratedAspire.verified.ts @@ -32,6 +32,9 @@ type TestCallbackContextHandle = Handle<'Aspire.Hosting.CodeGeneration.TypeScrip /** Handle to TestCollectionContext */ type TestCollectionContextHandle = Handle<'Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCollectionContext'>; +/** Handle to TestDatabaseResource */ +type TestDatabaseResourceHandle = Handle<'Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource'>; + /** Handle to TestEnvironmentContext */ type TestEnvironmentContextHandle = Handle<'Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext'>; @@ -105,6 +108,10 @@ export interface TestNestedDto { // Options Interfaces // ============================================================================ +export interface AddTestChildDatabaseOptions { + databaseName?: string; +} + export interface AddTestRedisOptions { port?: number; } @@ -461,6 +468,359 @@ export class DistributedApplicationBuilderPromise implements PromiseLike { + constructor(handle: TestDatabaseResourceHandle, client: AspireClientRpc) { + super(handle, client); + } + + /** @internal */ + private async _withOptionalStringInternal(value?: string, enabled?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (value !== undefined) rpcArgs.value = value; + if (enabled !== undefined) rpcArgs.enabled = enabled; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withOptionalString', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Adds an optional string parameter */ + withOptionalString(options?: WithOptionalStringOptions): TestDatabaseResourcePromise { + const value = options?.value; + const enabled = options?.enabled; + return new TestDatabaseResourcePromise(this._withOptionalStringInternal(value, enabled)); + } + + /** @internal */ + private async _withConfigInternal(config: TestConfigDto): Promise { + const rpcArgs: Record = { builder: this._handle, config }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withConfig', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Configures the resource with a DTO */ + withConfig(config: TestConfigDto): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withConfigInternal(config)); + } + + /** @internal */ + private async _testWithEnvironmentCallbackInternal(callback: (arg: TestEnvironmentContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as TestEnvironmentContextHandle; + const arg = new TestEnvironmentContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/testWithEnvironmentCallback', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Configures environment with callback (test version) */ + testWithEnvironmentCallback(callback: (arg: TestEnvironmentContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._testWithEnvironmentCallbackInternal(callback)); + } + + /** @internal */ + private async _withCreatedAtInternal(createdAt: string): Promise { + const rpcArgs: Record = { builder: this._handle, createdAt }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withCreatedAt', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Sets the created timestamp */ + withCreatedAt(createdAt: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withCreatedAtInternal(createdAt)); + } + + /** @internal */ + private async _withModifiedAtInternal(modifiedAt: string): Promise { + const rpcArgs: Record = { builder: this._handle, modifiedAt }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withModifiedAt', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Sets the modified timestamp */ + withModifiedAt(modifiedAt: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withModifiedAtInternal(modifiedAt)); + } + + /** @internal */ + private async _withCorrelationIdInternal(correlationId: string): Promise { + const rpcArgs: Record = { builder: this._handle, correlationId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withCorrelationId', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Sets the correlation ID */ + withCorrelationId(correlationId: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withCorrelationIdInternal(correlationId)); + } + + /** @internal */ + private async _withOptionalCallbackInternal(callback?: (arg: TestCallbackContext) => Promise): Promise { + const callbackId = callback ? registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as TestCallbackContextHandle; + const arg = new TestCallbackContext(argHandle, this._client); + await callback(arg); + }) : undefined; + const rpcArgs: Record = { builder: this._handle }; + if (callback !== undefined) rpcArgs.callback = callbackId; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withOptionalCallback', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Configures with optional callback */ + withOptionalCallback(options?: WithOptionalCallbackOptions): TestDatabaseResourcePromise { + const callback = options?.callback; + return new TestDatabaseResourcePromise(this._withOptionalCallbackInternal(callback)); + } + + /** @internal */ + private async _withStatusInternal(status: TestResourceStatus): Promise { + const rpcArgs: Record = { builder: this._handle, status }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withStatus', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Sets the resource status */ + withStatus(status: TestResourceStatus): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withStatusInternal(status)); + } + + /** @internal */ + private async _withNestedConfigInternal(config: TestNestedDto): Promise { + const rpcArgs: Record = { builder: this._handle, config }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withNestedConfig', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Configures with nested DTO */ + withNestedConfig(config: TestNestedDto): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withNestedConfigInternal(config)); + } + + /** @internal */ + private async _withValidatorInternal(validator: (arg: TestResourceContext) => Promise): Promise { + const validatorId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as TestResourceContextHandle; + const arg = new TestResourceContext(argHandle, this._client); + return await validator(arg); + }); + const rpcArgs: Record = { builder: this._handle, validator: validatorId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withValidator', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Adds validation callback */ + withValidator(validator: (arg: TestResourceContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withValidatorInternal(validator)); + } + + /** @internal */ + private async _testWaitForInternal(dependency: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, dependency }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/testWaitFor', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Waits for another resource (test version) */ + testWaitFor(dependency: ResourceBuilderBase): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._testWaitForInternal(dependency)); + } + + /** @internal */ + private async _withDependencyInternal(dependency: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, dependency }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withDependency', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Adds a dependency on another resource */ + withDependency(dependency: ResourceBuilderBase): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withDependencyInternal(dependency)); + } + + /** @internal */ + private async _withEndpointsInternal(endpoints: string[]): Promise { + const rpcArgs: Record = { builder: this._handle, endpoints }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withEndpoints', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Sets the endpoints */ + withEndpoints(endpoints: string[]): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withEndpointsInternal(endpoints)); + } + + /** @internal */ + private async _withEnvironmentVariablesInternal(variables: Record): Promise { + const rpcArgs: Record = { builder: this._handle, variables }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withEnvironmentVariables', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Sets environment variables */ + withEnvironmentVariables(variables: Record): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withEnvironmentVariablesInternal(variables)); + } + + /** @internal */ + private async _withCancellableOperationInternal(operation: (arg: AbortSignal) => Promise): Promise { + const operationId = registerCallback(async (argData: unknown) => { + const arg = wrapIfHandle(argData) as AbortSignal; + await operation(arg); + }); + const rpcArgs: Record = { builder: this._handle, operation: operationId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withCancellableOperation', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Performs a cancellable operation */ + withCancellableOperation(operation: (arg: AbortSignal) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withCancellableOperationInternal(operation)); + } + +} + +/** + * Thenable wrapper for TestDatabaseResource that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class TestDatabaseResourcePromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: TestDatabaseResource) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Adds an optional string parameter */ + withOptionalString(options?: WithOptionalStringOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withOptionalString(options))); + } + + /** Configures the resource with a DTO */ + withConfig(config: TestConfigDto): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withConfig(config))); + } + + /** Configures environment with callback (test version) */ + testWithEnvironmentCallback(callback: (arg: TestEnvironmentContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.testWithEnvironmentCallback(callback))); + } + + /** Sets the created timestamp */ + withCreatedAt(createdAt: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withCreatedAt(createdAt))); + } + + /** Sets the modified timestamp */ + withModifiedAt(modifiedAt: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withModifiedAt(modifiedAt))); + } + + /** Sets the correlation ID */ + withCorrelationId(correlationId: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withCorrelationId(correlationId))); + } + + /** Configures with optional callback */ + withOptionalCallback(options?: WithOptionalCallbackOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withOptionalCallback(options))); + } + + /** Sets the resource status */ + withStatus(status: TestResourceStatus): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withStatus(status))); + } + + /** Configures with nested DTO */ + withNestedConfig(config: TestNestedDto): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withNestedConfig(config))); + } + + /** Adds validation callback */ + withValidator(validator: (arg: TestResourceContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withValidator(validator))); + } + + /** Waits for another resource (test version) */ + testWaitFor(dependency: ResourceBuilderBase): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.testWaitFor(dependency))); + } + + /** Adds a dependency on another resource */ + withDependency(dependency: ResourceBuilderBase): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withDependency(dependency))); + } + + /** Sets the endpoints */ + withEndpoints(endpoints: string[]): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withEndpoints(endpoints))); + } + + /** Sets environment variables */ + withEnvironmentVariables(variables: Record): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withEnvironmentVariables(variables))); + } + + /** Performs a cancellable operation */ + withCancellableOperation(operation: (arg: AbortSignal) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withCancellableOperation(operation))); + } + +} + // ============================================================================ // TestRedisResource // ============================================================================ @@ -470,6 +830,23 @@ export class TestRedisResource extends ResourceBuilderBase { + const rpcArgs: Record = { builder: this._handle, name }; + if (databaseName !== undefined) rpcArgs.databaseName = databaseName; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/addTestChildDatabase', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Adds a child database to a test Redis resource */ + addTestChildDatabase(name: string, options?: AddTestChildDatabaseOptions): TestDatabaseResourcePromise { + const databaseName = options?.databaseName; + return new TestDatabaseResourcePromise(this._addTestChildDatabaseInternal(name, databaseName)); + } + /** @internal */ private async _withPersistenceInternal(mode?: TestPersistenceMode): Promise { const rpcArgs: Record = { builder: this._handle }; @@ -850,6 +1227,11 @@ export class TestRedisResourcePromise implements PromiseLike return this._promise.then(onfulfilled, onrejected); } + /** Adds a child database to a test Redis resource */ + addTestChildDatabase(name: string, options?: AddTestChildDatabaseOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.addTestChildDatabase(name, options))); + } + /** Configures the Redis resource with persistence */ withPersistence(options?: WithPersistenceOptions): TestRedisResourcePromise { return new TestRedisResourcePromise(this._promise.then(obj => obj.withPersistence(options))); @@ -1532,6 +1914,7 @@ registerHandleWrapper('Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hos registerHandleWrapper('Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext', (handle, client) => new TestEnvironmentContext(handle as TestEnvironmentContextHandle, client)); registerHandleWrapper('Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestResourceContext', (handle, client) => new TestResourceContext(handle as TestResourceContextHandle, client)); registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.IDistributedApplicationBuilder', (handle, client) => new DistributedApplicationBuilder(handle as IDistributedApplicationBuilderHandle, client)); +registerHandleWrapper('Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource', (handle, client) => new TestDatabaseResource(handle as TestDatabaseResourceHandle, client)); registerHandleWrapper('Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestRedisResource', (handle, client) => new TestRedisResource(handle as TestRedisResourceHandle, client)); registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource', (handle, client) => new Resource(handle as IResourceHandle, client)); registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithConnectionString', (handle, client) => new ResourceWithConnectionString(handle as IResourceWithConnectionStringHandle, client)); diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index 5597351eeee..15222acf4e2 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -32,6 +32,9 @@ type TestCallbackContextHandle = Handle<'Aspire.Hosting.CodeGeneration.TypeScrip /** Handle to TestCollectionContext */ type TestCollectionContextHandle = Handle<'Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestCollectionContext'>; +/** Handle to TestDatabaseResource */ +type TestDatabaseResourceHandle = Handle<'Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource'>; + /** Handle to TestEnvironmentContext */ type TestEnvironmentContextHandle = Handle<'Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestEnvironmentContext'>; @@ -314,6 +317,10 @@ export interface AddParameterOptions { secret?: boolean; } +export interface AddTestChildDatabaseOptions { + databaseName?: string; +} + export interface AddTestRedisOptions { port?: number; } @@ -5140,314 +5147,314 @@ export class ProjectResourcePromise implements PromiseLike { } // ============================================================================ -// TestRedisResource +// TestDatabaseResource // ============================================================================ -export class TestRedisResource extends ResourceBuilderBase { - constructor(handle: TestRedisResourceHandle, client: AspireClientRpc) { +export class TestDatabaseResource extends ResourceBuilderBase { + constructor(handle: TestDatabaseResourceHandle, client: AspireClientRpc) { super(handle, client); } /** @internal */ - private async _withBindMountInternal(source: string, target: string, isReadOnly?: boolean): Promise { + private async _withBindMountInternal(source: string, target: string, isReadOnly?: boolean): Promise { const rpcArgs: Record = { builder: this._handle, source, target }; if (isReadOnly !== undefined) rpcArgs.isReadOnly = isReadOnly; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withBindMount', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds a bind mount */ - withBindMount(source: string, target: string, options?: WithBindMountOptions): TestRedisResourcePromise { + withBindMount(source: string, target: string, options?: WithBindMountOptions): TestDatabaseResourcePromise { const isReadOnly = options?.isReadOnly; - return new TestRedisResourcePromise(this._withBindMountInternal(source, target, isReadOnly)); + return new TestDatabaseResourcePromise(this._withBindMountInternal(source, target, isReadOnly)); } /** @internal */ - private async _withEntrypointInternal(entrypoint: string): Promise { + private async _withEntrypointInternal(entrypoint: string): Promise { const rpcArgs: Record = { builder: this._handle, entrypoint }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withEntrypoint', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Sets the container entrypoint */ - withEntrypoint(entrypoint: string): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withEntrypointInternal(entrypoint)); + withEntrypoint(entrypoint: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withEntrypointInternal(entrypoint)); } /** @internal */ - private async _withImageTagInternal(tag: string): Promise { + private async _withImageTagInternal(tag: string): Promise { const rpcArgs: Record = { builder: this._handle, tag }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withImageTag', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Sets the container image tag */ - withImageTag(tag: string): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withImageTagInternal(tag)); + withImageTag(tag: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withImageTagInternal(tag)); } /** @internal */ - private async _withImageRegistryInternal(registry: string): Promise { + private async _withImageRegistryInternal(registry: string): Promise { const rpcArgs: Record = { builder: this._handle, registry }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withImageRegistry', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Sets the container image registry */ - withImageRegistry(registry: string): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withImageRegistryInternal(registry)); + withImageRegistry(registry: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withImageRegistryInternal(registry)); } /** @internal */ - private async _withImageInternal(image: string, tag?: string): Promise { + private async _withImageInternal(image: string, tag?: string): Promise { const rpcArgs: Record = { builder: this._handle, image }; if (tag !== undefined) rpcArgs.tag = tag; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withImage', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Sets the container image */ - withImage(image: string, options?: WithImageOptions): TestRedisResourcePromise { + withImage(image: string, options?: WithImageOptions): TestDatabaseResourcePromise { const tag = options?.tag; - return new TestRedisResourcePromise(this._withImageInternal(image, tag)); + return new TestDatabaseResourcePromise(this._withImageInternal(image, tag)); } /** @internal */ - private async _withContainerRuntimeArgsInternal(args: string[]): Promise { + private async _withContainerRuntimeArgsInternal(args: string[]): Promise { const rpcArgs: Record = { builder: this._handle, args }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withContainerRuntimeArgs', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds runtime arguments for the container */ - withContainerRuntimeArgs(args: string[]): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withContainerRuntimeArgsInternal(args)); + withContainerRuntimeArgs(args: string[]): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withContainerRuntimeArgsInternal(args)); } /** @internal */ - private async _withLifetimeInternal(lifetime: ContainerLifetime): Promise { + private async _withLifetimeInternal(lifetime: ContainerLifetime): Promise { const rpcArgs: Record = { builder: this._handle, lifetime }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withLifetime', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Sets the lifetime behavior of the container resource */ - withLifetime(lifetime: ContainerLifetime): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withLifetimeInternal(lifetime)); + withLifetime(lifetime: ContainerLifetime): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withLifetimeInternal(lifetime)); } /** @internal */ - private async _withImagePullPolicyInternal(pullPolicy: ImagePullPolicy): Promise { + private async _withImagePullPolicyInternal(pullPolicy: ImagePullPolicy): Promise { const rpcArgs: Record = { builder: this._handle, pullPolicy }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withImagePullPolicy', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Sets the container image pull policy */ - withImagePullPolicy(pullPolicy: ImagePullPolicy): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withImagePullPolicyInternal(pullPolicy)); + withImagePullPolicy(pullPolicy: ImagePullPolicy): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withImagePullPolicyInternal(pullPolicy)); } /** @internal */ - private async _withContainerNameInternal(name: string): Promise { + private async _withContainerNameInternal(name: string): Promise { const rpcArgs: Record = { builder: this._handle, name }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withContainerName', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Sets the container name */ - withContainerName(name: string): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withContainerNameInternal(name)); + withContainerName(name: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withContainerNameInternal(name)); } /** @internal */ - private async _withEnvironmentInternal(name: string, value: string): Promise { + private async _withEnvironmentInternal(name: string, value: string): Promise { const rpcArgs: Record = { builder: this._handle, name, value }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withEnvironment', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Sets an environment variable */ - withEnvironment(name: string, value: string): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withEnvironmentInternal(name, value)); + withEnvironment(name: string, value: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withEnvironmentInternal(name, value)); } /** @internal */ - private async _withEnvironmentExpressionInternal(name: string, value: ReferenceExpression): Promise { + private async _withEnvironmentExpressionInternal(name: string, value: ReferenceExpression): Promise { const rpcArgs: Record = { builder: this._handle, name, value }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withEnvironmentExpression', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds an environment variable with a reference expression */ - withEnvironmentExpression(name: string, value: ReferenceExpression): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withEnvironmentExpressionInternal(name, value)); + withEnvironmentExpression(name: string, value: ReferenceExpression): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withEnvironmentExpressionInternal(name, value)); } /** @internal */ - private async _withEnvironmentCallbackInternal(callback: (obj: EnvironmentCallbackContext) => Promise): Promise { + private async _withEnvironmentCallbackInternal(callback: (obj: EnvironmentCallbackContext) => Promise): Promise { const callbackId = registerCallback(async (objData: unknown) => { const objHandle = wrapIfHandle(objData) as EnvironmentCallbackContextHandle; const obj = new EnvironmentCallbackContext(objHandle, this._client); await callback(obj); }); const rpcArgs: Record = { builder: this._handle, callback: callbackId }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withEnvironmentCallback', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Sets environment variables via callback */ - withEnvironmentCallback(callback: (obj: EnvironmentCallbackContext) => Promise): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withEnvironmentCallbackInternal(callback)); + withEnvironmentCallback(callback: (obj: EnvironmentCallbackContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withEnvironmentCallbackInternal(callback)); } /** @internal */ - private async _withEnvironmentCallbackAsyncInternal(callback: (arg: EnvironmentCallbackContext) => Promise): Promise { + private async _withEnvironmentCallbackAsyncInternal(callback: (arg: EnvironmentCallbackContext) => Promise): Promise { const callbackId = registerCallback(async (argData: unknown) => { const argHandle = wrapIfHandle(argData) as EnvironmentCallbackContextHandle; const arg = new EnvironmentCallbackContext(argHandle, this._client); await callback(arg); }); const rpcArgs: Record = { builder: this._handle, callback: callbackId }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withEnvironmentCallbackAsync', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Sets environment variables via async callback */ - withEnvironmentCallbackAsync(callback: (arg: EnvironmentCallbackContext) => Promise): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withEnvironmentCallbackAsyncInternal(callback)); + withEnvironmentCallbackAsync(callback: (arg: EnvironmentCallbackContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withEnvironmentCallbackAsyncInternal(callback)); } /** @internal */ - private async _withArgsInternal(args: string[]): Promise { + private async _withArgsInternal(args: string[]): Promise { const rpcArgs: Record = { builder: this._handle, args }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withArgs', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds arguments */ - withArgs(args: string[]): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withArgsInternal(args)); + withArgs(args: string[]): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withArgsInternal(args)); } /** @internal */ - private async _withArgsCallbackInternal(callback: (obj: CommandLineArgsCallbackContext) => Promise): Promise { + private async _withArgsCallbackInternal(callback: (obj: CommandLineArgsCallbackContext) => Promise): Promise { const callbackId = registerCallback(async (objData: unknown) => { const objHandle = wrapIfHandle(objData) as CommandLineArgsCallbackContextHandle; const obj = new CommandLineArgsCallbackContext(objHandle, this._client); await callback(obj); }); const rpcArgs: Record = { builder: this._handle, callback: callbackId }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withArgsCallback', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Sets command-line arguments via callback */ - withArgsCallback(callback: (obj: CommandLineArgsCallbackContext) => Promise): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withArgsCallbackInternal(callback)); + withArgsCallback(callback: (obj: CommandLineArgsCallbackContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withArgsCallbackInternal(callback)); } /** @internal */ - private async _withArgsCallbackAsyncInternal(callback: (arg: CommandLineArgsCallbackContext) => Promise): Promise { + private async _withArgsCallbackAsyncInternal(callback: (arg: CommandLineArgsCallbackContext) => Promise): Promise { const callbackId = registerCallback(async (argData: unknown) => { const argHandle = wrapIfHandle(argData) as CommandLineArgsCallbackContextHandle; const arg = new CommandLineArgsCallbackContext(argHandle, this._client); await callback(arg); }); const rpcArgs: Record = { builder: this._handle, callback: callbackId }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withArgsCallbackAsync', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Sets command-line arguments via async callback */ - withArgsCallbackAsync(callback: (arg: CommandLineArgsCallbackContext) => Promise): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withArgsCallbackAsyncInternal(callback)); + withArgsCallbackAsync(callback: (arg: CommandLineArgsCallbackContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withArgsCallbackAsyncInternal(callback)); } /** @internal */ - private async _withReferenceInternal(source: ResourceBuilderBase, connectionName?: string, optional?: boolean): Promise { + private async _withReferenceInternal(source: ResourceBuilderBase, connectionName?: string, optional?: boolean): Promise { const rpcArgs: Record = { builder: this._handle, source }; if (connectionName !== undefined) rpcArgs.connectionName = connectionName; if (optional !== undefined) rpcArgs.optional = optional; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withReference', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds a reference to another resource */ - withReference(source: ResourceBuilderBase, options?: WithReferenceOptions): TestRedisResourcePromise { + withReference(source: ResourceBuilderBase, options?: WithReferenceOptions): TestDatabaseResourcePromise { const connectionName = options?.connectionName; const optional = options?.optional; - return new TestRedisResourcePromise(this._withReferenceInternal(source, connectionName, optional)); + return new TestDatabaseResourcePromise(this._withReferenceInternal(source, connectionName, optional)); } /** @internal */ - private async _withServiceReferenceInternal(source: ResourceBuilderBase): Promise { + private async _withServiceReferenceInternal(source: ResourceBuilderBase): Promise { const rpcArgs: Record = { builder: this._handle, source }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withServiceReference', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds a service discovery reference to another resource */ - withServiceReference(source: ResourceBuilderBase): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withServiceReferenceInternal(source)); + withServiceReference(source: ResourceBuilderBase): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withServiceReferenceInternal(source)); } /** @internal */ - private async _withEndpointInternal(port?: number, targetPort?: number, scheme?: string, name?: string, env?: string, isProxied?: boolean, isExternal?: boolean, protocol?: ProtocolType): Promise { + private async _withEndpointInternal(port?: number, targetPort?: number, scheme?: string, name?: string, env?: string, isProxied?: boolean, isExternal?: boolean, protocol?: ProtocolType): Promise { const rpcArgs: Record = { builder: this._handle }; if (port !== undefined) rpcArgs.port = port; if (targetPort !== undefined) rpcArgs.targetPort = targetPort; @@ -5457,15 +5464,15 @@ export class TestRedisResource extends ResourceBuilderBase( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withEndpoint', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds a network endpoint */ - withEndpoint(options?: WithEndpointOptions): TestRedisResourcePromise { + withEndpoint(options?: WithEndpointOptions): TestDatabaseResourcePromise { const port = options?.port; const targetPort = options?.targetPort; const scheme = options?.scheme; @@ -5474,72 +5481,72 @@ export class TestRedisResource extends ResourceBuilderBase { + private async _withHttpEndpointInternal(port?: number, targetPort?: number, name?: string, env?: string, isProxied?: boolean): Promise { const rpcArgs: Record = { builder: this._handle }; if (port !== undefined) rpcArgs.port = port; if (targetPort !== undefined) rpcArgs.targetPort = targetPort; if (name !== undefined) rpcArgs.name = name; if (env !== undefined) rpcArgs.env = env; if (isProxied !== undefined) rpcArgs.isProxied = isProxied; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withHttpEndpoint', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds an HTTP endpoint */ - withHttpEndpoint(options?: WithHttpEndpointOptions): TestRedisResourcePromise { + withHttpEndpoint(options?: WithHttpEndpointOptions): TestDatabaseResourcePromise { const port = options?.port; const targetPort = options?.targetPort; const name = options?.name; const env = options?.env; const isProxied = options?.isProxied; - return new TestRedisResourcePromise(this._withHttpEndpointInternal(port, targetPort, name, env, isProxied)); + return new TestDatabaseResourcePromise(this._withHttpEndpointInternal(port, targetPort, name, env, isProxied)); } /** @internal */ - private async _withHttpsEndpointInternal(port?: number, targetPort?: number, name?: string, env?: string, isProxied?: boolean): Promise { + private async _withHttpsEndpointInternal(port?: number, targetPort?: number, name?: string, env?: string, isProxied?: boolean): Promise { const rpcArgs: Record = { builder: this._handle }; if (port !== undefined) rpcArgs.port = port; if (targetPort !== undefined) rpcArgs.targetPort = targetPort; if (name !== undefined) rpcArgs.name = name; if (env !== undefined) rpcArgs.env = env; if (isProxied !== undefined) rpcArgs.isProxied = isProxied; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withHttpsEndpoint', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds an HTTPS endpoint */ - withHttpsEndpoint(options?: WithHttpsEndpointOptions): TestRedisResourcePromise { + withHttpsEndpoint(options?: WithHttpsEndpointOptions): TestDatabaseResourcePromise { const port = options?.port; const targetPort = options?.targetPort; const name = options?.name; const env = options?.env; const isProxied = options?.isProxied; - return new TestRedisResourcePromise(this._withHttpsEndpointInternal(port, targetPort, name, env, isProxied)); + return new TestDatabaseResourcePromise(this._withHttpsEndpointInternal(port, targetPort, name, env, isProxied)); } /** @internal */ - private async _withExternalHttpEndpointsInternal(): Promise { + private async _withExternalHttpEndpointsInternal(): Promise { const rpcArgs: Record = { builder: this._handle }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withExternalHttpEndpoints', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Makes HTTP endpoints externally accessible */ - withExternalHttpEndpoints(): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withExternalHttpEndpointsInternal()); + withExternalHttpEndpoints(): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withExternalHttpEndpointsInternal()); } /** Gets an endpoint reference */ @@ -5552,218 +5559,218 @@ export class TestRedisResource extends ResourceBuilderBase { + private async _asHttp2ServiceInternal(): Promise { const rpcArgs: Record = { builder: this._handle }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/asHttp2Service', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Configures resource for HTTP/2 */ - asHttp2Service(): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._asHttp2ServiceInternal()); + asHttp2Service(): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._asHttp2ServiceInternal()); } /** @internal */ - private async _withUrlsCallbackInternal(callback: (obj: ResourceUrlsCallbackContext) => Promise): Promise { + private async _withUrlsCallbackInternal(callback: (obj: ResourceUrlsCallbackContext) => Promise): Promise { const callbackId = registerCallback(async (objData: unknown) => { const objHandle = wrapIfHandle(objData) as ResourceUrlsCallbackContextHandle; const obj = new ResourceUrlsCallbackContext(objHandle, this._client); await callback(obj); }); const rpcArgs: Record = { builder: this._handle, callback: callbackId }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withUrlsCallback', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Customizes displayed URLs via callback */ - withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withUrlsCallbackInternal(callback)); + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withUrlsCallbackInternal(callback)); } /** @internal */ - private async _withUrlsCallbackAsyncInternal(callback: (arg: ResourceUrlsCallbackContext) => Promise): Promise { + private async _withUrlsCallbackAsyncInternal(callback: (arg: ResourceUrlsCallbackContext) => Promise): Promise { const callbackId = registerCallback(async (argData: unknown) => { const argHandle = wrapIfHandle(argData) as ResourceUrlsCallbackContextHandle; const arg = new ResourceUrlsCallbackContext(argHandle, this._client); await callback(arg); }); const rpcArgs: Record = { builder: this._handle, callback: callbackId }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withUrlsCallbackAsync', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Customizes displayed URLs via async callback */ - withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withUrlsCallbackAsyncInternal(callback)); + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withUrlsCallbackAsyncInternal(callback)); } /** @internal */ - private async _withUrlInternal(url: string, displayText?: string): Promise { + private async _withUrlInternal(url: string, displayText?: string): Promise { const rpcArgs: Record = { builder: this._handle, url }; if (displayText !== undefined) rpcArgs.displayText = displayText; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withUrl', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds or modifies displayed URLs */ - withUrl(url: string, options?: WithUrlOptions): TestRedisResourcePromise { + withUrl(url: string, options?: WithUrlOptions): TestDatabaseResourcePromise { const displayText = options?.displayText; - return new TestRedisResourcePromise(this._withUrlInternal(url, displayText)); + return new TestDatabaseResourcePromise(this._withUrlInternal(url, displayText)); } /** @internal */ - private async _withUrlExpressionInternal(url: ReferenceExpression, displayText?: string): Promise { + private async _withUrlExpressionInternal(url: ReferenceExpression, displayText?: string): Promise { const rpcArgs: Record = { builder: this._handle, url }; if (displayText !== undefined) rpcArgs.displayText = displayText; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withUrlExpression', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds a URL using a reference expression */ - withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): TestRedisResourcePromise { + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): TestDatabaseResourcePromise { const displayText = options?.displayText; - return new TestRedisResourcePromise(this._withUrlExpressionInternal(url, displayText)); + return new TestDatabaseResourcePromise(this._withUrlExpressionInternal(url, displayText)); } /** @internal */ - private async _withUrlForEndpointInternal(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): Promise { + private async _withUrlForEndpointInternal(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): Promise { const callbackId = registerCallback(async (objData: unknown) => { const obj = wrapIfHandle(objData) as ResourceUrlAnnotation; await callback(obj); }); const rpcArgs: Record = { builder: this._handle, endpointName, callback: callbackId }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withUrlForEndpoint', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Customizes the URL for a specific endpoint via callback */ - withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withUrlForEndpointInternal(endpointName, callback)); + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withUrlForEndpointInternal(endpointName, callback)); } /** @internal */ - private async _withUrlForEndpointFactoryInternal(endpointName: string, callback: (arg: EndpointReference) => Promise): Promise { + private async _withUrlForEndpointFactoryInternal(endpointName: string, callback: (arg: EndpointReference) => Promise): Promise { const callbackId = registerCallback(async (argData: unknown) => { const argHandle = wrapIfHandle(argData) as EndpointReferenceHandle; const arg = new EndpointReference(argHandle, this._client); return await callback(arg); }); const rpcArgs: Record = { builder: this._handle, endpointName, callback: callbackId }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withUrlForEndpointFactory', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds a URL for a specific endpoint via factory callback */ - withUrlForEndpointFactory(endpointName: string, callback: (arg: EndpointReference) => Promise): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withUrlForEndpointFactoryInternal(endpointName, callback)); + withUrlForEndpointFactory(endpointName: string, callback: (arg: EndpointReference) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withUrlForEndpointFactoryInternal(endpointName, callback)); } /** @internal */ - private async _waitForInternal(dependency: ResourceBuilderBase): Promise { + private async _waitForInternal(dependency: ResourceBuilderBase): Promise { const rpcArgs: Record = { builder: this._handle, dependency }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/waitFor', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Waits for another resource to be ready */ - waitFor(dependency: ResourceBuilderBase): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._waitForInternal(dependency)); + waitFor(dependency: ResourceBuilderBase): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._waitForInternal(dependency)); } /** @internal */ - private async _withExplicitStartInternal(): Promise { + private async _withExplicitStartInternal(): Promise { const rpcArgs: Record = { builder: this._handle }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withExplicitStart', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Prevents resource from starting automatically */ - withExplicitStart(): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withExplicitStartInternal()); + withExplicitStart(): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withExplicitStartInternal()); } /** @internal */ - private async _waitForCompletionInternal(dependency: ResourceBuilderBase, exitCode?: number): Promise { + private async _waitForCompletionInternal(dependency: ResourceBuilderBase, exitCode?: number): Promise { const rpcArgs: Record = { builder: this._handle, dependency }; if (exitCode !== undefined) rpcArgs.exitCode = exitCode; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/waitForCompletion', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Waits for resource completion */ - waitForCompletion(dependency: ResourceBuilderBase, options?: WaitForCompletionOptions): TestRedisResourcePromise { + waitForCompletion(dependency: ResourceBuilderBase, options?: WaitForCompletionOptions): TestDatabaseResourcePromise { const exitCode = options?.exitCode; - return new TestRedisResourcePromise(this._waitForCompletionInternal(dependency, exitCode)); + return new TestDatabaseResourcePromise(this._waitForCompletionInternal(dependency, exitCode)); } /** @internal */ - private async _withHealthCheckInternal(key: string): Promise { + private async _withHealthCheckInternal(key: string): Promise { const rpcArgs: Record = { builder: this._handle, key }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withHealthCheck', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds a health check by key */ - withHealthCheck(key: string): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withHealthCheckInternal(key)); + withHealthCheck(key: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withHealthCheckInternal(key)); } /** @internal */ - private async _withHttpHealthCheckInternal(path?: string, statusCode?: number, endpointName?: string): Promise { + private async _withHttpHealthCheckInternal(path?: string, statusCode?: number, endpointName?: string): Promise { const rpcArgs: Record = { builder: this._handle }; if (path !== undefined) rpcArgs.path = path; if (statusCode !== undefined) rpcArgs.statusCode = statusCode; if (endpointName !== undefined) rpcArgs.endpointName = endpointName; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withHttpHealthCheck', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds an HTTP health check */ - withHttpHealthCheck(options?: WithHttpHealthCheckOptions): TestRedisResourcePromise { + withHttpHealthCheck(options?: WithHttpHealthCheckOptions): TestDatabaseResourcePromise { const path = options?.path; const statusCode = options?.statusCode; const endpointName = options?.endpointName; - return new TestRedisResourcePromise(this._withHttpHealthCheckInternal(path, statusCode, endpointName)); + return new TestDatabaseResourcePromise(this._withHttpHealthCheckInternal(path, statusCode, endpointName)); } /** @internal */ - private async _withCommandInternal(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, commandOptions?: CommandOptions): Promise { + private async _withCommandInternal(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, commandOptions?: CommandOptions): Promise { const executeCommandId = registerCallback(async (argData: unknown) => { const argHandle = wrapIfHandle(argData) as ExecuteCommandContextHandle; const arg = new ExecuteCommandContext(argHandle, this._client); @@ -5771,51 +5778,51 @@ export class TestRedisResource extends ResourceBuilderBase = { builder: this._handle, name, displayName, executeCommand: executeCommandId }; if (commandOptions !== undefined) rpcArgs.commandOptions = commandOptions; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withCommand', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds a resource command */ - withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): TestRedisResourcePromise { + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): TestDatabaseResourcePromise { const commandOptions = options?.commandOptions; - return new TestRedisResourcePromise(this._withCommandInternal(name, displayName, executeCommand, commandOptions)); + return new TestDatabaseResourcePromise(this._withCommandInternal(name, displayName, executeCommand, commandOptions)); } /** @internal */ - private async _withParentRelationshipInternal(parent: ResourceBuilderBase): Promise { + private async _withParentRelationshipInternal(parent: ResourceBuilderBase): Promise { const rpcArgs: Record = { builder: this._handle, parent }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withParentRelationship', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Sets the parent relationship */ - withParentRelationship(parent: ResourceBuilderBase): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withParentRelationshipInternal(parent)); + withParentRelationship(parent: ResourceBuilderBase): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withParentRelationshipInternal(parent)); } /** @internal */ - private async _withVolumeInternal(target: string, name?: string, isReadOnly?: boolean): Promise { + private async _withVolumeInternal(target: string, name?: string, isReadOnly?: boolean): Promise { const rpcArgs: Record = { resource: this._handle, target }; if (name !== undefined) rpcArgs.name = name; if (isReadOnly !== undefined) rpcArgs.isReadOnly = isReadOnly; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting/withVolume', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds a volume */ - withVolume(target: string, options?: WithVolumeOptions): TestRedisResourcePromise { + withVolume(target: string, options?: WithVolumeOptions): TestDatabaseResourcePromise { const name = options?.name; const isReadOnly = options?.isReadOnly; - return new TestRedisResourcePromise(this._withVolumeInternal(target, name, isReadOnly)); + return new TestDatabaseResourcePromise(this._withVolumeInternal(target, name, isReadOnly)); } /** Gets the resource name */ @@ -5828,107 +5835,1351 @@ export class TestRedisResource extends ResourceBuilderBase { - const rpcArgs: Record = { builder: this._handle }; - if (mode !== undefined) rpcArgs.mode = mode; - const result = await this._client.invokeCapability( - 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withPersistence', - rpcArgs - ); - return new TestRedisResource(result, this._client); - } - - /** Configures the Redis resource with persistence */ - withPersistence(options?: WithPersistenceOptions): TestRedisResourcePromise { - const mode = options?.mode; - return new TestRedisResourcePromise(this._withPersistenceInternal(mode)); - } - - /** @internal */ - private async _withOptionalStringInternal(value?: string, enabled?: boolean): Promise { + private async _withOptionalStringInternal(value?: string, enabled?: boolean): Promise { const rpcArgs: Record = { builder: this._handle }; if (value !== undefined) rpcArgs.value = value; if (enabled !== undefined) rpcArgs.enabled = enabled; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withOptionalString', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Adds an optional string parameter */ - withOptionalString(options?: WithOptionalStringOptions): TestRedisResourcePromise { + withOptionalString(options?: WithOptionalStringOptions): TestDatabaseResourcePromise { const value = options?.value; const enabled = options?.enabled; - return new TestRedisResourcePromise(this._withOptionalStringInternal(value, enabled)); + return new TestDatabaseResourcePromise(this._withOptionalStringInternal(value, enabled)); } /** @internal */ - private async _withConfigInternal(config: TestConfigDto): Promise { + private async _withConfigInternal(config: TestConfigDto): Promise { const rpcArgs: Record = { builder: this._handle, config }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withConfig', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Configures the resource with a DTO */ - withConfig(config: TestConfigDto): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withConfigInternal(config)); - } - - /** Gets the tags for the resource */ - async getTags(): Promise> { - const rpcArgs: Record = { builder: this._handle }; - return await this._client.invokeCapability>( - 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/getTags', - rpcArgs - ); - } - - /** Gets the metadata for the resource */ - async getMetadata(): Promise> { - const rpcArgs: Record = { builder: this._handle }; - return await this._client.invokeCapability>( - 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/getMetadata', - rpcArgs - ); - } - - /** @internal */ - private async _withConnectionStringInternal(connectionString: ReferenceExpression): Promise { - const rpcArgs: Record = { builder: this._handle, connectionString }; - const result = await this._client.invokeCapability( - 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withConnectionString', - rpcArgs - ); - return new TestRedisResource(result, this._client); - } - - /** Sets the connection string using a reference expression */ - withConnectionString(connectionString: ReferenceExpression): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._withConnectionStringInternal(connectionString)); + withConfig(config: TestConfigDto): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withConfigInternal(config)); } /** @internal */ - private async _testWithEnvironmentCallbackInternal(callback: (arg: TestEnvironmentContext) => Promise): Promise { + private async _testWithEnvironmentCallbackInternal(callback: (arg: TestEnvironmentContext) => Promise): Promise { const callbackId = registerCallback(async (argData: unknown) => { const argHandle = wrapIfHandle(argData) as TestEnvironmentContextHandle; const arg = new TestEnvironmentContext(argHandle, this._client); await callback(arg); }); const rpcArgs: Record = { builder: this._handle, callback: callbackId }; - const result = await this._client.invokeCapability( + const result = await this._client.invokeCapability( 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/testWithEnvironmentCallback', rpcArgs ); - return new TestRedisResource(result, this._client); + return new TestDatabaseResource(result, this._client); } /** Configures environment with callback (test version) */ - testWithEnvironmentCallback(callback: (arg: TestEnvironmentContext) => Promise): TestRedisResourcePromise { - return new TestRedisResourcePromise(this._testWithEnvironmentCallbackInternal(callback)); + testWithEnvironmentCallback(callback: (arg: TestEnvironmentContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._testWithEnvironmentCallbackInternal(callback)); + } + + /** @internal */ + private async _withCreatedAtInternal(createdAt: string): Promise { + const rpcArgs: Record = { builder: this._handle, createdAt }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withCreatedAt', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Sets the created timestamp */ + withCreatedAt(createdAt: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withCreatedAtInternal(createdAt)); + } + + /** @internal */ + private async _withModifiedAtInternal(modifiedAt: string): Promise { + const rpcArgs: Record = { builder: this._handle, modifiedAt }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withModifiedAt', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Sets the modified timestamp */ + withModifiedAt(modifiedAt: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withModifiedAtInternal(modifiedAt)); + } + + /** @internal */ + private async _withCorrelationIdInternal(correlationId: string): Promise { + const rpcArgs: Record = { builder: this._handle, correlationId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withCorrelationId', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Sets the correlation ID */ + withCorrelationId(correlationId: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withCorrelationIdInternal(correlationId)); + } + + /** @internal */ + private async _withOptionalCallbackInternal(callback?: (arg: TestCallbackContext) => Promise): Promise { + const callbackId = callback ? registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as TestCallbackContextHandle; + const arg = new TestCallbackContext(argHandle, this._client); + await callback(arg); + }) : undefined; + const rpcArgs: Record = { builder: this._handle }; + if (callback !== undefined) rpcArgs.callback = callbackId; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withOptionalCallback', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Configures with optional callback */ + withOptionalCallback(options?: WithOptionalCallbackOptions): TestDatabaseResourcePromise { + const callback = options?.callback; + return new TestDatabaseResourcePromise(this._withOptionalCallbackInternal(callback)); + } + + /** @internal */ + private async _withStatusInternal(status: TestResourceStatus): Promise { + const rpcArgs: Record = { builder: this._handle, status }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withStatus', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Sets the resource status */ + withStatus(status: TestResourceStatus): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withStatusInternal(status)); + } + + /** @internal */ + private async _withNestedConfigInternal(config: TestNestedDto): Promise { + const rpcArgs: Record = { builder: this._handle, config }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withNestedConfig', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Configures with nested DTO */ + withNestedConfig(config: TestNestedDto): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withNestedConfigInternal(config)); + } + + /** @internal */ + private async _withValidatorInternal(validator: (arg: TestResourceContext) => Promise): Promise { + const validatorId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as TestResourceContextHandle; + const arg = new TestResourceContext(argHandle, this._client); + return await validator(arg); + }); + const rpcArgs: Record = { builder: this._handle, validator: validatorId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withValidator', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Adds validation callback */ + withValidator(validator: (arg: TestResourceContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withValidatorInternal(validator)); + } + + /** @internal */ + private async _testWaitForInternal(dependency: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, dependency }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/testWaitFor', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Waits for another resource (test version) */ + testWaitFor(dependency: ResourceBuilderBase): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._testWaitForInternal(dependency)); + } + + /** @internal */ + private async _withDependencyInternal(dependency: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, dependency }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withDependency', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Adds a dependency on another resource */ + withDependency(dependency: ResourceBuilderBase): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withDependencyInternal(dependency)); + } + + /** @internal */ + private async _withEndpointsInternal(endpoints: string[]): Promise { + const rpcArgs: Record = { builder: this._handle, endpoints }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withEndpoints', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Sets the endpoints */ + withEndpoints(endpoints: string[]): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withEndpointsInternal(endpoints)); + } + + /** @internal */ + private async _withEnvironmentVariablesInternal(variables: Record): Promise { + const rpcArgs: Record = { builder: this._handle, variables }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withEnvironmentVariables', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Sets environment variables */ + withEnvironmentVariables(variables: Record): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withEnvironmentVariablesInternal(variables)); + } + + /** @internal */ + private async _withCancellableOperationInternal(operation: (arg: AbortSignal) => Promise): Promise { + const operationId = registerCallback(async (argData: unknown) => { + const arg = wrapIfHandle(argData) as AbortSignal; + await operation(arg); + }); + const rpcArgs: Record = { builder: this._handle, operation: operationId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withCancellableOperation', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Performs a cancellable operation */ + withCancellableOperation(operation: (arg: AbortSignal) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._withCancellableOperationInternal(operation)); + } + +} + +/** + * Thenable wrapper for TestDatabaseResource that enables fluent chaining. + * @example + * await builder.addSomething().withX().withY(); + */ +export class TestDatabaseResourcePromise implements PromiseLike { + constructor(private _promise: Promise) {} + + then( + onfulfilled?: ((value: TestDatabaseResource) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): PromiseLike { + return this._promise.then(onfulfilled, onrejected); + } + + /** Adds a bind mount */ + withBindMount(source: string, target: string, options?: WithBindMountOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withBindMount(source, target, options))); + } + + /** Sets the container entrypoint */ + withEntrypoint(entrypoint: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withEntrypoint(entrypoint))); + } + + /** Sets the container image tag */ + withImageTag(tag: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withImageTag(tag))); + } + + /** Sets the container image registry */ + withImageRegistry(registry: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withImageRegistry(registry))); + } + + /** Sets the container image */ + withImage(image: string, options?: WithImageOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withImage(image, options))); + } + + /** Adds runtime arguments for the container */ + withContainerRuntimeArgs(args: string[]): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withContainerRuntimeArgs(args))); + } + + /** Sets the lifetime behavior of the container resource */ + withLifetime(lifetime: ContainerLifetime): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withLifetime(lifetime))); + } + + /** Sets the container image pull policy */ + withImagePullPolicy(pullPolicy: ImagePullPolicy): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withImagePullPolicy(pullPolicy))); + } + + /** Sets the container name */ + withContainerName(name: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withContainerName(name))); + } + + /** Sets an environment variable */ + withEnvironment(name: string, value: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withEnvironment(name, value))); + } + + /** Adds an environment variable with a reference expression */ + withEnvironmentExpression(name: string, value: ReferenceExpression): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withEnvironmentExpression(name, value))); + } + + /** Sets environment variables via callback */ + withEnvironmentCallback(callback: (obj: EnvironmentCallbackContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withEnvironmentCallback(callback))); + } + + /** Sets environment variables via async callback */ + withEnvironmentCallbackAsync(callback: (arg: EnvironmentCallbackContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withEnvironmentCallbackAsync(callback))); + } + + /** Adds arguments */ + withArgs(args: string[]): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withArgs(args))); + } + + /** Sets command-line arguments via callback */ + withArgsCallback(callback: (obj: CommandLineArgsCallbackContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withArgsCallback(callback))); + } + + /** Sets command-line arguments via async callback */ + withArgsCallbackAsync(callback: (arg: CommandLineArgsCallbackContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withArgsCallbackAsync(callback))); + } + + /** Adds a reference to another resource */ + withReference(source: ResourceBuilderBase, options?: WithReferenceOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withReference(source, options))); + } + + /** Adds a service discovery reference to another resource */ + withServiceReference(source: ResourceBuilderBase): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withServiceReference(source))); + } + + /** Adds a network endpoint */ + withEndpoint(options?: WithEndpointOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withEndpoint(options))); + } + + /** Adds an HTTP endpoint */ + withHttpEndpoint(options?: WithHttpEndpointOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withHttpEndpoint(options))); + } + + /** Adds an HTTPS endpoint */ + withHttpsEndpoint(options?: WithHttpsEndpointOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withHttpsEndpoint(options))); + } + + /** Makes HTTP endpoints externally accessible */ + withExternalHttpEndpoints(): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withExternalHttpEndpoints())); + } + + /** Gets an endpoint reference */ + getEndpoint(name: string): Promise { + return this._promise.then(obj => obj.getEndpoint(name)); + } + + /** Configures resource for HTTP/2 */ + asHttp2Service(): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.asHttp2Service())); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withUrlsCallback(callback))); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withUrlsCallbackAsync(callback))); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withUrl(url, options))); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withUrlExpression(url, options))); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withUrlForEndpoint(endpointName, callback))); + } + + /** Adds a URL for a specific endpoint via factory callback */ + withUrlForEndpointFactory(endpointName: string, callback: (arg: EndpointReference) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withUrlForEndpointFactory(endpointName, callback))); + } + + /** Waits for another resource to be ready */ + waitFor(dependency: ResourceBuilderBase): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.waitFor(dependency))); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withExplicitStart())); + } + + /** Waits for resource completion */ + waitForCompletion(dependency: ResourceBuilderBase, options?: WaitForCompletionOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.waitForCompletion(dependency, options))); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withHealthCheck(key))); + } + + /** Adds an HTTP health check */ + withHttpHealthCheck(options?: WithHttpHealthCheckOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withHttpHealthCheck(options))); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withCommand(name, displayName, executeCommand, options))); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withParentRelationship(parent))); + } + + /** Adds a volume */ + withVolume(target: string, options?: WithVolumeOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withVolume(target, options))); + } + + /** Gets the resource name */ + getResourceName(): Promise { + return this._promise.then(obj => obj.getResourceName()); + } + + /** Adds an optional string parameter */ + withOptionalString(options?: WithOptionalStringOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withOptionalString(options))); + } + + /** Configures the resource with a DTO */ + withConfig(config: TestConfigDto): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withConfig(config))); + } + + /** Configures environment with callback (test version) */ + testWithEnvironmentCallback(callback: (arg: TestEnvironmentContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.testWithEnvironmentCallback(callback))); + } + + /** Sets the created timestamp */ + withCreatedAt(createdAt: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withCreatedAt(createdAt))); + } + + /** Sets the modified timestamp */ + withModifiedAt(modifiedAt: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withModifiedAt(modifiedAt))); + } + + /** Sets the correlation ID */ + withCorrelationId(correlationId: string): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withCorrelationId(correlationId))); + } + + /** Configures with optional callback */ + withOptionalCallback(options?: WithOptionalCallbackOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withOptionalCallback(options))); + } + + /** Sets the resource status */ + withStatus(status: TestResourceStatus): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withStatus(status))); + } + + /** Configures with nested DTO */ + withNestedConfig(config: TestNestedDto): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withNestedConfig(config))); + } + + /** Adds validation callback */ + withValidator(validator: (arg: TestResourceContext) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withValidator(validator))); + } + + /** Waits for another resource (test version) */ + testWaitFor(dependency: ResourceBuilderBase): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.testWaitFor(dependency))); + } + + /** Adds a dependency on another resource */ + withDependency(dependency: ResourceBuilderBase): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withDependency(dependency))); + } + + /** Sets the endpoints */ + withEndpoints(endpoints: string[]): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withEndpoints(endpoints))); + } + + /** Sets environment variables */ + withEnvironmentVariables(variables: Record): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withEnvironmentVariables(variables))); + } + + /** Performs a cancellable operation */ + withCancellableOperation(operation: (arg: AbortSignal) => Promise): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.withCancellableOperation(operation))); + } + +} + +// ============================================================================ +// TestRedisResource +// ============================================================================ + +export class TestRedisResource extends ResourceBuilderBase { + constructor(handle: TestRedisResourceHandle, client: AspireClientRpc) { + super(handle, client); + } + + /** @internal */ + private async _withBindMountInternal(source: string, target: string, isReadOnly?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle, source, target }; + if (isReadOnly !== undefined) rpcArgs.isReadOnly = isReadOnly; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withBindMount', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds a bind mount */ + withBindMount(source: string, target: string, options?: WithBindMountOptions): TestRedisResourcePromise { + const isReadOnly = options?.isReadOnly; + return new TestRedisResourcePromise(this._withBindMountInternal(source, target, isReadOnly)); + } + + /** @internal */ + private async _withEntrypointInternal(entrypoint: string): Promise { + const rpcArgs: Record = { builder: this._handle, entrypoint }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEntrypoint', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Sets the container entrypoint */ + withEntrypoint(entrypoint: string): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withEntrypointInternal(entrypoint)); + } + + /** @internal */ + private async _withImageTagInternal(tag: string): Promise { + const rpcArgs: Record = { builder: this._handle, tag }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withImageTag', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Sets the container image tag */ + withImageTag(tag: string): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withImageTagInternal(tag)); + } + + /** @internal */ + private async _withImageRegistryInternal(registry: string): Promise { + const rpcArgs: Record = { builder: this._handle, registry }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withImageRegistry', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Sets the container image registry */ + withImageRegistry(registry: string): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withImageRegistryInternal(registry)); + } + + /** @internal */ + private async _withImageInternal(image: string, tag?: string): Promise { + const rpcArgs: Record = { builder: this._handle, image }; + if (tag !== undefined) rpcArgs.tag = tag; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withImage', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Sets the container image */ + withImage(image: string, options?: WithImageOptions): TestRedisResourcePromise { + const tag = options?.tag; + return new TestRedisResourcePromise(this._withImageInternal(image, tag)); + } + + /** @internal */ + private async _withContainerRuntimeArgsInternal(args: string[]): Promise { + const rpcArgs: Record = { builder: this._handle, args }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withContainerRuntimeArgs', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds runtime arguments for the container */ + withContainerRuntimeArgs(args: string[]): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withContainerRuntimeArgsInternal(args)); + } + + /** @internal */ + private async _withLifetimeInternal(lifetime: ContainerLifetime): Promise { + const rpcArgs: Record = { builder: this._handle, lifetime }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withLifetime', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Sets the lifetime behavior of the container resource */ + withLifetime(lifetime: ContainerLifetime): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withLifetimeInternal(lifetime)); + } + + /** @internal */ + private async _withImagePullPolicyInternal(pullPolicy: ImagePullPolicy): Promise { + const rpcArgs: Record = { builder: this._handle, pullPolicy }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withImagePullPolicy', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Sets the container image pull policy */ + withImagePullPolicy(pullPolicy: ImagePullPolicy): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withImagePullPolicyInternal(pullPolicy)); + } + + /** @internal */ + private async _withContainerNameInternal(name: string): Promise { + const rpcArgs: Record = { builder: this._handle, name }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withContainerName', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Sets the container name */ + withContainerName(name: string): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withContainerNameInternal(name)); + } + + /** @internal */ + private async _withEnvironmentInternal(name: string, value: string): Promise { + const rpcArgs: Record = { builder: this._handle, name, value }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironment', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Sets an environment variable */ + withEnvironment(name: string, value: string): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withEnvironmentInternal(name, value)); + } + + /** @internal */ + private async _withEnvironmentExpressionInternal(name: string, value: ReferenceExpression): Promise { + const rpcArgs: Record = { builder: this._handle, name, value }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironmentExpression', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds an environment variable with a reference expression */ + withEnvironmentExpression(name: string, value: ReferenceExpression): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withEnvironmentExpressionInternal(name, value)); + } + + /** @internal */ + private async _withEnvironmentCallbackInternal(callback: (obj: EnvironmentCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as EnvironmentCallbackContextHandle; + const obj = new EnvironmentCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironmentCallback', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Sets environment variables via callback */ + withEnvironmentCallback(callback: (obj: EnvironmentCallbackContext) => Promise): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withEnvironmentCallbackInternal(callback)); + } + + /** @internal */ + private async _withEnvironmentCallbackAsyncInternal(callback: (arg: EnvironmentCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as EnvironmentCallbackContextHandle; + const arg = new EnvironmentCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEnvironmentCallbackAsync', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Sets environment variables via async callback */ + withEnvironmentCallbackAsync(callback: (arg: EnvironmentCallbackContext) => Promise): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withEnvironmentCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withArgsInternal(args: string[]): Promise { + const rpcArgs: Record = { builder: this._handle, args }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withArgs', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds arguments */ + withArgs(args: string[]): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withArgsInternal(args)); + } + + /** @internal */ + private async _withArgsCallbackInternal(callback: (obj: CommandLineArgsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as CommandLineArgsCallbackContextHandle; + const obj = new CommandLineArgsCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withArgsCallback', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Sets command-line arguments via callback */ + withArgsCallback(callback: (obj: CommandLineArgsCallbackContext) => Promise): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withArgsCallbackInternal(callback)); + } + + /** @internal */ + private async _withArgsCallbackAsyncInternal(callback: (arg: CommandLineArgsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as CommandLineArgsCallbackContextHandle; + const arg = new CommandLineArgsCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withArgsCallbackAsync', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Sets command-line arguments via async callback */ + withArgsCallbackAsync(callback: (arg: CommandLineArgsCallbackContext) => Promise): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withArgsCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withReferenceInternal(source: ResourceBuilderBase, connectionName?: string, optional?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle, source }; + if (connectionName !== undefined) rpcArgs.connectionName = connectionName; + if (optional !== undefined) rpcArgs.optional = optional; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withReference', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds a reference to another resource */ + withReference(source: ResourceBuilderBase, options?: WithReferenceOptions): TestRedisResourcePromise { + const connectionName = options?.connectionName; + const optional = options?.optional; + return new TestRedisResourcePromise(this._withReferenceInternal(source, connectionName, optional)); + } + + /** @internal */ + private async _withServiceReferenceInternal(source: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, source }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withServiceReference', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds a service discovery reference to another resource */ + withServiceReference(source: ResourceBuilderBase): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withServiceReferenceInternal(source)); + } + + /** @internal */ + private async _withEndpointInternal(port?: number, targetPort?: number, scheme?: string, name?: string, env?: string, isProxied?: boolean, isExternal?: boolean, protocol?: ProtocolType): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (port !== undefined) rpcArgs.port = port; + if (targetPort !== undefined) rpcArgs.targetPort = targetPort; + if (scheme !== undefined) rpcArgs.scheme = scheme; + if (name !== undefined) rpcArgs.name = name; + if (env !== undefined) rpcArgs.env = env; + if (isProxied !== undefined) rpcArgs.isProxied = isProxied; + if (isExternal !== undefined) rpcArgs.isExternal = isExternal; + if (protocol !== undefined) rpcArgs.protocol = protocol; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withEndpoint', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds a network endpoint */ + withEndpoint(options?: WithEndpointOptions): TestRedisResourcePromise { + const port = options?.port; + const targetPort = options?.targetPort; + const scheme = options?.scheme; + const name = options?.name; + const env = options?.env; + const isProxied = options?.isProxied; + const isExternal = options?.isExternal; + const protocol = options?.protocol; + return new TestRedisResourcePromise(this._withEndpointInternal(port, targetPort, scheme, name, env, isProxied, isExternal, protocol)); + } + + /** @internal */ + private async _withHttpEndpointInternal(port?: number, targetPort?: number, name?: string, env?: string, isProxied?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (port !== undefined) rpcArgs.port = port; + if (targetPort !== undefined) rpcArgs.targetPort = targetPort; + if (name !== undefined) rpcArgs.name = name; + if (env !== undefined) rpcArgs.env = env; + if (isProxied !== undefined) rpcArgs.isProxied = isProxied; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpEndpoint', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds an HTTP endpoint */ + withHttpEndpoint(options?: WithHttpEndpointOptions): TestRedisResourcePromise { + const port = options?.port; + const targetPort = options?.targetPort; + const name = options?.name; + const env = options?.env; + const isProxied = options?.isProxied; + return new TestRedisResourcePromise(this._withHttpEndpointInternal(port, targetPort, name, env, isProxied)); + } + + /** @internal */ + private async _withHttpsEndpointInternal(port?: number, targetPort?: number, name?: string, env?: string, isProxied?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (port !== undefined) rpcArgs.port = port; + if (targetPort !== undefined) rpcArgs.targetPort = targetPort; + if (name !== undefined) rpcArgs.name = name; + if (env !== undefined) rpcArgs.env = env; + if (isProxied !== undefined) rpcArgs.isProxied = isProxied; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpsEndpoint', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds an HTTPS endpoint */ + withHttpsEndpoint(options?: WithHttpsEndpointOptions): TestRedisResourcePromise { + const port = options?.port; + const targetPort = options?.targetPort; + const name = options?.name; + const env = options?.env; + const isProxied = options?.isProxied; + return new TestRedisResourcePromise(this._withHttpsEndpointInternal(port, targetPort, name, env, isProxied)); + } + + /** @internal */ + private async _withExternalHttpEndpointsInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExternalHttpEndpoints', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Makes HTTP endpoints externally accessible */ + withExternalHttpEndpoints(): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withExternalHttpEndpointsInternal()); + } + + /** Gets an endpoint reference */ + async getEndpoint(name: string): Promise { + const rpcArgs: Record = { builder: this._handle, name }; + return await this._client.invokeCapability( + 'Aspire.Hosting/getEndpoint', + rpcArgs + ); + } + + /** @internal */ + private async _asHttp2ServiceInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/asHttp2Service', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Configures resource for HTTP/2 */ + asHttp2Service(): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._asHttp2ServiceInternal()); + } + + /** @internal */ + private async _withUrlsCallbackInternal(callback: (obj: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const objHandle = wrapIfHandle(objData) as ResourceUrlsCallbackContextHandle; + const obj = new ResourceUrlsCallbackContext(objHandle, this._client); + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallback', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Customizes displayed URLs via callback */ + withUrlsCallback(callback: (obj: ResourceUrlsCallbackContext) => Promise): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withUrlsCallbackInternal(callback)); + } + + /** @internal */ + private async _withUrlsCallbackAsyncInternal(callback: (arg: ResourceUrlsCallbackContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ResourceUrlsCallbackContextHandle; + const arg = new ResourceUrlsCallbackContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlsCallbackAsync', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Customizes displayed URLs via async callback */ + withUrlsCallbackAsync(callback: (arg: ResourceUrlsCallbackContext) => Promise): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withUrlsCallbackAsyncInternal(callback)); + } + + /** @internal */ + private async _withUrlInternal(url: string, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrl', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds or modifies displayed URLs */ + withUrl(url: string, options?: WithUrlOptions): TestRedisResourcePromise { + const displayText = options?.displayText; + return new TestRedisResourcePromise(this._withUrlInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlExpressionInternal(url: ReferenceExpression, displayText?: string): Promise { + const rpcArgs: Record = { builder: this._handle, url }; + if (displayText !== undefined) rpcArgs.displayText = displayText; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlExpression', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds a URL using a reference expression */ + withUrlExpression(url: ReferenceExpression, options?: WithUrlExpressionOptions): TestRedisResourcePromise { + const displayText = options?.displayText; + return new TestRedisResourcePromise(this._withUrlExpressionInternal(url, displayText)); + } + + /** @internal */ + private async _withUrlForEndpointInternal(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): Promise { + const callbackId = registerCallback(async (objData: unknown) => { + const obj = wrapIfHandle(objData) as ResourceUrlAnnotation; + await callback(obj); + }); + const rpcArgs: Record = { builder: this._handle, endpointName, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlForEndpoint', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Customizes the URL for a specific endpoint via callback */ + withUrlForEndpoint(endpointName: string, callback: (obj: ResourceUrlAnnotation) => Promise): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withUrlForEndpointInternal(endpointName, callback)); + } + + /** @internal */ + private async _withUrlForEndpointFactoryInternal(endpointName: string, callback: (arg: EndpointReference) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as EndpointReferenceHandle; + const arg = new EndpointReference(argHandle, this._client); + return await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, endpointName, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withUrlForEndpointFactory', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds a URL for a specific endpoint via factory callback */ + withUrlForEndpointFactory(endpointName: string, callback: (arg: EndpointReference) => Promise): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withUrlForEndpointFactoryInternal(endpointName, callback)); + } + + /** @internal */ + private async _waitForInternal(dependency: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, dependency }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/waitFor', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Waits for another resource to be ready */ + waitFor(dependency: ResourceBuilderBase): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._waitForInternal(dependency)); + } + + /** @internal */ + private async _withExplicitStartInternal(): Promise { + const rpcArgs: Record = { builder: this._handle }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withExplicitStart', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Prevents resource from starting automatically */ + withExplicitStart(): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withExplicitStartInternal()); + } + + /** @internal */ + private async _waitForCompletionInternal(dependency: ResourceBuilderBase, exitCode?: number): Promise { + const rpcArgs: Record = { builder: this._handle, dependency }; + if (exitCode !== undefined) rpcArgs.exitCode = exitCode; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/waitForCompletion', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Waits for resource completion */ + waitForCompletion(dependency: ResourceBuilderBase, options?: WaitForCompletionOptions): TestRedisResourcePromise { + const exitCode = options?.exitCode; + return new TestRedisResourcePromise(this._waitForCompletionInternal(dependency, exitCode)); + } + + /** @internal */ + private async _withHealthCheckInternal(key: string): Promise { + const rpcArgs: Record = { builder: this._handle, key }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHealthCheck', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds a health check by key */ + withHealthCheck(key: string): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withHealthCheckInternal(key)); + } + + /** @internal */ + private async _withHttpHealthCheckInternal(path?: string, statusCode?: number, endpointName?: string): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (path !== undefined) rpcArgs.path = path; + if (statusCode !== undefined) rpcArgs.statusCode = statusCode; + if (endpointName !== undefined) rpcArgs.endpointName = endpointName; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withHttpHealthCheck', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds an HTTP health check */ + withHttpHealthCheck(options?: WithHttpHealthCheckOptions): TestRedisResourcePromise { + const path = options?.path; + const statusCode = options?.statusCode; + const endpointName = options?.endpointName; + return new TestRedisResourcePromise(this._withHttpHealthCheckInternal(path, statusCode, endpointName)); + } + + /** @internal */ + private async _withCommandInternal(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, commandOptions?: CommandOptions): Promise { + const executeCommandId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as ExecuteCommandContextHandle; + const arg = new ExecuteCommandContext(argHandle, this._client); + return await executeCommand(arg); + }); + const rpcArgs: Record = { builder: this._handle, name, displayName, executeCommand: executeCommandId }; + if (commandOptions !== undefined) rpcArgs.commandOptions = commandOptions; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withCommand', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds a resource command */ + withCommand(name: string, displayName: string, executeCommand: (arg: ExecuteCommandContext) => Promise, options?: WithCommandOptions): TestRedisResourcePromise { + const commandOptions = options?.commandOptions; + return new TestRedisResourcePromise(this._withCommandInternal(name, displayName, executeCommand, commandOptions)); + } + + /** @internal */ + private async _withParentRelationshipInternal(parent: ResourceBuilderBase): Promise { + const rpcArgs: Record = { builder: this._handle, parent }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withParentRelationship', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Sets the parent relationship */ + withParentRelationship(parent: ResourceBuilderBase): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withParentRelationshipInternal(parent)); + } + + /** @internal */ + private async _withVolumeInternal(target: string, name?: string, isReadOnly?: boolean): Promise { + const rpcArgs: Record = { resource: this._handle, target }; + if (name !== undefined) rpcArgs.name = name; + if (isReadOnly !== undefined) rpcArgs.isReadOnly = isReadOnly; + const result = await this._client.invokeCapability( + 'Aspire.Hosting/withVolume', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds a volume */ + withVolume(target: string, options?: WithVolumeOptions): TestRedisResourcePromise { + const name = options?.name; + const isReadOnly = options?.isReadOnly; + return new TestRedisResourcePromise(this._withVolumeInternal(target, name, isReadOnly)); + } + + /** Gets the resource name */ + async getResourceName(): Promise { + const rpcArgs: Record = { resource: this._handle }; + return await this._client.invokeCapability( + 'Aspire.Hosting/getResourceName', + rpcArgs + ); + } + + /** @internal */ + private async _addTestChildDatabaseInternal(name: string, databaseName?: string): Promise { + const rpcArgs: Record = { builder: this._handle, name }; + if (databaseName !== undefined) rpcArgs.databaseName = databaseName; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/addTestChildDatabase', + rpcArgs + ); + return new TestDatabaseResource(result, this._client); + } + + /** Adds a child database to a test Redis resource */ + addTestChildDatabase(name: string, options?: AddTestChildDatabaseOptions): TestDatabaseResourcePromise { + const databaseName = options?.databaseName; + return new TestDatabaseResourcePromise(this._addTestChildDatabaseInternal(name, databaseName)); + } + + /** @internal */ + private async _withPersistenceInternal(mode?: TestPersistenceMode): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (mode !== undefined) rpcArgs.mode = mode; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withPersistence', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Configures the Redis resource with persistence */ + withPersistence(options?: WithPersistenceOptions): TestRedisResourcePromise { + const mode = options?.mode; + return new TestRedisResourcePromise(this._withPersistenceInternal(mode)); + } + + /** @internal */ + private async _withOptionalStringInternal(value?: string, enabled?: boolean): Promise { + const rpcArgs: Record = { builder: this._handle }; + if (value !== undefined) rpcArgs.value = value; + if (enabled !== undefined) rpcArgs.enabled = enabled; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withOptionalString', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Adds an optional string parameter */ + withOptionalString(options?: WithOptionalStringOptions): TestRedisResourcePromise { + const value = options?.value; + const enabled = options?.enabled; + return new TestRedisResourcePromise(this._withOptionalStringInternal(value, enabled)); + } + + /** @internal */ + private async _withConfigInternal(config: TestConfigDto): Promise { + const rpcArgs: Record = { builder: this._handle, config }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withConfig', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Configures the resource with a DTO */ + withConfig(config: TestConfigDto): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withConfigInternal(config)); + } + + /** Gets the tags for the resource */ + async getTags(): Promise> { + const rpcArgs: Record = { builder: this._handle }; + return await this._client.invokeCapability>( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/getTags', + rpcArgs + ); + } + + /** Gets the metadata for the resource */ + async getMetadata(): Promise> { + const rpcArgs: Record = { builder: this._handle }; + return await this._client.invokeCapability>( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/getMetadata', + rpcArgs + ); + } + + /** @internal */ + private async _withConnectionStringInternal(connectionString: ReferenceExpression): Promise { + const rpcArgs: Record = { builder: this._handle, connectionString }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/withConnectionString', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Sets the connection string using a reference expression */ + withConnectionString(connectionString: ReferenceExpression): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._withConnectionStringInternal(connectionString)); + } + + /** @internal */ + private async _testWithEnvironmentCallbackInternal(callback: (arg: TestEnvironmentContext) => Promise): Promise { + const callbackId = registerCallback(async (argData: unknown) => { + const argHandle = wrapIfHandle(argData) as TestEnvironmentContextHandle; + const arg = new TestEnvironmentContext(argHandle, this._client); + await callback(arg); + }); + const rpcArgs: Record = { builder: this._handle, callback: callbackId }; + const result = await this._client.invokeCapability( + 'Aspire.Hosting.CodeGeneration.TypeScript.Tests/testWithEnvironmentCallback', + rpcArgs + ); + return new TestRedisResource(result, this._client); + } + + /** Configures environment with callback (test version) */ + testWithEnvironmentCallback(callback: (arg: TestEnvironmentContext) => Promise): TestRedisResourcePromise { + return new TestRedisResourcePromise(this._testWithEnvironmentCallbackInternal(callback)); } /** @internal */ @@ -6402,6 +7653,11 @@ export class TestRedisResourcePromise implements PromiseLike return this._promise.then(obj => obj.getResourceName()); } + /** Adds a child database to a test Redis resource */ + addTestChildDatabase(name: string, options?: AddTestChildDatabaseOptions): TestDatabaseResourcePromise { + return new TestDatabaseResourcePromise(this._promise.then(obj => obj.addTestChildDatabase(name, options))); + } + /** Configures the Redis resource with persistence */ withPersistence(options?: WithPersistenceOptions): TestRedisResourcePromise { return new TestRedisResourcePromise(this._promise.then(obj => obj.withPersistence(options))); @@ -7875,6 +9131,7 @@ registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.ContainerR registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.ExecutableResource', (handle, client) => new ExecutableResource(handle as ExecutableResourceHandle, client)); registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.ParameterResource', (handle, client) => new ParameterResource(handle as ParameterResourceHandle, client)); registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.ProjectResource', (handle, client) => new ProjectResource(handle as ProjectResourceHandle, client)); +registerHandleWrapper('Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource', (handle, client) => new TestDatabaseResource(handle as TestDatabaseResourceHandle, client)); registerHandleWrapper('Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestRedisResource', (handle, client) => new TestRedisResource(handle as TestRedisResourceHandle, client)); registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource', (handle, client) => new Resource(handle as IResourceHandle, client)); registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResourceWithArgs', (handle, client) => new ResourceWithArgs(handle as IResourceWithArgsHandle, client)); diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/WithOptionalStringCapability.verified.txt b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/WithOptionalStringCapability.verified.txt index ae6181cbdf7..a47a6106eaf 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/WithOptionalStringCapability.verified.txt +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/WithOptionalStringCapability.verified.txt @@ -68,6 +68,16 @@ IsResourceBuilder: true, IsDistributedApplicationBuilder: false, IsDistributedApplication: false + }, + { + TypeId: Aspire.Hosting.CodeGeneration.TypeScript.Tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes.TestDatabaseResource, + ClrType: TestDatabaseResource, + Category: Handle, + IsInterface: false, + IsReadOnly: false, + IsResourceBuilder: true, + IsDistributedApplicationBuilder: false, + IsDistributedApplication: false } ], ReturnsBuilder: true, diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/base.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/base.verified.ts new file mode 100644 index 00000000000..1ba0189fae4 --- /dev/null +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/base.verified.ts @@ -0,0 +1,412 @@ +// aspire.ts - Core Aspire types: base classes, ReferenceExpression +import { Handle, AspireClient, MarshalledHandle } from './transport.js'; + +// Re-export transport types for convenience +export { Handle, AspireClient, CapabilityError, registerCallback, unregisterCallback, registerCancellation, unregisterCancellation } from './transport.js'; +export type { MarshalledHandle, AtsError, AtsErrorDetails, CallbackFunction } from './transport.js'; +export { AtsErrorCodes, isMarshalledHandle, isAtsError, wrapIfHandle } from './transport.js'; + +// ============================================================================ +// Reference Expression +// ============================================================================ + +/** + * Represents a reference expression that can be passed to capabilities. + * + * Reference expressions are serialized in the protocol as: + * ```json + * { + * "$expr": { + * "format": "redis://{0}:{1}", + * "valueProviders": [ + * { "$handle": "Aspire.Hosting.ApplicationModel/EndpointReference:1" }, + * { "$handle": "Aspire.Hosting.ApplicationModel/EndpointReference:2" } + * ] + * } + * } + * ``` + * + * @example + * ```typescript + * const redis = await builder.addRedis("cache"); + * const endpoint = await redis.getEndpoint("tcp"); + * + * // Create a reference expression + * const expr = refExpr`redis://${endpoint}:6379`; + * + * // Use it in an environment variable + * await api.withEnvironment("REDIS_URL", expr); + * ``` + */ +export class ReferenceExpression { + private readonly _format: string; + private readonly _valueProviders: unknown[]; + + private constructor(format: string, valueProviders: unknown[]) { + this._format = format; + this._valueProviders = valueProviders; + } + + /** + * Creates a reference expression from a tagged template literal. + * + * @param strings - The template literal string parts + * @param values - The interpolated values (handles to value providers) + * @returns A ReferenceExpression instance + */ + static create(strings: TemplateStringsArray, ...values: unknown[]): ReferenceExpression { + // Build the format string with {0}, {1}, etc. placeholders + let format = ''; + for (let i = 0; i < strings.length; i++) { + format += strings[i]; + if (i < values.length) { + format += `{${i}}`; + } + } + + // Extract handles from values + const valueProviders = values.map(extractHandleForExpr); + + return new ReferenceExpression(format, valueProviders); + } + + /** + * Serializes the reference expression for JSON-RPC transport. + * Uses the $expr format recognized by the server. + */ + toJSON(): { $expr: { format: string; valueProviders?: unknown[] } } { + return { + $expr: { + format: this._format, + valueProviders: this._valueProviders.length > 0 ? this._valueProviders : undefined + } + }; + } + + /** + * String representation for debugging. + */ + toString(): string { + return `ReferenceExpression(${this._format})`; + } +} + +/** + * Extracts a value for use in reference expressions. + * Supports handles (objects) and string literals. + * @internal + */ +function extractHandleForExpr(value: unknown): unknown { + if (value === null || value === undefined) { + throw new Error('Cannot use null or undefined in reference expression'); + } + + // String literals - include directly in the expression + if (typeof value === 'string') { + return value; + } + + // Number literals - convert to string + if (typeof value === 'number') { + return String(value); + } + + // Handle objects - get their JSON representation + if (value instanceof Handle) { + return value.toJSON(); + } + + // Objects with $handle property (already in handle format) + if (typeof value === 'object' && value !== null && '$handle' in value) { + return value; + } + + // Objects with toJSON that returns a handle + if (typeof value === 'object' && value !== null && 'toJSON' in value && typeof value.toJSON === 'function') { + const json = value.toJSON(); + if (json && typeof json === 'object' && '$handle' in json) { + return json; + } + } + + throw new Error( + `Cannot use value of type ${typeof value} in reference expression. ` + + `Expected a Handle, string, or number.` + ); +} + +/** + * Tagged template function for creating reference expressions. + * + * Use this to create dynamic expressions that reference endpoints, parameters, and other + * value providers. The expression is evaluated at runtime by Aspire. + * + * @example + * ```typescript + * const redis = await builder.addRedis("cache"); + * const endpoint = await redis.getEndpoint("tcp"); + * + * // Create a reference expression using the tagged template + * const expr = refExpr`redis://${endpoint}:6379`; + * + * // Use it in an environment variable + * await api.withEnvironment("REDIS_URL", expr); + * ``` + */ +export function refExpr(strings: TemplateStringsArray, ...values: unknown[]): ReferenceExpression { + return ReferenceExpression.create(strings, ...values); +} + +// ============================================================================ +// ResourceBuilderBase +// ============================================================================ + +/** + * Base class for resource builders (e.g., RedisBuilder, ContainerBuilder). + * Provides handle management and JSON serialization. + */ +export class ResourceBuilderBase { + constructor(protected _handle: THandle, protected _client: AspireClient) {} + + toJSON(): MarshalledHandle { return this._handle.toJSON(); } +} + +// ============================================================================ +// AspireList - Mutable List Wrapper +// ============================================================================ + +/** + * Wrapper for a mutable .NET List. + * Provides array-like methods that invoke capabilities on the underlying collection. + * + * @example + * ```typescript + * const items = await resource.getItems(); // Returns AspireList + * const count = await items.count(); + * const first = await items.get(0); + * await items.add(newItem); + * ``` + */ +export class AspireList { + constructor( + private readonly _handle: Handle, + private readonly _client: AspireClient, + private readonly _typeId: string + ) {} + + /** + * Gets the number of elements in the list. + */ + async count(): Promise { + return await this._client.invokeCapability('Aspire.Hosting/List.length', { + list: this._handle + }) as number; + } + + /** + * Gets the element at the specified index. + */ + async get(index: number): Promise { + return await this._client.invokeCapability('Aspire.Hosting/List.get', { + list: this._handle, + index + }) as T; + } + + /** + * Adds an element to the end of the list. + */ + async add(item: T): Promise { + await this._client.invokeCapability('Aspire.Hosting/List.add', { + list: this._handle, + item + }); + } + + /** + * Removes the element at the specified index. + */ + async removeAt(index: number): Promise { + await this._client.invokeCapability('Aspire.Hosting/List.removeAt', { + list: this._handle, + index + }); + } + + /** + * Clears all elements from the list. + */ + async clear(): Promise { + await this._client.invokeCapability('Aspire.Hosting/List.clear', { + list: this._handle + }); + } + + /** + * Converts the list to an array (creates a copy). + */ + async toArray(): Promise { + return await this._client.invokeCapability('Aspire.Hosting/List.toArray', { + list: this._handle + }) as T[]; + } + + toJSON(): MarshalledHandle { return this._handle.toJSON(); } +} + +// ============================================================================ +// AspireDict - Mutable Dictionary Wrapper +// ============================================================================ + +/** + * Wrapper for a mutable .NET Dictionary. + * Provides object-like methods that invoke capabilities on the underlying collection. + * + * @example + * ```typescript + * const config = await resource.getConfig(); // Returns AspireDict + * const value = await config.get("key"); + * await config.set("key", "value"); + * const hasKey = await config.containsKey("key"); + * ``` + */ +export class AspireDict { + private _resolvedHandle?: Handle; + private _resolvePromise?: Promise; + + constructor( + private readonly _handleOrContext: Handle, + private readonly _client: AspireClient, + private readonly _typeId: string, + private readonly _getterCapabilityId?: string + ) { + // If no getter capability, the handle is already the dictionary handle + if (!_getterCapabilityId) { + this._resolvedHandle = _handleOrContext; + } + } + + /** + * Ensures we have the actual dictionary handle by calling the getter if needed. + */ + private async _ensureHandle(): Promise { + if (this._resolvedHandle) { + return this._resolvedHandle; + } + if (this._resolvePromise) { + return this._resolvePromise; + } + // Call the getter capability to get the actual dictionary handle + this._resolvePromise = (async () => { + const result = await this._client.invokeCapability(this._getterCapabilityId!, { + context: this._handleOrContext + }); + this._resolvedHandle = result as Handle; + return this._resolvedHandle; + })(); + return this._resolvePromise; + } + + /** + * Gets the number of key-value pairs in the dictionary. + */ + async count(): Promise { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/Dict.count', { + dict: handle + }) as number; + } + + /** + * Gets the value associated with the specified key. + * @throws If the key is not found. + */ + async get(key: K): Promise { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/Dict.get', { + dict: handle, + key + }) as V; + } + + /** + * Sets the value for the specified key. + */ + async set(key: K, value: V): Promise { + const handle = await this._ensureHandle(); + await this._client.invokeCapability('Aspire.Hosting/Dict.set', { + dict: handle, + key, + value + }); + } + + /** + * Determines whether the dictionary contains the specified key. + */ + async containsKey(key: K): Promise { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/Dict.has', { + dict: handle, + key + }) as boolean; + } + + /** + * Removes the value with the specified key. + * @returns True if the element was removed; false if the key was not found. + */ + async remove(key: K): Promise { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/Dict.remove', { + dict: handle, + key + }) as boolean; + } + + /** + * Clears all key-value pairs from the dictionary. + */ + async clear(): Promise { + const handle = await this._ensureHandle(); + await this._client.invokeCapability('Aspire.Hosting/Dict.clear', { + dict: handle + }); + } + + /** + * Gets all keys in the dictionary. + */ + async keys(): Promise { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/Dict.keys', { + dict: handle + }) as K[]; + } + + /** + * Gets all values in the dictionary. + */ + async values(): Promise { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/Dict.values', { + dict: handle + }) as V[]; + } + + /** + * Converts the dictionary to a plain object (creates a copy). + * Only works when K is string. + */ + async toObject(): Promise> { + const handle = await this._ensureHandle(); + return await this._client.invokeCapability('Aspire.Hosting/Dict.toObject', { + dict: handle + }) as Record; + } + + async toJSON(): Promise { + const handle = await this._ensureHandle(); + return handle.toJSON(); + } +} diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/transport.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/transport.verified.ts new file mode 100644 index 00000000000..50d6aeeaf5f --- /dev/null +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/transport.verified.ts @@ -0,0 +1,557 @@ +// transport.ts - ATS transport layer: RPC, Handle, errors, callbacks +import * as net from 'net'; +import * as rpc from 'vscode-jsonrpc/node.js'; + +// ============================================================================ +// Base Types +// ============================================================================ + +/** + * Type for callback functions that can be registered and invoked from .NET. + * Internal: receives args and client for handle wrapping. + */ +export type CallbackFunction = (args: unknown, client: AspireClient) => unknown | Promise; + +/** + * Represents a handle to a .NET object in the ATS system. + * Handles are typed references that can be passed between capabilities. + */ +export interface MarshalledHandle { + /** The handle ID (instance number) */ + $handle: string; + /** The ATS type ID */ + $type: string; +} + +/** + * Error details for ATS errors. + */ +export interface AtsErrorDetails { + /** The parameter that caused the error */ + parameter?: string; + /** The expected type or value */ + expected?: string; + /** The actual type or value */ + actual?: string; +} + +/** + * Structured error from ATS capability invocation. + */ +export interface AtsError { + /** Machine-readable error code */ + code: string; + /** Human-readable error message */ + message: string; + /** The capability that failed (if applicable) */ + capability?: string; + /** Additional error details */ + details?: AtsErrorDetails; +} + +/** + * ATS error codes returned by the server. + */ +export const AtsErrorCodes = { + /** Unknown capability ID */ + CapabilityNotFound: 'CAPABILITY_NOT_FOUND', + /** Handle ID doesn't exist or was disposed */ + HandleNotFound: 'HANDLE_NOT_FOUND', + /** Handle type doesn't satisfy capability's type constraint */ + TypeMismatch: 'TYPE_MISMATCH', + /** Missing required argument or wrong type */ + InvalidArgument: 'INVALID_ARGUMENT', + /** Argument value outside valid range */ + ArgumentOutOfRange: 'ARGUMENT_OUT_OF_RANGE', + /** Error occurred during callback invocation */ + CallbackError: 'CALLBACK_ERROR', + /** Unexpected error in capability execution */ + InternalError: 'INTERNAL_ERROR', +} as const; + +/** + * Type guard to check if a value is an ATS error response. + */ +export function isAtsError(value: unknown): value is { $error: AtsError } { + return ( + value !== null && + typeof value === 'object' && + '$error' in value && + typeof (value as { $error: unknown }).$error === 'object' + ); +} + +/** + * Type guard to check if a value is a marshalled handle. + */ +export function isMarshalledHandle(value: unknown): value is MarshalledHandle { + return ( + value !== null && + typeof value === 'object' && + '$handle' in value && + '$type' in value + ); +} + +// ============================================================================ +// Handle +// ============================================================================ + +/** + * A typed handle to a .NET object in the ATS system. + * Handles are opaque references that can be passed to capabilities. + * + * @typeParam T - The ATS type ID (e.g., "Aspire.Hosting/IDistributedApplicationBuilder") + */ +export class Handle { + private readonly _handleId: string; + private readonly _typeId: T; + + constructor(marshalled: MarshalledHandle) { + this._handleId = marshalled.$handle; + this._typeId = marshalled.$type as T; + } + + /** The handle ID (instance number) */ + get $handle(): string { + return this._handleId; + } + + /** The ATS type ID */ + get $type(): T { + return this._typeId; + } + + /** Serialize for JSON-RPC transport */ + toJSON(): MarshalledHandle { + return { + $handle: this._handleId, + $type: this._typeId + }; + } + + /** String representation for debugging */ + toString(): string { + return `Handle<${this._typeId}>(${this._handleId})`; + } +} + +// ============================================================================ +// Handle Wrapper Registry +// ============================================================================ + +/** + * Factory function for creating typed wrapper instances from handles. + */ +export type HandleWrapperFactory = (handle: Handle, client: AspireClient) => unknown; + +/** + * Registry of handle wrapper factories by type ID. + * Generated code registers wrapper classes here so callback handles can be properly typed. + */ +const handleWrapperRegistry = new Map(); + +/** + * Register a wrapper factory for a type ID. + * Called by generated code to register wrapper classes. + */ +export function registerHandleWrapper(typeId: string, factory: HandleWrapperFactory): void { + handleWrapperRegistry.set(typeId, factory); +} + +/** + * Checks if a value is a marshalled handle and wraps it appropriately. + * Uses the wrapper registry to create typed wrapper instances when available. + * + * @param value - The value to potentially wrap + * @param client - Optional client for creating typed wrapper instances + */ +export function wrapIfHandle(value: unknown, client?: AspireClient): unknown { + if (value && typeof value === 'object') { + if (isMarshalledHandle(value)) { + const handle = new Handle(value); + const typeId = value.$type; + + // Try to find a registered wrapper factory for this type + if (typeId && client) { + const factory = handleWrapperRegistry.get(typeId); + if (factory) { + return factory(handle, client); + } + } + + return handle; + } + } + return value; +} + +// ============================================================================ +// Capability Error +// ============================================================================ + +/** + * Error thrown when an ATS capability invocation fails. + */ +export class CapabilityError extends Error { + constructor( + /** The structured error from the server */ + public readonly error: AtsError + ) { + super(error.message); + this.name = 'CapabilityError'; + } + + /** Machine-readable error code */ + get code(): string { + return this.error.code; + } + + /** The capability that failed (if applicable) */ + get capability(): string | undefined { + return this.error.capability; + } +} + +// ============================================================================ +// Callback Registry +// ============================================================================ + +const callbackRegistry = new Map(); +let callbackIdCounter = 0; + +/** + * Register a callback function that can be invoked from the .NET side. + * Returns a callback ID that should be passed to methods accepting callbacks. + * + * .NET passes arguments as an object with positional keys: `{ p0: value0, p1: value1, ... }` + * This function automatically extracts positional parameters and wraps handles. + * + * @example + * // Single parameter callback + * const id = registerCallback((ctx) => console.log(ctx)); + * // .NET sends: { p0: { $handle: "...", $type: "..." } } + * // Callback receives: Handle instance + * + * @example + * // Multi-parameter callback + * const id = registerCallback((a, b) => console.log(a, b)); + * // .NET sends: { p0: "hello", p1: 42 } + * // Callback receives: "hello", 42 + */ +export function registerCallback( + callback: (...args: any[]) => TResult | Promise +): string { + const callbackId = `callback_${++callbackIdCounter}_${Date.now()}`; + + // Wrap the callback to handle .NET's positional argument format + const wrapper: CallbackFunction = async (args: unknown, client: AspireClient) => { + // .NET sends args as object { p0: value0, p1: value1, ... } + if (args && typeof args === 'object' && !Array.isArray(args)) { + const argObj = args as Record; + const argArray: unknown[] = []; + + // Extract positional parameters (p0, p1, p2, ...) + for (let i = 0; ; i++) { + const key = `p${i}`; + if (key in argObj) { + argArray.push(wrapIfHandle(argObj[key], client)); + } else { + break; + } + } + + if (argArray.length > 0) { + // Spread positional arguments to callback + return await callback(...argArray); + } + + // No positional params found - call with no args + return await callback(); + } + + // Null/undefined - call with no args + if (args === null || args === undefined) { + return await callback(); + } + + // Primitive value - pass as single arg (shouldn't happen with current protocol) + return await callback(wrapIfHandle(args, client)); + }; + + callbackRegistry.set(callbackId, wrapper); + return callbackId; +} + +/** + * Unregister a callback by its ID. + */ +export function unregisterCallback(callbackId: string): boolean { + return callbackRegistry.delete(callbackId); +} + +/** + * Get the number of registered callbacks. + */ +export function getCallbackCount(): number { + return callbackRegistry.size; +} + +// ============================================================================ +// Cancellation Token Registry +// ============================================================================ + +/** + * Registry for cancellation tokens. + * Maps cancellation IDs to cleanup functions. + */ +const cancellationRegistry = new Map void>(); +let cancellationIdCounter = 0; + +/** + * A reference to the current AspireClient for sending cancel requests. + * Set by AspireClient.connect(). + */ +let currentClient: AspireClient | null = null; + +/** + * Register an AbortSignal for cancellation support. + * Returns a cancellation ID that should be passed to methods accepting CancellationToken. + * + * When the AbortSignal is aborted, sends a cancelToken request to the host. + * + * @param signal - The AbortSignal to register (optional) + * @returns The cancellation ID, or undefined if no signal provided + * + * @example + * const controller = new AbortController(); + * const id = registerCancellation(controller.signal); + * // Pass id to capability invocation + * // Later: controller.abort() will cancel the operation + */ +export function registerCancellation(signal?: AbortSignal): string | undefined { + if (!signal) { + return undefined; + } + + // Already aborted? Don't register + if (signal.aborted) { + return undefined; + } + + const cancellationId = `ct_${++cancellationIdCounter}_${Date.now()}`; + + // Set up the abort listener + const onAbort = () => { + // Send cancel request to host + if (currentClient?.connected) { + currentClient.cancelToken(cancellationId).catch(() => { + // Ignore errors - the operation may have already completed + }); + } + // Clean up the listener + cancellationRegistry.delete(cancellationId); + }; + + // Listen for abort + signal.addEventListener('abort', onAbort, { once: true }); + + // Store cleanup function + cancellationRegistry.set(cancellationId, () => { + signal.removeEventListener('abort', onAbort); + }); + + return cancellationId; +} + +/** + * Unregister a cancellation token by its ID. + * Call this when the operation completes to clean up resources. + * + * @param cancellationId - The cancellation ID to unregister + */ +export function unregisterCancellation(cancellationId: string | undefined): void { + if (!cancellationId) { + return; + } + + const cleanup = cancellationRegistry.get(cancellationId); + if (cleanup) { + cleanup(); + cancellationRegistry.delete(cancellationId); + } +} + +// ============================================================================ +// AspireClient (JSON-RPC Connection) +// ============================================================================ + +/** + * Client for connecting to the Aspire AppHost via socket/named pipe. + */ +export class AspireClient { + private connection: rpc.MessageConnection | null = null; + private socket: net.Socket | null = null; + private disconnectCallbacks: (() => void)[] = []; + private _pendingCalls = 0; + + constructor(private socketPath: string) { } + + /** + * Register a callback to be called when the connection is lost + */ + onDisconnect(callback: () => void): void { + this.disconnectCallbacks.push(callback); + } + + private notifyDisconnect(): void { + for (const callback of this.disconnectCallbacks) { + try { + callback(); + } catch { + // Ignore callback errors + } + } + } + + connect(timeoutMs: number = 5000): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Connection timeout')), timeoutMs); + + // On Windows, use named pipes; on Unix, use Unix domain sockets + const isWindows = process.platform === 'win32'; + const pipePath = isWindows ? `\\\\.\\pipe\\${this.socketPath}` : this.socketPath; + + this.socket = net.createConnection(pipePath); + + this.socket.once('error', (error: Error) => { + clearTimeout(timeout); + reject(error); + }); + + this.socket.once('connect', () => { + clearTimeout(timeout); + try { + const reader = new rpc.SocketMessageReader(this.socket!); + const writer = new rpc.SocketMessageWriter(this.socket!); + this.connection = rpc.createMessageConnection(reader, writer); + + this.connection.onClose(() => { + this.connection = null; + this.notifyDisconnect(); + }); + this.connection.onError((err: any) => console.error('JsonRpc connection error:', err)); + + // Handle callback invocations from the .NET side + this.connection.onRequest('invokeCallback', async (callbackId: string, args: unknown) => { + const callback = callbackRegistry.get(callbackId); + if (!callback) { + throw new Error(`Callback not found: ${callbackId}`); + } + try { + // The registered wrapper handles arg unpacking and handle wrapping + // Pass this client so handles can be wrapped with typed wrapper classes + return await Promise.resolve(callback(args, this)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Callback execution failed: ${message}`); + } + }); + + this.connection.listen(); + + // Set the current client for cancellation registry + currentClient = this; + + resolve(); + } catch (e) { + reject(e); + } + }); + + this.socket.on('close', () => { + this.connection?.dispose(); + this.connection = null; + if (currentClient === this) { + currentClient = null; + } + this.notifyDisconnect(); + }); + }); + } + + ping(): Promise { + if (!this.connection) return Promise.reject(new Error('Not connected to AppHost')); + return this.connection.sendRequest('ping'); + } + + /** + * Cancel a CancellationToken by its ID. + * Called when an AbortSignal is aborted. + * + * @param tokenId - The token ID to cancel + * @returns True if the token was found and cancelled, false otherwise + */ + cancelToken(tokenId: string): Promise { + if (!this.connection) return Promise.reject(new Error('Not connected to AppHost')); + return this.connection.sendRequest('cancelToken', tokenId); + } + + /** + * Invoke an ATS capability by ID. + * + * Capabilities are operations exposed by [AspireExport] attributes. + * Results are automatically wrapped in Handle objects when applicable. + * + * @param capabilityId - The capability ID (e.g., "Aspire.Hosting/createBuilder") + * @param args - Arguments to pass to the capability + * @returns The capability result, wrapped as Handle if it's a handle type + * @throws CapabilityError if the capability fails + */ + async invokeCapability( + capabilityId: string, + args?: Record + ): Promise { + if (!this.connection) { + throw new Error('Not connected to AppHost'); + } + + // Ref counting: The vscode-jsonrpc socket keeps Node's event loop alive. + // We ref() during RPC calls so the process doesn't exit mid-call, and + // unref() when idle so the process can exit naturally after all work completes. + if (this._pendingCalls === 0) { + this.socket?.ref(); + } + this._pendingCalls++; + + try { + const result = await this.connection.sendRequest( + 'invokeCapability', + capabilityId, + args ?? null + ); + + // Check for structured error response + if (isAtsError(result)) { + throw new CapabilityError(result.$error); + } + + // Wrap handles automatically + return wrapIfHandle(result, this) as T; + } finally { + this._pendingCalls--; + if (this._pendingCalls === 0) { + this.socket?.unref(); + } + } + } + + disconnect(): void { + try { this.connection?.dispose(); } finally { this.connection = null; } + try { this.socket?.end(); } finally { this.socket = null; } + } + + get connected(): boolean { + return this.connection !== null && this.socket !== null; + } +} diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TestTypes/TestExtensions.cs b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TestTypes/TestExtensions.cs index c2cc6af4438..33b55d436a1 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TestTypes/TestExtensions.cs +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/TestTypes/TestExtensions.cs @@ -39,6 +39,26 @@ public static IResourceBuilder AddTestDatabase( return builder.AddResource(resource); } + /// + /// Adds a child database to a Redis server resource (factory method pattern). + /// + /// + /// This method tests the factory method codegen pattern where a method on builder type A + /// returns builder type B (e.g., SqlServerServerResource.AddDatabase returning SqlServerDatabaseResource). + /// + [AspireExport("addTestChildDatabase", Description = "Adds a child database to a test Redis resource")] + public static IResourceBuilder AddTestChildDatabase( + this IResourceBuilder builder, + string name, + string? databaseName = null) + { + var resource = new TestDatabaseResource(name) + { + DatabaseName = databaseName + }; + return builder.ApplicationBuilder.AddResource(resource); + } + /// /// Configures the Redis resource with persistence. /// From 29c11dd6ca21a8b5119afcb21fa5347a19dc109d Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 25 Feb 2026 14:17:18 +0800 Subject: [PATCH 177/256] Backport E2E test refactoring (#14667) --- .github/skills/cli-e2e-testing/SKILL.md | 9 +- .../skills/deployment-e2e-testing/SKILL.md | 8 +- .../AgentCommandTests.cs | 44 +--- .../Aspire.Cli.EndToEnd.Tests.csproj | 1 + .../Aspire.Cli.EndToEnd.Tests/BannerTests.cs | 26 +-- .../BundleSmokeTests.cs | 11 +- .../CentralPackageManagementTests.cs | 11 +- .../DescribeCommandTests.cs | 11 +- .../DockerDeploymentTests.cs | 21 +- .../DoctorCommandTests.cs | 23 +-- .../EmptyAppHostTemplateTests.cs | 11 +- .../Aspire.Cli.EndToEnd.Tests/GlobalUsings.cs | 4 + .../Helpers/CliE2ETestHelpers.cs | 141 ++----------- .../Helpers/SequenceCounter.cs | 14 -- .../JsReactTemplateTests.cs | 11 +- .../KubernetesPublishTests.cs | 10 +- .../LogsCommandTests.cs | 11 +- .../MultipleAppHostTests.cs | 11 +- .../PsCommandTests.cs | 11 +- .../PythonReactTemplateTests.cs | 11 +- tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs | 11 +- .../StagingChannelTests.cs | 11 +- .../StartStopTests.cs | 41 +--- .../StopNonInteractiveTests.cs | 41 +--- .../TypeScriptPolyglotTests.cs | 11 +- .../WaitCommandTests.cs | 11 +- .../AcaCompactNamingDeploymentTests.cs | 10 +- .../AcaCompactNamingUpgradeDeploymentTests.cs | 10 +- .../AcaCustomRegistryDeploymentTests.cs | 10 +- .../AcaDeploymentErrorOutputTests.cs | 10 +- .../AcaExistingRegistryDeploymentTests.cs | 10 +- .../AcaStarterDeploymentTests.cs | 10 +- .../AksStarterDeploymentTests.cs | 10 +- .../AksStarterWithRedisDeploymentTests.cs | 10 +- .../AppServicePythonDeploymentTests.cs | 10 +- .../AppServiceReactDeploymentTests.cs | 10 +- .../Aspire.Deployment.EndToEnd.Tests.csproj | 1 + .../AzureAppConfigDeploymentTests.cs | 10 +- .../AzureContainerRegistryDeploymentTests.cs | 10 +- .../AzureEventHubsDeploymentTests.cs | 10 +- .../AzureKeyVaultDeploymentTests.cs | 10 +- .../AzureLogAnalyticsDeploymentTests.cs | 10 +- .../AzureServiceBusDeploymentTests.cs | 10 +- .../AzureStorageDeploymentTests.cs | 10 +- .../GlobalUsings.cs | 4 + .../Helpers/DeploymentE2ETestHelpers.cs | 108 ++-------- .../Helpers/SequenceCounter.cs | 17 -- .../PythonFastApiDeploymentTests.cs | 10 +- ...VnetKeyVaultConnectivityDeploymentTests.cs | 10 +- .../VnetKeyVaultInfraDeploymentTests.cs | 10 +- ...netSqlServerConnectivityDeploymentTests.cs | 10 +- .../VnetSqlServerInfraDeploymentTests.cs | 10 +- ...tStorageBlobConnectivityDeploymentTests.cs | 10 +- .../VnetStorageBlobInfraDeploymentTests.cs | 10 +- tests/Shared/Hex1bTestHelpers.cs | 189 ++++++++++++++++++ 55 files changed, 292 insertions(+), 793 deletions(-) create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/GlobalUsings.cs delete mode 100644 tests/Aspire.Cli.EndToEnd.Tests/Helpers/SequenceCounter.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/GlobalUsings.cs delete mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/Helpers/SequenceCounter.cs create mode 100644 tests/Shared/Hex1bTestHelpers.cs diff --git a/.github/skills/cli-e2e-testing/SKILL.md b/.github/skills/cli-e2e-testing/SKILL.md index 02050f28783..8234bee8e3f 100644 --- a/.github/skills/cli-e2e-testing/SKILL.md +++ b/.github/skills/cli-e2e-testing/SKILL.md @@ -57,14 +57,8 @@ public sealed class SmokeTests : IAsyncDisposable var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(MyCliTest)); - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -275,7 +269,6 @@ Use `CliE2ETestHelpers` for CI environment variables: var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); // GITHUB_PR_NUMBER (0 when local) var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); // GITHUB_PR_HEAD_SHA ("local0000" when local) var isCI = CliE2ETestHelpers.IsRunningInCI; // true when both env vars set -var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath("test-name"); // Appropriate path for CI vs local ``` ## DON'T: Use Hard-coded Delays diff --git a/.github/skills/deployment-e2e-testing/SKILL.md b/.github/skills/deployment-e2e-testing/SKILL.md index 9784d408019..71687976658 100644 --- a/.github/skills/deployment-e2e-testing/SKILL.md +++ b/.github/skills/deployment-e2e-testing/SKILL.md @@ -83,18 +83,12 @@ public sealed class MyDeploymentTests(ITestOutputHelper output) // 2. Setup var resourceGroupName = AzureAuthenticationHelpers.GenerateResourceGroupName("my-scenario"); var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployMyScenario)); var startTime = DateTime.UtcNow; try { // 3. Build terminal and run deployment - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); var counter = new SequenceCounter(); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs index 446b4c1a4b9..d04c23714b8 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -31,16 +30,7 @@ public async Task AgentCommands_AllHelpOutputs_AreCorrect() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath( - nameof(AgentCommands_AllHelpOutputs_AreCorrect)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -136,16 +126,7 @@ public async Task AgentInitCommand_MigratesDeprecatedConfig() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath( - nameof(AgentInitCommand_MigratesDeprecatedConfig)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -255,16 +236,7 @@ public async Task DoctorCommand_DetectsDeprecatedAgentConfig() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath( - nameof(DoctorCommand_DetectsDeprecatedAgentConfig)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -332,16 +304,8 @@ public async Task AgentInitCommand_WithMalformedMcpJson_ShowsErrorAndExitsNonZer var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath( - nameof(AgentInitCommand_WithMalformedMcpJson_ShowsErrorAndExitsNonZero)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj b/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj index 1e4801674a3..5bd4cf8e073 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj +++ b/tests/Aspire.Cli.EndToEnd.Tests/Aspire.Cli.EndToEnd.Tests.csproj @@ -50,6 +50,7 @@ + diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs index 8a2f19ed57d..5bbd4a2048e 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/BannerTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,8 @@ public async Task Banner_DisplayedOnFirstRun() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(Banner_DisplayedOnFirstRun)); - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -94,14 +86,8 @@ public async Task Banner_DisplayedWithExplicitFlag() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(Banner_DisplayedWithExplicitFlag)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -160,14 +146,8 @@ public async Task Banner_NotDisplayedWithNoLogoFlag() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(Banner_NotDisplayedWithNoLogoFlag)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs index 2a0bceb3862..b628c6defd0 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/BundleSmokeTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -24,15 +23,7 @@ public async Task CreateAndRunAspireStarterProjectWithBundle() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndRunAspireStarterProjectWithBundle)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs index 069baeae379..28551963c5f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/CentralPackageManagementTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -24,15 +23,7 @@ public async Task AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesP var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesProps)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs index d946b463853..c6ef1f21391 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DescribeCommandTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task DescribeCommandShowsRunningResources() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(DescribeCommandShowsRunningResources)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs index a61ac5e0133..ea6cf828cab 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DockerDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -26,15 +25,7 @@ public async Task CreateAndDeployToDockerCompose() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndDeployToDockerCompose)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -203,15 +194,7 @@ public async Task CreateAndDeployToDockerComposeInteractive() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndDeployToDockerComposeInteractive)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs index a9c51496d59..98bfa6bd535 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,16 +22,7 @@ public async Task DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath( - nameof(DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -87,16 +77,7 @@ public async Task DoctorCommand_WithSslCertDir_ShowsTrusted() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath( - nameof(DoctorCommand_WithSslCertDir_ShowsTrusted)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs index 384165d596e..de7aaf05c8c 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task CreateEmptyAppHostProject() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateEmptyAppHostProject)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/GlobalUsings.cs b/tests/Aspire.Cli.EndToEnd.Tests/GlobalUsings.cs new file mode 100644 index 00000000000..75730f250c7 --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/GlobalUsings.cs @@ -0,0 +1,4 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +global using Aspire.Tests.Shared; diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs index cc2455000c8..fd3d0070e5e 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2ETestHelpers.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. -#pragma warning disable IDE0005 // Incorrectly flagged as unused due to types spread across namespaces +using System.Runtime.CompilerServices; using Aspire.Cli.Tests.Utils; +using Hex1b; using Hex1b.Automation; -#pragma warning restore IDE0005 using Xunit; namespace Aspire.Cli.EndToEnd.Tests.Helpers; @@ -69,22 +69,20 @@ internal static string GetRequiredCommitSha() /// The full path to the .cast recording file. internal static string GetTestResultsRecordingPath(string testName) { - var githubWorkspace = Environment.GetEnvironmentVariable("GITHUB_WORKSPACE"); - string recordingsDir; - - if (!string.IsNullOrEmpty(githubWorkspace)) - { - // CI environment - write directly to test results for artifact upload - recordingsDir = Path.Combine(githubWorkspace, "testresults", "recordings"); - } - else - { - // Local development - use temp directory - recordingsDir = Path.Combine(Path.GetTempPath(), "aspire-cli-e2e", "recordings"); - } + return Hex1bTestHelpers.GetTestResultsRecordingPath(testName, "aspire-cli-e2e"); + } - Directory.CreateDirectory(recordingsDir); - return Path.Combine(recordingsDir, $"{testName}.cast"); + /// + /// Creates a headless Hex1b terminal configured for E2E testing with asciinema recording. + /// Uses default dimensions of 160x48 unless overridden. + /// + /// The test name used for the recording file path. Defaults to the calling method name. + /// The terminal width in columns. Defaults to 160. + /// The terminal height in rows. Defaults to 48. + /// A configured instance. Caller is responsible for disposal. + internal static Hex1bTerminal CreateTestTerminal(int width = 160, int height = 48, [CallerMemberName] string testName = "") + { + return Hex1bTestHelpers.CreateTestTerminal("aspire-cli-e2e", width, height, testName); } internal static Hex1bTerminalInputSequenceBuilder PrepareEnvironment( @@ -199,109 +197,6 @@ internal static Hex1bTerminalInputSequenceBuilder VerifyAspireCliVersion( .WaitForSuccessPrompt(counter); } - internal static Hex1bTerminalInputSequenceBuilder WaitForSuccessPrompt( - this Hex1bTerminalInputSequenceBuilder builder, - SequenceCounter counter, - TimeSpan? timeout = null) - { - var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); - - return builder.WaitUntil(snapshot => - { - var successPromptSearcher = new CellPatternSearcher() - .FindPattern(counter.Value.ToString()) - .RightText(" OK] $ "); - - var result = successPromptSearcher.Search(snapshot); - return result.Count > 0; - }, effectiveTimeout) - .IncrementSequence(counter); - } - - /// - /// Waits for any prompt (success or error) matching the current sequence counter. - /// Use this when the command is expected to return a non-zero exit code. - /// - internal static Hex1bTerminalInputSequenceBuilder WaitForAnyPrompt( - this Hex1bTerminalInputSequenceBuilder builder, - SequenceCounter counter, - TimeSpan? timeout = null) - { - var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); - - return builder.WaitUntil(snapshot => - { - var successSearcher = new CellPatternSearcher() - .FindPattern(counter.Value.ToString()) - .RightText(" OK] $ "); - var errorSearcher = new CellPatternSearcher() - .FindPattern(counter.Value.ToString()) - .RightText(" ERR:"); - - return successSearcher.Search(snapshot).Count > 0 || errorSearcher.Search(snapshot).Count > 0; - }, effectiveTimeout) - .IncrementSequence(counter); - } - - /// - /// Waits for the shell prompt to show a non-zero exit code pattern: [N ERR:code] $ - /// This is used to verify that a command exited with a failure code. - /// - /// The sequence builder. - /// The sequence counter for prompt detection. - /// The expected non-zero exit code. - /// Optional timeout (defaults to 500 seconds). - /// The builder for chaining. - internal static Hex1bTerminalInputSequenceBuilder WaitForErrorPrompt( - this Hex1bTerminalInputSequenceBuilder builder, - SequenceCounter counter, - int exitCode = 1, - TimeSpan? timeout = null) - { - var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); - - return builder.WaitUntil(snapshot => - { - var errorPromptSearcher = new CellPatternSearcher() - .FindPattern(counter.Value.ToString()) - .RightText($" ERR:{exitCode}] $ "); - - var result = errorPromptSearcher.Search(snapshot); - return result.Count > 0; - }, effectiveTimeout) - .IncrementSequence(counter); - } - - internal static Hex1bTerminalInputSequenceBuilder IncrementSequence( - this Hex1bTerminalInputSequenceBuilder builder, - SequenceCounter counter) - { - return builder.WaitUntil(s => - { - // Hack to pump the counter fluently. - counter.Increment(); - return true; - }, TimeSpan.FromSeconds(1)); - } - - /// - /// Executes an arbitrary callback action during the sequence execution. - /// This is useful for performing file modifications or other side effects between terminal commands. - /// - /// The sequence builder. - /// The callback action to execute. - /// The builder for chaining. - internal static Hex1bTerminalInputSequenceBuilder ExecuteCallback( - this Hex1bTerminalInputSequenceBuilder builder, - Action callback) - { - return builder.WaitUntil(s => - { - callback(); - return true; - }, TimeSpan.FromSeconds(1)); - } - /// /// Enables polyglot support feature flag using the aspire config set command. /// This allows the CLI to create TypeScript and Python AppHosts. @@ -451,11 +346,11 @@ internal static Hex1bTerminalInputSequenceBuilder CreateDeprecatedMcpConfig( } /// - /// Creates a malformed MCP config file for testing error handling. + /// Creates a .vscode/mcp.json file with malformed content for testing error handling. /// /// The sequence builder. - /// The path to create the malformed config file. - /// The malformed JSON content to write. + /// The path to the mcp.json file. + /// The malformed content to write. /// The builder for chaining. internal static Hex1bTerminalInputSequenceBuilder CreateMalformedMcpConfig( this Hex1bTerminalInputSequenceBuilder builder, diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SequenceCounter.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SequenceCounter.cs deleted file mode 100644 index 3582322067d..00000000000 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/SequenceCounter.cs +++ /dev/null @@ -1,14 +0,0 @@ -// 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; - -public class SequenceCounter -{ - public int Value { get; private set; } = 1; - - public int Increment() - { - return ++Value; - } -} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs index 978af50c2c4..f0c5eef3a52 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JsReactTemplateTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task CreateAndRunJsReactProject() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndRunJsReactProject)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs index 42b57f0ecb4..e39899f2b5d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -34,20 +33,13 @@ public async Task CreateAndPublishToKubernetes() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndPublishToKubernetes)); var clusterName = GenerateUniqueClusterName(); output.WriteLine($"Using KinD version: {KindVersion}"); output.WriteLine($"Using Helm version: {HelmVersion}"); output.WriteLine($"Using cluster name: {clusterName}"); - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs index 30ffe3adabd..680fe95fd2b 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/LogsCommandTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task LogsCommandShowsResourceLogs() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(LogsCommandShowsResourceLogs)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs index a963b5291de..b7b3ee942af 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/MultipleAppHostTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task DetachFormatJsonProducesValidJson() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(DetachFormatJsonProducesValidJson)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs index cafff69de44..afd13bb3b55 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PsCommandTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task PsCommandListsRunningAppHost() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(PsCommandListsRunningAppHost)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs index 684bab355f8..683afa46241 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/PythonReactTemplateTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task CreateAndRunPythonReactProject() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndRunPythonReactProject)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs index e85f579a1c4..7903092841d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task CreateAndRunAspireStarterProject() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateAndRunAspireStarterProject)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs index 14498306c9d..eb5a9b163bc 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StagingChannelTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -24,15 +23,7 @@ public async Task StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs index e1d8092b158..b9326bae40f 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StartStopTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task CreateStartAndStopAspireProject() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateStartAndStopAspireProject)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -130,15 +121,7 @@ public async Task StopWithNoRunningAppHostExitsSuccessfully() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StopWithNoRunningAppHostExitsSuccessfully)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -183,15 +166,7 @@ public async Task AddPackageWhileAppHostRunningDetached() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(AddPackageWhileAppHostRunningDetached)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -302,15 +277,7 @@ public async Task AddPackageInteractiveWhileAppHostRunningDetached() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(AddPackageInteractiveWhileAppHostRunningDetached)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs index 2f12c568511..e4a5a2c9023 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/StopNonInteractiveTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task StopNonInteractiveSingleAppHost() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StopNonInteractiveSingleAppHost)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -145,15 +136,7 @@ public async Task StopAllAppHostsFromAppHostDirectory() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StopAllAppHostsFromAppHostDirectory)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -292,15 +275,7 @@ public async Task StopAllAppHostsFromUnrelatedDirectory() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StopAllAppHostsFromUnrelatedDirectory)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); @@ -444,15 +419,7 @@ public async Task StopNonInteractiveMultipleAppHostsShowsError() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(StopNonInteractiveMultipleAppHostsShowsError)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs index 348df5ee732..46a6576354e 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task CreateTypeScriptAppHostWithViteApp() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateTypeScriptAppHostWithViteApp)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs index e0fab0a036b..1c6e3bbd301 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/WaitCommandTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.EndToEnd.Tests.Helpers; using Aspire.Cli.Tests.Utils; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -23,15 +22,7 @@ public async Task CreateStartWaitAndStopAspireProject() var prNumber = CliE2ETestHelpers.GetRequiredPrNumber(); var commitSha = CliE2ETestHelpers.GetRequiredCommitSha(); var isCI = CliE2ETestHelpers.IsRunningInCI; - var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(nameof(CreateStartWaitAndStopAspireProject)); - - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = CliE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs index 058d1be7673..36073dad29b 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -54,7 +53,6 @@ private async Task DeployWithCompactNamingFixesStorageCollisionCore(Cancellation } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployWithCompactNamingFixesStorageCollision)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("compact"); @@ -65,13 +63,7 @@ private async Task DeployWithCompactNamingFixesStorageCollisionCore(Cancellation try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var waitingForInitComplete = new CellPatternSearcher() diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs index c0f39d3175c..0cfc7290cca 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCompactNamingUpgradeDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -53,7 +52,6 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(UpgradeFromGaToDevDoesNotDuplicateStorageAccounts)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("upgrade"); @@ -64,13 +62,7 @@ private async Task UpgradeFromGaToDevDoesNotDuplicateStorageAccountsCore(Cancell try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var waitingForInitComplete = new CellPatternSearcher() diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs index 9f89389e4a2..0e74570250f 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaCustomRegistryDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -52,7 +51,6 @@ private async Task DeployStarterTemplateWithCustomRegistryCore(CancellationToken } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithCustomRegistry)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("aca-custom-acr"); @@ -66,13 +64,7 @@ private async Task DeployStarterTemplateWithCustomRegistryCore(CancellationToken try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire new interactive prompts diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaDeploymentErrorOutputTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaDeploymentErrorOutputTests.cs index a275c95badf..95d4c13c01a 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaDeploymentErrorOutputTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaDeploymentErrorOutputTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -56,7 +55,6 @@ private async Task DeployWithInvalidLocation_ErrorOutputIsCleanCore(Cancellation } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployWithInvalidLocation_ErrorOutputIsClean)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("errout"); var deployOutputFile = Path.Combine(workspace.WorkspaceRoot.FullName, "deploy-output.txt"); @@ -68,13 +66,7 @@ private async Task DeployWithInvalidLocation_ErrorOutputIsCleanCore(Cancellation try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var waitingForInitComplete = new CellPatternSearcher() diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs index b93678ac62b..f16553ca300 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaExistingRegistryDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployStarterTemplateWithExistingRegistryCore(CancellationTok } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithExistingRegistry)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("aca-existing-acr"); @@ -76,13 +74,7 @@ private async Task DeployStarterTemplateWithExistingRegistryCore(CancellationTok try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire new interactive prompts diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs index 6f7488c993f..5bf5a2c7af1 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AcaStarterDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateToAzureContainerApps)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt] @@ -67,13 +65,7 @@ private async Task DeployStarterTemplateToAzureContainerAppsCore(CancellationTok try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire new interactive prompts diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs index 6f5a46903fb..6e25dfaf4c3 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -50,7 +49,6 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateToAks)); var startTime = DateTime.UtcNow; // Generate unique names for Azure resources @@ -74,13 +72,7 @@ private async Task DeployStarterTemplateToAksCore(CancellationToken cancellation try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var counter = new SequenceCounter(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs index 9c3b0332ff7..25cdc3aba6e 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksStarterWithRedisDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithRedisToAks)); var startTime = DateTime.UtcNow; // Generate unique names for Azure resources @@ -75,13 +73,7 @@ private async Task DeployStarterTemplateWithRedisToAksCore(CancellationToken can try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var counter = new SequenceCounter(); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs index e4887306758..c15d910933f 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AppServicePythonDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployPythonFastApiTemplateToAzureAppServiceCore(Cancellation } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployPythonFastApiTemplateToAzureAppService)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt] @@ -67,13 +65,7 @@ private async Task DeployPythonFastApiTemplateToAzureAppServiceCore(Cancellation try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire new interactive prompts diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs index cd0509f3b69..f006cd1475b 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AppServiceReactDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployReactTemplateToAzureAppServiceCore(CancellationToken ca } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployReactTemplateToAzureAppService)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt] @@ -67,13 +65,7 @@ private async Task DeployReactTemplateToAzureAppServiceCore(CancellationToken ca try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire new interactive prompts diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj b/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj index 2c6d3eb61bf..9ce58253a7d 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj +++ b/tests/Aspire.Deployment.EndToEnd.Tests/Aspire.Deployment.EndToEnd.Tests.csproj @@ -50,6 +50,7 @@ + diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs index c89e886df2f..529e7cd22cb 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureAppConfigDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployAzureAppConfigResourceCore(CancellationToken cancellati } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureAppConfigResource)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("appconfig"); @@ -62,13 +60,7 @@ private async Task DeployAzureAppConfigResourceCore(CancellationToken cancellati try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire init diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs index b27221ad519..b387dcc5cf1 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureContainerRegistryDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployAzureContainerRegistryResourceCore(CancellationToken ca } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureContainerRegistryResource)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("acr"); @@ -62,13 +60,7 @@ private async Task DeployAzureContainerRegistryResourceCore(CancellationToken ca try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire init diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs index f551407bf2f..eaea7798804 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureEventHubsDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployAzureEventHubsResourceCore(CancellationToken cancellati } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureEventHubsResource)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("eventhubs"); @@ -62,13 +60,7 @@ private async Task DeployAzureEventHubsResourceCore(CancellationToken cancellati try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire init diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs index 035b280a7df..ec82296d081 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureKeyVaultDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployAzureKeyVaultResourceCore(CancellationToken cancellatio } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureKeyVaultResource)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("keyvault"); @@ -62,13 +60,7 @@ private async Task DeployAzureKeyVaultResourceCore(CancellationToken cancellatio try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire init diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs index c0a14e97dfb..f26c51ce2b0 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureLogAnalyticsDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployAzureLogAnalyticsResourceCore(CancellationToken cancell } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureLogAnalyticsResource)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("logs"); @@ -62,13 +60,7 @@ private async Task DeployAzureLogAnalyticsResourceCore(CancellationToken cancell try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire init diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs index c3083e8d9a4..0378cf537e9 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureServiceBusDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployAzureServiceBusResourceCore(CancellationToken cancellat } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureServiceBusResource)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("servicebus"); @@ -62,13 +60,7 @@ private async Task DeployAzureServiceBusResourceCore(CancellationToken cancellat try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire init diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs index 3c004591f74..d309d8337ad 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployAzureStorageResourceCore(CancellationToken cancellation } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployAzureStorageResource)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("storage"); @@ -62,13 +60,7 @@ private async Task DeployAzureStorageResourceCore(CancellationToken cancellation try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire init diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/GlobalUsings.cs b/tests/Aspire.Deployment.EndToEnd.Tests/GlobalUsings.cs new file mode 100644 index 00000000000..75730f250c7 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/GlobalUsings.cs @@ -0,0 +1,4 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +global using Aspire.Tests.Shared; diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs index 6b2f00c359b..9d1a305d9b0 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2ETestHelpers.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Runtime.CompilerServices; using Aspire.Cli.Tests.Utils; +using Hex1b; using Hex1b.Automation; namespace Aspire.Deployment.EndToEnd.Tests.Helpers; @@ -75,25 +77,25 @@ internal static string GenerateResourceGroupName(string testCaseName) return $"e2e-{testCaseName}-{runId}-{attempt}"; } + /// + /// Creates a headless Hex1b terminal configured for deployment E2E testing with asciinema recording. + /// Uses default dimensions of 160x48 unless overridden. + /// + /// The test name used for the recording file path. Defaults to the calling method name. + /// The terminal width in columns. Defaults to 160. + /// The terminal height in rows. Defaults to 48. + /// A configured instance. Caller is responsible for disposal. + internal static Hex1bTerminal CreateTestTerminal(int width = 160, int height = 48, [CallerMemberName] string testName = "") + { + return Hex1bTestHelpers.CreateTestTerminal("aspire-deployment-e2e", width, height, testName); + } + /// /// Gets the path for storing asciinema recordings that will be uploaded as CI artifacts. /// internal static string GetTestResultsRecordingPath(string testName) { - var githubWorkspace = Environment.GetEnvironmentVariable("GITHUB_WORKSPACE"); - string recordingsDir; - - if (!string.IsNullOrEmpty(githubWorkspace)) - { - recordingsDir = Path.Combine(githubWorkspace, "testresults", "recordings"); - } - else - { - recordingsDir = Path.Combine(Path.GetTempPath(), "aspire-deployment-e2e", "recordings"); - } - - Directory.CreateDirectory(recordingsDir); - return Path.Combine(recordingsDir, $"{testName}.cast"); + return Hex1bTestHelpers.GetTestResultsRecordingPath(testName, "aspire-deployment-e2e"); } /// @@ -172,82 +174,4 @@ internal static Hex1bTerminalInputSequenceBuilder SourceAspireCliEnvironment( .WaitForSuccessPrompt(counter); } - /// - /// Waits for a successful command prompt with the expected sequence number. - /// - internal static Hex1bTerminalInputSequenceBuilder WaitForSuccessPrompt( - this Hex1bTerminalInputSequenceBuilder builder, - SequenceCounter counter, - TimeSpan? timeout = null) - { - var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); - - return builder.WaitUntil(snapshot => - { - var successPromptSearcher = new CellPatternSearcher() - .FindPattern(counter.Value.ToString()) - .RightText(" OK] $ "); - - var result = successPromptSearcher.Search(snapshot); - return result.Count > 0; - }, effectiveTimeout) - .IncrementSequence(counter); - } - - /// - /// Waits for any command prompt (success or error) with the expected sequence number. - /// Use this after commands that are expected to fail (non-zero exit code). - /// - internal static Hex1bTerminalInputSequenceBuilder WaitForAnyPrompt( - this Hex1bTerminalInputSequenceBuilder builder, - SequenceCounter counter, - TimeSpan? timeout = null) - { - var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); - - return builder.WaitUntil(snapshot => - { - var expectedCount = counter.Value.ToString(); - - var successSearcher = new CellPatternSearcher() - .FindPattern(expectedCount) - .RightText(" OK] $ "); - - var errorSearcher = new CellPatternSearcher() - .FindPattern(expectedCount) - .RightText(" ERR:"); - - return successSearcher.Search(snapshot).Count > 0 - || errorSearcher.Search(snapshot).Count > 0; - }, effectiveTimeout) - .IncrementSequence(counter); - } - - /// - /// Increments the sequence counter. - /// - internal static Hex1bTerminalInputSequenceBuilder IncrementSequence( - this Hex1bTerminalInputSequenceBuilder builder, - SequenceCounter counter) - { - return builder.WaitUntil(s => - { - counter.Increment(); - return true; - }, TimeSpan.FromSeconds(1)); - } - - /// - /// Executes an arbitrary callback action during the sequence execution. - /// - internal static Hex1bTerminalInputSequenceBuilder ExecuteCallback( - this Hex1bTerminalInputSequenceBuilder builder, - Action callback) - { - return builder.WaitUntil(s => - { - callback(); - return true; - }, TimeSpan.FromSeconds(1)); - } } diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/SequenceCounter.cs b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/SequenceCounter.cs deleted file mode 100644 index 0fd8b5cc93c..00000000000 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/SequenceCounter.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Aspire.Deployment.EndToEnd.Tests.Helpers; - -/// -/// Tracks the sequence number for shell prompt detection in Hex1b terminal sessions. -/// -public class SequenceCounter -{ - public int Value { get; private set; } = 1; - - public int Increment() - { - return ++Value; - } -} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs index 71537a5f54e..3a7255a684c 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/PythonFastApiDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -51,7 +50,6 @@ private async Task DeployPythonFastApiTemplateToAzureContainerAppsCore(Cancellat } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployPythonFastApiTemplateToAzureContainerApps)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); // Generate a unique resource group name with pattern: e2e-[testcasename]-[runid]-[attempt] @@ -67,13 +65,7 @@ private async Task DeployPythonFastApiTemplateToAzureContainerAppsCore(Cancellat try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire new interactive prompts diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs index 4971f8bf592..77e21948e90 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultConnectivityDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -49,7 +48,6 @@ private async Task DeployStarterTemplateWithKeyVaultPrivateEndpointCore(Cancella } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithKeyVaultPrivateEndpoint)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-kv-l23"); @@ -63,13 +61,7 @@ private async Task DeployStarterTemplateWithKeyVaultPrivateEndpointCore(Cancella try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var waitingForTemplateSelectionPrompt = new CellPatternSearcher() diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs index 6f85e18d287..13c82e0fd3c 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetKeyVaultInfraDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -49,7 +48,6 @@ private async Task DeployVnetKeyVaultInfrastructureCore(CancellationToken cancel } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployVnetKeyVaultInfrastructure)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-kv-l1"); @@ -60,13 +58,7 @@ private async Task DeployVnetKeyVaultInfrastructureCore(CancellationToken cancel try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var waitingForInitComplete = new CellPatternSearcher() diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs index 9616dc53e25..61b9a4d9846 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerConnectivityDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -50,7 +49,6 @@ private async Task DeployStarterTemplateWithSqlServerPrivateEndpointCore(Cancell } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithSqlServerPrivateEndpoint)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-sql-l23"); @@ -64,13 +62,7 @@ private async Task DeployStarterTemplateWithSqlServerPrivateEndpointCore(Cancell try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var waitingForTemplateSelectionPrompt = new CellPatternSearcher() diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs index 9f15d2288e1..badfc76ce63 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetSqlServerInfraDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -49,7 +48,6 @@ private async Task DeployVnetSqlServerInfrastructureCore(CancellationToken cance } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployVnetSqlServerInfrastructure)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-sql-l1"); @@ -60,13 +58,7 @@ private async Task DeployVnetSqlServerInfrastructureCore(CancellationToken cance try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var waitingForInitComplete = new CellPatternSearcher() diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs index f06017ebb95..294de71c57c 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobConnectivityDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -49,7 +48,6 @@ private async Task DeployStarterTemplateWithStorageBlobPrivateEndpointCore(Cance } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployStarterTemplateWithStorageBlobPrivateEndpoint)); var startTime = DateTime.UtcNow; var deploymentUrls = new Dictionary(); var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-blob-l23"); @@ -63,13 +61,7 @@ private async Task DeployStarterTemplateWithStorageBlobPrivateEndpointCore(Cance try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); // Pattern searchers for aspire new interactive prompts diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs index 07346fde16d..9dcab71af68 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/VnetStorageBlobInfraDeploymentTests.cs @@ -3,7 +3,6 @@ using Aspire.Cli.Tests.Utils; using Aspire.Deployment.EndToEnd.Tests.Helpers; -using Hex1b; using Hex1b.Automation; using Xunit; @@ -49,7 +48,6 @@ private async Task DeployVnetStorageBlobInfrastructureCore(CancellationToken can } var workspace = TemporaryWorkspace.Create(output); - var recordingPath = DeploymentE2ETestHelpers.GetTestResultsRecordingPath(nameof(DeployVnetStorageBlobInfrastructure)); var startTime = DateTime.UtcNow; var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("vnet-blob-l1"); @@ -60,13 +58,7 @@ private async Task DeployVnetStorageBlobInfrastructureCore(CancellationToken can try { - var builder = Hex1bTerminal.CreateBuilder() - .WithHeadless() - .WithDimensions(160, 48) - .WithAsciinemaRecording(recordingPath) - .WithPtyProcess("/bin/bash", ["--norc"]); - - using var terminal = builder.Build(); + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); var pendingRun = terminal.RunAsync(cancellationToken); var waitingForInitComplete = new CellPatternSearcher() diff --git a/tests/Shared/Hex1bTestHelpers.cs b/tests/Shared/Hex1bTestHelpers.cs new file mode 100644 index 00000000000..a0937a48aaa --- /dev/null +++ b/tests/Shared/Hex1bTestHelpers.cs @@ -0,0 +1,189 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; +using Hex1b; +using Hex1b.Automation; + +namespace Aspire.Tests.Shared; + +/// +/// Tracks the sequence number for shell prompt detection in Hex1b terminal sessions. +/// +internal sealed class SequenceCounter +{ + public int Value { get; private set; } = 1; + + public int Increment() + { + return ++Value; + } +} + +/// +/// Shared helper methods for creating and managing Hex1b terminal sessions across E2E test projects. +/// +internal static class Hex1bTestHelpers +{ + /// + /// Creates a headless Hex1b terminal configured for E2E testing with asciinema recording. + /// Uses default dimensions of 160x48 unless overridden. + /// + /// The test name used for the recording file path. Defaults to the calling method name. + /// The subdirectory name under the temp folder for local (non-CI) recordings. + /// The terminal width in columns. Defaults to 160. + /// The terminal height in rows. Defaults to 48. + /// A configured instance. Caller is responsible for disposal. + internal static Hex1bTerminal CreateTestTerminal( + string localSubDir, + int width = 160, + int height = 48, + [CallerMemberName] string testName = "") + { + var recordingPath = GetTestResultsRecordingPath(testName, localSubDir); + + var builder = Hex1bTerminal.CreateBuilder() + .WithHeadless() + .WithDimensions(width, height) + .WithAsciinemaRecording(recordingPath) + .WithPtyProcess("/bin/bash", ["--norc"]); + + return builder.Build(); + } + + /// + /// Gets the path for storing asciinema recordings that will be uploaded as CI artifacts. + /// In CI, this returns a path under $GITHUB_WORKSPACE/testresults/recordings/. + /// Locally, this returns a path under the system temp directory. + /// + /// The name of the test (used as the recording filename). + /// The subdirectory name under the temp folder for local (non-CI) recordings. + /// The full path to the .cast recording file. + internal static string GetTestResultsRecordingPath(string testName, string localSubDir) + { + var githubWorkspace = Environment.GetEnvironmentVariable("GITHUB_WORKSPACE"); + string recordingsDir; + + if (!string.IsNullOrEmpty(githubWorkspace)) + { + // CI environment - write directly to test results for artifact upload + recordingsDir = Path.Combine(githubWorkspace, "testresults", "recordings"); + } + else + { + // Local development - use temp directory + recordingsDir = Path.Combine(Path.GetTempPath(), localSubDir, "recordings"); + } + + Directory.CreateDirectory(recordingsDir); + return Path.Combine(recordingsDir, $"{testName}.cast"); + } + + /// + /// Waits for a successful command prompt with the expected sequence number. + /// + internal static Hex1bTerminalInputSequenceBuilder WaitForSuccessPrompt( + this Hex1bTerminalInputSequenceBuilder builder, + SequenceCounter counter, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); + + return builder.WaitUntil(snapshot => + { + var successPromptSearcher = new CellPatternSearcher() + .FindPattern(counter.Value.ToString()) + .RightText(" OK] $ "); + + var result = successPromptSearcher.Search(snapshot); + return result.Count > 0; + }, effectiveTimeout) + .IncrementSequence(counter); + } + + /// + /// Waits for any prompt (success or error) matching the current sequence counter. + /// Use this when the command is expected to return a non-zero exit code. + /// + internal static Hex1bTerminalInputSequenceBuilder WaitForAnyPrompt( + this Hex1bTerminalInputSequenceBuilder builder, + SequenceCounter counter, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); + + return builder.WaitUntil(snapshot => + { + var successSearcher = new CellPatternSearcher() + .FindPattern(counter.Value.ToString()) + .RightText(" OK] $ "); + var errorSearcher = new CellPatternSearcher() + .FindPattern(counter.Value.ToString()) + .RightText(" ERR:"); + + return successSearcher.Search(snapshot).Count > 0 || errorSearcher.Search(snapshot).Count > 0; + }, effectiveTimeout) + .IncrementSequence(counter); + } + + /// + /// Waits for the shell prompt to show a non-zero exit code pattern: [N ERR:code] $ + /// This is used to verify that a command exited with a failure code. + /// + /// The sequence builder. + /// The sequence counter for prompt detection. + /// The expected non-zero exit code. + /// Optional timeout (defaults to 500 seconds). + /// The builder for chaining. + internal static Hex1bTerminalInputSequenceBuilder WaitForErrorPrompt( + this Hex1bTerminalInputSequenceBuilder builder, + SequenceCounter counter, + int exitCode = 1, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(500); + + return builder.WaitUntil(snapshot => + { + var errorPromptSearcher = new CellPatternSearcher() + .FindPattern(counter.Value.ToString()) + .RightText($" ERR:{exitCode}] $ "); + + var result = errorPromptSearcher.Search(snapshot); + return result.Count > 0; + }, effectiveTimeout) + .IncrementSequence(counter); + } + + /// + /// Increments the sequence counter. + /// + internal static Hex1bTerminalInputSequenceBuilder IncrementSequence( + this Hex1bTerminalInputSequenceBuilder builder, + SequenceCounter counter) + { + return builder.WaitUntil(s => + { + counter.Increment(); + return true; + }, TimeSpan.FromSeconds(1)); + } + + /// + /// Executes an arbitrary callback action during the sequence execution. + /// This is useful for performing file modifications or other side effects between terminal commands. + /// + /// The sequence builder. + /// The callback action to execute. + /// The builder for chaining. + internal static Hex1bTerminalInputSequenceBuilder ExecuteCallback( + this Hex1bTerminalInputSequenceBuilder builder, + Action callback) + { + return builder.WaitUntil(s => + { + callback(); + return true; + }, TimeSpan.FromSeconds(1)); + } +} \ No newline at end of file From 802684332381fce73244d1af27301b8763fe47bf Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 25 Feb 2026 23:41:48 +0800 Subject: [PATCH 178/256] Rename --project to --apphost (#14674) * Rename --project to --apphost * Don't show fallback in help * Update resource files and add tests --- .../Backchannel/AppHostConnectionResolver.cs | 2 +- src/Aspire.Cli/Commands/AddCommand.cs | 9 +-- src/Aspire.Cli/Commands/AppHostLauncher.cs | 9 +-- src/Aspire.Cli/Commands/DescribeCommand.cs | 9 +-- src/Aspire.Cli/Commands/ExecCommand.cs | 9 +-- src/Aspire.Cli/Commands/LogsCommand.cs | 9 +-- src/Aspire.Cli/Commands/OptionWithLegacy.cs | 64 +++++++++++++++++++ .../Commands/PipelineCommandBase.cs | 9 +-- src/Aspire.Cli/Commands/ResourceCommand.cs | 9 +-- .../Commands/ResourceCommandBase.cs | 9 +-- src/Aspire.Cli/Commands/RunCommand.cs | 2 +- src/Aspire.Cli/Commands/StartCommand.cs | 2 +- src/Aspire.Cli/Commands/StopCommand.cs | 13 ++-- .../Commands/TelemetryCommandHelpers.cs | 13 +--- .../Commands/TelemetryLogsCommand.cs | 8 +-- .../Commands/TelemetrySpansCommand.cs | 8 +-- .../Commands/TelemetryTracesCommand.cs | 8 +-- src/Aspire.Cli/Commands/UpdateCommand.cs | 9 +-- src/Aspire.Cli/Commands/WaitCommand.cs | 9 +-- .../Resources/AddCommandStrings.resx | 2 +- .../InteractionServiceStrings.Designer.cs | 4 +- .../Resources/InteractionServiceStrings.resx | 4 +- .../SharedCommandStrings.Designer.cs | 4 +- .../Resources/SharedCommandStrings.resx | 2 +- .../Resources/StopCommandStrings.resx | 2 +- .../Resources/xlf/AddCommandStrings.cs.xlf | 4 +- .../Resources/xlf/AddCommandStrings.de.xlf | 4 +- .../Resources/xlf/AddCommandStrings.es.xlf | 4 +- .../Resources/xlf/AddCommandStrings.fr.xlf | 4 +- .../Resources/xlf/AddCommandStrings.it.xlf | 4 +- .../Resources/xlf/AddCommandStrings.ja.xlf | 4 +- .../Resources/xlf/AddCommandStrings.ko.xlf | 4 +- .../Resources/xlf/AddCommandStrings.pl.xlf | 4 +- .../Resources/xlf/AddCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/AddCommandStrings.ru.xlf | 4 +- .../Resources/xlf/AddCommandStrings.tr.xlf | 4 +- .../xlf/AddCommandStrings.zh-Hans.xlf | 4 +- .../xlf/AddCommandStrings.zh-Hant.xlf | 4 +- .../xlf/InteractionServiceStrings.cs.xlf | 8 +-- .../xlf/InteractionServiceStrings.de.xlf | 8 +-- .../xlf/InteractionServiceStrings.es.xlf | 8 +-- .../xlf/InteractionServiceStrings.fr.xlf | 8 +-- .../xlf/InteractionServiceStrings.it.xlf | 8 +-- .../xlf/InteractionServiceStrings.ja.xlf | 8 +-- .../xlf/InteractionServiceStrings.ko.xlf | 8 +-- .../xlf/InteractionServiceStrings.pl.xlf | 8 +-- .../xlf/InteractionServiceStrings.pt-BR.xlf | 8 +-- .../xlf/InteractionServiceStrings.ru.xlf | 8 +-- .../xlf/InteractionServiceStrings.tr.xlf | 8 +-- .../xlf/InteractionServiceStrings.zh-Hans.xlf | 8 +-- .../xlf/InteractionServiceStrings.zh-Hant.xlf | 8 +-- .../Resources/xlf/SharedCommandStrings.cs.xlf | 2 +- .../Resources/xlf/SharedCommandStrings.de.xlf | 2 +- .../Resources/xlf/SharedCommandStrings.es.xlf | 2 +- .../Resources/xlf/SharedCommandStrings.fr.xlf | 2 +- .../Resources/xlf/SharedCommandStrings.it.xlf | 2 +- .../Resources/xlf/SharedCommandStrings.ja.xlf | 2 +- .../Resources/xlf/SharedCommandStrings.ko.xlf | 2 +- .../Resources/xlf/SharedCommandStrings.pl.xlf | 2 +- .../xlf/SharedCommandStrings.pt-BR.xlf | 2 +- .../Resources/xlf/SharedCommandStrings.ru.xlf | 2 +- .../Resources/xlf/SharedCommandStrings.tr.xlf | 2 +- .../xlf/SharedCommandStrings.zh-Hans.xlf | 2 +- .../xlf/SharedCommandStrings.zh-Hant.xlf | 2 +- .../Resources/xlf/StopCommandStrings.cs.xlf | 4 +- .../Resources/xlf/StopCommandStrings.de.xlf | 4 +- .../Resources/xlf/StopCommandStrings.es.xlf | 4 +- .../Resources/xlf/StopCommandStrings.fr.xlf | 4 +- .../Resources/xlf/StopCommandStrings.it.xlf | 4 +- .../Resources/xlf/StopCommandStrings.ja.xlf | 4 +- .../Resources/xlf/StopCommandStrings.ko.xlf | 4 +- .../Resources/xlf/StopCommandStrings.pl.xlf | 4 +- .../xlf/StopCommandStrings.pt-BR.xlf | 4 +- .../Resources/xlf/StopCommandStrings.ru.xlf | 4 +- .../Resources/xlf/StopCommandStrings.tr.xlf | 4 +- .../xlf/StopCommandStrings.zh-Hans.xlf | 4 +- .../xlf/StopCommandStrings.zh-Hant.xlf | 4 +- .../Commands/DeployCommandTests.cs | 6 +- .../Commands/DoCommandTests.cs | 2 +- .../Commands/ExecCommandTests.cs | 4 +- .../Commands/OptionWithLegacyTests.cs | 40 ++++++++++++ .../Commands/PublishCommandTests.cs | 8 +-- .../Commands/ResourceCommandTests.cs | 2 +- .../Commands/RestartCommandTests.cs | 2 +- .../Commands/RunCommandTests.cs | 2 +- .../Commands/StartCommandTests.cs | 2 +- .../Commands/UpdateCommandTests.cs | 6 +- .../Commands/WaitCommandTests.cs | 2 +- 88 files changed, 301 insertions(+), 237 deletions(-) create mode 100644 src/Aspire.Cli/Commands/OptionWithLegacy.cs create mode 100644 tests/Aspire.Cli.Tests/Commands/OptionWithLegacyTests.cs diff --git a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs index 4c0fd2fea3e..03c7440ff6d 100644 --- a/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs +++ b/src/Aspire.Cli/Backchannel/AppHostConnectionResolver.cs @@ -80,7 +80,7 @@ public async Task ResolveConnectionAsync( string notFoundMessage, CancellationToken cancellationToken) { - // Fast path: If --project was specified, check directly for its socket + // Fast path: If --apphost was specified, check directly for its socket if (projectFile is not null) { var targetPath = projectFile.FullName; diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 72dce6dc6cd..1bc8bc08b70 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -34,10 +34,7 @@ internal sealed class AddCommand : BaseCommand Description = AddCommandStrings.IntegrationArgumentDescription, Arity = ArgumentArity.ZeroOrOne }; - private static readonly Option s_projectOption = new("--project") - { - Description = AddCommandStrings.ProjectArgumentDescription - }; + private static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", AddCommandStrings.ProjectArgumentDescription); private static readonly Option s_versionOption = new("--version") { Description = AddCommandStrings.VersionArgumentDescription @@ -59,7 +56,7 @@ public AddCommand(IPackagingService packagingService, IInteractionService intera _projectFactory = projectFactory; Arguments.Add(s_integrationArgument); - Options.Add(s_projectOption); + Options.Add(s_appHostOption); Options.Add(s_versionOption); Options.Add(s_sourceOption); } @@ -74,7 +71,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { var integrationName = parseResult.GetValue(s_integrationArgument); - var passedAppHostProjectFile = parseResult.GetValue(s_projectOption); + var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption); var searchResult = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, MultipleAppHostProjectsFoundBehavior.Prompt, createSettingsFile: true, cancellationToken); var effectiveAppHostProjectFile = searchResult.SelectedProjectFile; diff --git a/src/Aspire.Cli/Commands/AppHostLauncher.cs b/src/Aspire.Cli/Commands/AppHostLauncher.cs index 4374d4ecf90..d0fd1722bd7 100644 --- a/src/Aspire.Cli/Commands/AppHostLauncher.cs +++ b/src/Aspire.Cli/Commands/AppHostLauncher.cs @@ -36,10 +36,7 @@ internal sealed class AppHostLauncher( /// /// Shared option for the AppHost project file path. /// - internal static readonly Option s_projectOption = new("--project") - { - Description = SharedCommandStrings.ProjectOptionDescription - }; + internal static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription); /// /// Shared option for output format (JSON or table) in detached AppHost mode. @@ -63,7 +60,7 @@ internal sealed class AppHostLauncher( /// internal static void AddLaunchOptions(Command command) { - command.Options.Add(s_projectOption); + command.Options.Add(s_appHostOption); command.Options.Add(s_formatOption); command.Options.Add(s_isolatedOption); } @@ -173,7 +170,7 @@ private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, Can { "run", "--non-interactive", - s_projectOption.Name, + s_appHostOption.Name, effectiveAppHostFile.FullName, "--log-file", childLogFile diff --git a/src/Aspire.Cli/Commands/DescribeCommand.cs b/src/Aspire.Cli/Commands/DescribeCommand.cs index b78a9b508fe..246bcc16983 100644 --- a/src/Aspire.Cli/Commands/DescribeCommand.cs +++ b/src/Aspire.Cli/Commands/DescribeCommand.cs @@ -77,10 +77,7 @@ internal sealed class DescribeCommand : BaseCommand Description = DescribeCommandStrings.ResourceArgumentDescription, Arity = ArgumentArity.ZeroOrOne }; - private static readonly Option s_projectOption = new("--project") - { - Description = SharedCommandStrings.ProjectOptionDescription - }; + private static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription); private static readonly Option s_followOption = new("--follow", "-f") { Description = DescribeCommandStrings.FollowOptionDescription @@ -105,7 +102,7 @@ public DescribeCommand( _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); Arguments.Add(s_resourceArgument); - Options.Add(s_projectOption); + Options.Add(s_appHostOption); Options.Add(s_followOption); Options.Add(s_formatOption); } @@ -115,7 +112,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell using var activity = Telemetry.StartDiagnosticActivity(Name); var resourceName = parseResult.GetValue(s_resourceArgument); - var passedAppHostProjectFile = parseResult.GetValue(s_projectOption); + var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption); var follow = parseResult.GetValue(s_followOption); var format = parseResult.GetValue(s_formatOption); diff --git a/src/Aspire.Cli/Commands/ExecCommand.cs b/src/Aspire.Cli/Commands/ExecCommand.cs index c73cbe74ea4..093bd8157b8 100644 --- a/src/Aspire.Cli/Commands/ExecCommand.cs +++ b/src/Aspire.Cli/Commands/ExecCommand.cs @@ -27,10 +27,7 @@ internal class ExecCommand : BaseCommand private readonly ICliHostEnvironment _hostEnvironment; private readonly IFeatures _features; - private static readonly Option s_projectOption = new("--project") - { - Description = ExecCommandStrings.ProjectArgumentDescription - }; + private static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", ExecCommandStrings.ProjectArgumentDescription); private static readonly Option s_resourceOption = new("--resource", "-r") { Description = ExecCommandStrings.TargetResourceArgumentDescription @@ -69,7 +66,7 @@ public ExecCommand( _hostEnvironment = hostEnvironment; _features = features; - Options.Add(s_projectOption); + Options.Add(s_appHostOption); Options.Add(s_resourceOption); Options.Add(s_startResourceOption); Options.Add(s_workdirOption); @@ -130,7 +127,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { using var activity = Telemetry.StartDiagnosticActivity(this.Name); - var passedAppHostProjectFile = parseResult.GetValue(s_projectOption); + var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption); var effectiveAppHostProjectFile = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, createSettingsFile: true, cancellationToken); if (effectiveAppHostProjectFile is null) diff --git a/src/Aspire.Cli/Commands/LogsCommand.cs b/src/Aspire.Cli/Commands/LogsCommand.cs index 08568ff31f5..b6b3304c9f6 100644 --- a/src/Aspire.Cli/Commands/LogsCommand.cs +++ b/src/Aspire.Cli/Commands/LogsCommand.cs @@ -84,10 +84,7 @@ internal sealed class LogsCommand : BaseCommand Description = LogsCommandStrings.ResourceArgumentDescription, Arity = ArgumentArity.ZeroOrOne }; - private static readonly Option s_projectOption = new("--project") - { - Description = SharedCommandStrings.ProjectOptionDescription - }; + private static readonly OptionWithLegacy s_appHostOption = new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription); private static readonly Option s_followOption = new("--follow", "-f") { Description = LogsCommandStrings.FollowOptionDescription @@ -139,7 +136,7 @@ public LogsCommand( _connectionResolver = new AppHostConnectionResolver(backchannelMonitor, interactionService, executionContext, logger); Arguments.Add(s_resourceArgument); - Options.Add(s_projectOption); + Options.Add(s_appHostOption); Options.Add(s_followOption); Options.Add(s_formatOption); Options.Add(s_tailOption); @@ -151,7 +148,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell using var activity = Telemetry.StartDiagnosticActivity(Name); var resourceName = parseResult.GetValue(s_resourceArgument); - var passedAppHostProjectFile = parseResult.GetValue(s_projectOption); + var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption); var follow = parseResult.GetValue(s_followOption); var format = parseResult.GetValue(s_formatOption); var tail = parseResult.GetValue(s_tailOption); diff --git a/src/Aspire.Cli/Commands/OptionWithLegacy.cs b/src/Aspire.Cli/Commands/OptionWithLegacy.cs new file mode 100644 index 00000000000..224e1604b72 --- /dev/null +++ b/src/Aspire.Cli/Commands/OptionWithLegacy.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; + +namespace Aspire.Cli.Commands; + +/// +/// Pairs a visible option with a hidden legacy alias so that help text shows +/// only the new name while existing scripts that pass the old name continue +/// to work. +/// +internal sealed class OptionWithLegacy +{ + /// + /// Initializes a new option pair. + /// + /// The primary, visible option name (e.g. --apphost). + /// The hidden backward-compatible name (e.g. --project). + /// The description shown in help text for the visible option. + internal OptionWithLegacy(string optionName, string legacyName, string description) + { + InnerOption = new(optionName) { Description = description }; + LegacyOption = new(legacyName) { Hidden = true }; + } + + /// + /// Gets the visible option. + /// + internal Option InnerOption { get; } + + /// + /// Gets the hidden option kept for backward compatibility. + /// + internal Option LegacyOption { get; } + + /// + /// Gets the primary option name. + /// + internal string Name => InnerOption.Name; +} + +/// +/// Extension methods for working with . +/// +internal static class OptionWithLegacyExtensions +{ + /// + /// Registers both the visible and hidden options from an + /// on the specified option list. + /// + internal static void Add(this IList